- Add isAdmin: boolean to AdminMember interface (after color, before
hasCredential) — feeds the Phase 20 editor toggle initial state (D-02)
- Add updateMemberProfile(memberId, body) fetcher: PATCH /api/admin/members/:id,
credentials:include, redirect:manual, JSON body
- Maps 401/opaqueredirect → SessionExpiredError (existing convention)
- Maps 409/422 → Error('last-admin') sentinel (D-03 last-admin guard)
- Maps other non-ok → generic error
- All 50 tests pass; eslint + prettier + tsc --noEmit exit 0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
824 lines
26 KiB
TypeScript
824 lines
26 KiB
TypeScript
/**
|
|
* client.ts unit tests — Task 1 (TDD RED → GREEN)
|
|
*
|
|
* Tests for write client calls added in Plan 03-05:
|
|
* - createEvent: POSTs to /api/events/create with credentials:'include'
|
|
* - updateEvent: PATCHes /api/events/:uid/edit
|
|
* - fetchWritableCalendars: GETs /api/events/writable-calendars, returns WritableCalendar[]
|
|
* Tests for Zustand store new keys:
|
|
* - eventFormOpen: boolean, defaults false
|
|
* - eventFormMode: 'create'|'edit', defaults 'create'
|
|
* - eventFormUid: string|null, defaults null
|
|
* - setEventForm: setter for all three
|
|
*
|
|
* Plan 06-05: SessionExpiredError detection tests (D-11)
|
|
* - fetchEvents / createEvent / updateEvent / deleteEvent with opaqueredirect → SessionExpiredError
|
|
* - fetchEvents / createEvent / updateEvent / deleteEvent with 401 → SessionExpiredError
|
|
* - 500 response does NOT produce SessionExpiredError
|
|
* - fetchMe's existing opaqueredirect/401 path now throws SessionExpiredError (unified)
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
// ── Plan 06-05: SessionExpiredError detection tests ───────────────────────────
|
|
|
|
describe('SessionExpiredError — fetchEvents (06-05)', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('throws SessionExpiredError on opaqueredirect', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'opaqueredirect',
|
|
status: 0,
|
|
} as unknown as Response);
|
|
|
|
const { fetchEvents, SessionExpiredError } = await import('./client.js');
|
|
await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf(
|
|
SessionExpiredError,
|
|
);
|
|
});
|
|
|
|
it('throws SessionExpiredError on 401', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'basic',
|
|
status: 401,
|
|
} as unknown as Response);
|
|
|
|
const { fetchEvents, SessionExpiredError } = await import('./client.js');
|
|
await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf(
|
|
SessionExpiredError,
|
|
);
|
|
});
|
|
|
|
it('throws a generic Error (not SessionExpiredError) on 500', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'basic',
|
|
status: 500,
|
|
} as unknown as Response);
|
|
|
|
const { fetchEvents, SessionExpiredError } = await import('./client.js');
|
|
await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toSatisfy(
|
|
(e: unknown) => e instanceof Error && !(e instanceof SessionExpiredError),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('SessionExpiredError — createEvent (06-05)', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('throws SessionExpiredError on 401', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'basic',
|
|
status: 401,
|
|
} as unknown as Response);
|
|
|
|
const { createEvent, SessionExpiredError } = await import('./client.js');
|
|
await expect(
|
|
createEvent({
|
|
title: 'Test',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00',
|
|
end: '2026-06-15T11:00:00',
|
|
}),
|
|
).rejects.toBeInstanceOf(SessionExpiredError);
|
|
});
|
|
});
|
|
|
|
describe('SessionExpiredError — updateEvent (06-05)', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('throws SessionExpiredError on 401', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'basic',
|
|
status: 401,
|
|
} as unknown as Response);
|
|
|
|
const { updateEvent, SessionExpiredError } = await import('./client.js');
|
|
await expect(
|
|
updateEvent('uid-123', {
|
|
title: 'Test',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00',
|
|
end: '2026-06-15T11:00:00',
|
|
}),
|
|
).rejects.toBeInstanceOf(SessionExpiredError);
|
|
});
|
|
});
|
|
|
|
describe('SessionExpiredError — deleteEvent (06-05)', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('throws SessionExpiredError on 401', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'basic',
|
|
status: 401,
|
|
} as unknown as Response);
|
|
|
|
const { deleteEvent, SessionExpiredError } = await import('./client.js');
|
|
await expect(deleteEvent('uid-to-delete')).rejects.toBeInstanceOf(SessionExpiredError);
|
|
});
|
|
});
|
|
|
|
describe('SessionExpiredError — fetchMe unified (06-05)', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('throws SessionExpiredError (instanceof) on opaqueredirect — unified with other wrappers', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'opaqueredirect',
|
|
status: 0,
|
|
} as unknown as Response);
|
|
|
|
const { fetchMe, SessionExpiredError } = await import('./client.js');
|
|
await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError);
|
|
});
|
|
|
|
it('throws SessionExpiredError on 401 — unified', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'basic',
|
|
status: 401,
|
|
} as unknown as Response);
|
|
|
|
const { fetchMe, SessionExpiredError } = await import('./client.js');
|
|
await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError);
|
|
});
|
|
});
|
|
|
|
// ── createEvent tests ─────────────────────────────────────────────────────────
|
|
|
|
describe('createEvent', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('POSTs to /api/events/create with credentials:include', async () => {
|
|
const mockFetch = vi.mocked(fetch);
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => ({ uid: 'test-uid-123' }),
|
|
} as unknown as Response);
|
|
|
|
const { createEvent } = await import('./client.js');
|
|
const payload = {
|
|
title: 'Team Meeting',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00',
|
|
end: '2026-06-15T11:00:00',
|
|
recurrence: 'none' as const,
|
|
};
|
|
await createEvent(payload);
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/api/events/create',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns 'any' (Vitest asymmetric matcher); safe in assertion context
|
|
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('returns { uid } from 202 response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => ({ uid: 'returned-uid-456' }),
|
|
} as unknown as Response);
|
|
|
|
const { createEvent } = await import('./client.js');
|
|
const result = await createEvent({
|
|
title: 'Test',
|
|
allDay: true,
|
|
start: '2026-06-20',
|
|
end: '2026-06-20',
|
|
recurrence: 'none' as const,
|
|
});
|
|
|
|
expect(result).toEqual({ uid: 'returned-uid-456' });
|
|
});
|
|
|
|
it('throws on non-ok response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
json: () => ({ error: 'Bad Request' }),
|
|
} as unknown as Response);
|
|
|
|
const { createEvent } = await import('./client.js');
|
|
await expect(
|
|
createEvent({
|
|
title: '',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00',
|
|
end: '2026-06-15T11:00:00',
|
|
recurrence: 'none' as const,
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
// ── updateEvent tests ─────────────────────────────────────────────────────────
|
|
|
|
describe('updateEvent', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('PATCHes /api/events/:uid/edit', async () => {
|
|
const mockFetch = vi.mocked(fetch);
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => ({ uid: 'edit-uid-789' }),
|
|
} as unknown as Response);
|
|
|
|
const { updateEvent } = await import('./client.js');
|
|
await updateEvent('edit-uid-789', {
|
|
title: 'Updated Meeting',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00',
|
|
end: '2026-06-15T11:00:00',
|
|
recurrence: 'weekly' as const,
|
|
});
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/api/events/edit-uid-789/edit',
|
|
expect.objectContaining({
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('returns { uid } on success', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => ({ uid: 'patched-uid' }),
|
|
} as unknown as Response);
|
|
|
|
const { updateEvent } = await import('./client.js');
|
|
const result = await updateEvent('some-uid', {
|
|
title: 'Patched',
|
|
allDay: false,
|
|
start: '2026-06-15T10:00:00',
|
|
end: '2026-06-15T11:00:00',
|
|
recurrence: 'none' as const,
|
|
});
|
|
|
|
expect(result).toEqual({ uid: 'patched-uid' });
|
|
});
|
|
});
|
|
|
|
// ── fetchWritableCalendars tests ──────────────────────────────────────────────
|
|
|
|
describe('fetchWritableCalendars', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('GETs /api/events/writable-calendars with credentials:include', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => ({
|
|
calendars: [
|
|
{
|
|
url: 'https://caldav.fastmail.com/cal1',
|
|
displayName: 'My Calendar',
|
|
color: '#4A90D9',
|
|
isShared: false,
|
|
},
|
|
],
|
|
}),
|
|
} as unknown as Response);
|
|
|
|
const { fetchWritableCalendars } = await import('./client.js');
|
|
await fetchWritableCalendars();
|
|
|
|
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
|
'/api/events/writable-calendars',
|
|
expect.objectContaining({ credentials: 'include' }),
|
|
);
|
|
});
|
|
|
|
it('returns WritableCalendar[] from the calendars envelope', async () => {
|
|
const mockCalendars = [
|
|
{
|
|
url: 'https://caldav.fastmail.com/cal1',
|
|
displayName: 'My Calendar',
|
|
color: '#4A90D9',
|
|
isShared: false,
|
|
},
|
|
{
|
|
url: 'https://caldav.fastmail.com/cal2',
|
|
displayName: 'Family',
|
|
color: '#F25C7A',
|
|
isShared: true,
|
|
},
|
|
];
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => ({ calendars: mockCalendars }),
|
|
} as unknown as Response);
|
|
|
|
const { fetchWritableCalendars } = await import('./client.js');
|
|
const result = await fetchWritableCalendars();
|
|
|
|
expect(result).toEqual(mockCalendars);
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
|
|
it('throws on non-ok response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
} as unknown as Response);
|
|
|
|
const { fetchWritableCalendars } = await import('./client.js');
|
|
await expect(fetchWritableCalendars()).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
// ── deleteEvent tests ─────────────────────────────────────────────────────────
|
|
|
|
describe('deleteEvent', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('DELETEs /api/events/:uid with credentials:include', async () => {
|
|
const mockFetch = vi.mocked(fetch);
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => ({}),
|
|
} as unknown as Response);
|
|
|
|
const { deleteEvent } = await import('./client.js');
|
|
await deleteEvent('uid-to-delete');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/api/events/uid-to-delete',
|
|
expect.objectContaining({
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('resolves void on success (204/202)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => ({}),
|
|
} as unknown as Response);
|
|
|
|
const { deleteEvent } = await import('./client.js');
|
|
const result = await deleteEvent('uid-abc');
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('throws on non-ok response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
} as unknown as Response);
|
|
|
|
const { deleteEvent } = await import('./client.js');
|
|
await expect(deleteEvent('missing-uid')).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
// ── fetchSyncStatus tests ─────────────────────────────────────────────────────
|
|
|
|
describe('fetchSyncStatus', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('GETs /api/events/sync-status?uid= with credentials:include', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => ({ uid: 'my-uid', status: 'pending' }),
|
|
} as unknown as Response);
|
|
|
|
const { fetchSyncStatus } = await import('./client.js');
|
|
await fetchSyncStatus('my-uid');
|
|
|
|
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
|
'/api/events/sync-status?uid=my-uid',
|
|
expect.objectContaining({ credentials: 'include' }),
|
|
);
|
|
});
|
|
|
|
it('returns SyncStatus object with uid and status', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => ({ uid: 'test-uid', status: 'done' }),
|
|
} as unknown as Response);
|
|
|
|
const { fetchSyncStatus } = await import('./client.js');
|
|
const result = await fetchSyncStatus('test-uid');
|
|
expect(result).toEqual({ uid: 'test-uid', status: 'done' });
|
|
});
|
|
|
|
it('returns SyncStatus with error field for failed status', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => ({ uid: 'fail-uid', status: 'failed', error: '412 Conflict' }),
|
|
} as unknown as Response);
|
|
|
|
const { fetchSyncStatus } = await import('./client.js');
|
|
const result = await fetchSyncStatus('fail-uid');
|
|
expect(result).toEqual({ uid: 'fail-uid', status: 'failed', error: '412 Conflict' });
|
|
});
|
|
|
|
it('throws on non-ok response', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
} as unknown as Response);
|
|
|
|
const { fetchSyncStatus } = await import('./client.js');
|
|
await expect(fetchSyncStatus('any-uid')).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
// ── fetchMe tests ─────────────────────────────────────────────────────────────
|
|
|
|
describe('fetchMe', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('requests /api/me with credentials:include AND redirect:manual', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
type: 'basic',
|
|
status: 200,
|
|
json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
|
|
} as unknown as Response);
|
|
|
|
const { fetchMe } = await import('./client.js');
|
|
await fetchMe();
|
|
|
|
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
|
'/api/me',
|
|
expect.objectContaining({ credentials: 'include', redirect: 'manual' }),
|
|
);
|
|
});
|
|
|
|
it('returns the user envelope on 200', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
type: 'basic',
|
|
status: 200,
|
|
json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
|
|
} as unknown as Response);
|
|
|
|
const { fetchMe } = await import('./client.js');
|
|
const result = await fetchMe();
|
|
expect(result).toEqual({ user: { id: 2, displayName: 'Me', color: '#E8734A' } });
|
|
});
|
|
|
|
it('throws on an opaqueredirect (OIDC guard 302 to Authelia) — the auth-required signal', async () => {
|
|
// redirect:'manual' surfaces a 302 as an opaqueredirect with status 0 and
|
|
// ok:false instead of following it (which would hang the fetch).
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'opaqueredirect',
|
|
status: 0,
|
|
json: () => {
|
|
throw new Error('body not accessible on opaqueredirect');
|
|
},
|
|
} as unknown as Response);
|
|
|
|
const { fetchMe } = await import('./client.js');
|
|
await expect(fetchMe()).rejects.toThrow(/authentication required/i);
|
|
});
|
|
|
|
it('throws on a 401', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'basic',
|
|
status: 401,
|
|
} as unknown as Response);
|
|
|
|
const { fetchMe } = await import('./client.js');
|
|
await expect(fetchMe()).rejects.toThrow(/authentication required/i);
|
|
});
|
|
});
|
|
|
|
// ── Zustand store new keys tests ──────────────────────────────────────────────
|
|
|
|
describe('calendarStore — eventForm keys', () => {
|
|
it('eventFormOpen defaults to false', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
const state = useCalendarStore.getState();
|
|
expect(state.eventFormOpen).toBe(false);
|
|
});
|
|
|
|
it('eventFormMode defaults to create', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
const state = useCalendarStore.getState();
|
|
expect(state.eventFormMode).toBe('create');
|
|
});
|
|
|
|
it('eventFormUid defaults to null', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
const state = useCalendarStore.getState();
|
|
expect(state.eventFormUid).toBeNull();
|
|
});
|
|
|
|
it('setEventForm(true, edit, some-uid) updates all three keys', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
useCalendarStore.getState().setEventForm(true, 'edit', 'some-uid');
|
|
const state = useCalendarStore.getState();
|
|
expect(state.eventFormOpen).toBe(true);
|
|
expect(state.eventFormMode).toBe('edit');
|
|
expect(state.eventFormUid).toBe('some-uid');
|
|
});
|
|
|
|
it('setEventForm(false) closes the form and preserves other defaults', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
// Open first
|
|
useCalendarStore.getState().setEventForm(true, 'edit', 'uid-abc');
|
|
// Close
|
|
useCalendarStore.getState().setEventForm(false);
|
|
const state = useCalendarStore.getState();
|
|
expect(state.eventFormOpen).toBe(false);
|
|
});
|
|
|
|
it('setEventForm(true) with no mode/uid defaults to create/null', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
useCalendarStore.getState().setEventForm(true);
|
|
const state = useCalendarStore.getState();
|
|
expect(state.eventFormOpen).toBe(true);
|
|
expect(state.eventFormMode).toBe('create');
|
|
expect(state.eventFormUid).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('calendarStore — delete/sync keys', () => {
|
|
it('deleteDialogOpen defaults to false', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
const state = useCalendarStore.getState();
|
|
expect(state.deleteDialogOpen).toBe(false);
|
|
});
|
|
|
|
it('deleteDialogUid defaults to null', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
const state = useCalendarStore.getState();
|
|
expect(state.deleteDialogUid).toBeNull();
|
|
});
|
|
|
|
it('lastSyncedUid defaults to null', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
const state = useCalendarStore.getState();
|
|
expect(state.lastSyncedUid).toBeNull();
|
|
});
|
|
|
|
it('setDeleteDialog(true, uid) opens dialog with uid', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
useCalendarStore.getState().setDeleteDialog(true, 'delete-uid-999');
|
|
const state = useCalendarStore.getState();
|
|
expect(state.deleteDialogOpen).toBe(true);
|
|
expect(state.deleteDialogUid).toBe('delete-uid-999');
|
|
});
|
|
|
|
it('setDeleteDialog(false) closes dialog', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
useCalendarStore.getState().setDeleteDialog(true, 'some-uid');
|
|
useCalendarStore.getState().setDeleteDialog(false);
|
|
const state = useCalendarStore.getState();
|
|
expect(state.deleteDialogOpen).toBe(false);
|
|
});
|
|
|
|
it('setLastSyncedUid sets the UID', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
useCalendarStore.getState().setLastSyncedUid('synced-uid-abc');
|
|
const state = useCalendarStore.getState();
|
|
expect(state.lastSyncedUid).toBe('synced-uid-abc');
|
|
});
|
|
|
|
it('setLastSyncedUid(null) clears the UID', async () => {
|
|
const { useCalendarStore } = await import('../store/calendarStore.js');
|
|
useCalendarStore.getState().setLastSyncedUid('some-uid');
|
|
useCalendarStore.getState().setLastSyncedUid(null);
|
|
const state = useCalendarStore.getState();
|
|
expect(state.lastSyncedUid).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── Phase 19 (AUTH-LOCAL-08): admin reset-password URL contract ───────────────
|
|
// Regression guard for the post-merge blocker: client.ts targeted
|
|
// /members/:id/reset-password but the API registers /members/:id/password, so the
|
|
// Admin reset sheet 404'd in production. Unit tests on both sides missed it (API
|
|
// tests hit the real path directly; PWA tests mock the fetcher). Pin the exact URL.
|
|
describe('fetchAdminResetPassword — URL contract (Phase 19, AUTH-LOCAL-08)', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('POSTs to /api/admin/members/:id/password (must match admin.ts route)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
type: 'basic',
|
|
status: 200,
|
|
} as unknown as Response);
|
|
|
|
const { fetchAdminResetPassword } = await import('./client.js');
|
|
await fetchAdminResetPassword(7, 'new-password-123');
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'/api/admin/members/7/password',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── Phase 20 (Plan 20-02): updateMemberProfile + AdminMember.isAdmin ─────────
|
|
// TDD RED: these tests MUST fail before the implementation is added to client.ts.
|
|
|
|
describe('updateMemberProfile — URL + verb contract (Phase 20, Plan 20-02)', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('PATCHes /api/admin/members/:id (must match PATCH /members/:id in admin.ts)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
type: 'basic',
|
|
status: 200,
|
|
} as unknown as Response);
|
|
|
|
const { updateMemberProfile } = await import('./client.js');
|
|
await updateMemberProfile(7, { displayName: 'Alice' });
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'/api/admin/members/7',
|
|
expect.objectContaining({ method: 'PATCH' }),
|
|
);
|
|
});
|
|
|
|
it('sends credentials:include and redirect:manual', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
type: 'basic',
|
|
status: 200,
|
|
} as unknown as Response);
|
|
|
|
const { updateMemberProfile } = await import('./client.js');
|
|
await updateMemberProfile(3, { isAdmin: true });
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'/api/admin/members/3',
|
|
expect.objectContaining({
|
|
credentials: 'include',
|
|
redirect: 'manual',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('resolves void on 200', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: true,
|
|
type: 'basic',
|
|
status: 200,
|
|
} as unknown as Response);
|
|
|
|
const { updateMemberProfile } = await import('./client.js');
|
|
const result = await updateMemberProfile(7, { displayName: 'Bob' });
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('throws SessionExpiredError on opaqueredirect', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'opaqueredirect',
|
|
status: 0,
|
|
} as unknown as Response);
|
|
|
|
const { updateMemberProfile, SessionExpiredError } = await import('./client.js');
|
|
await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toBeInstanceOf(
|
|
SessionExpiredError,
|
|
);
|
|
});
|
|
|
|
it('throws SessionExpiredError on 401', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'basic',
|
|
status: 401,
|
|
} as unknown as Response);
|
|
|
|
const { updateMemberProfile, SessionExpiredError } = await import('./client.js');
|
|
await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toBeInstanceOf(
|
|
SessionExpiredError,
|
|
);
|
|
});
|
|
|
|
it('throws Error("last-admin") on 409 (last-admin demotion sentinel)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'basic',
|
|
status: 409,
|
|
} as unknown as Response);
|
|
|
|
const { updateMemberProfile } = await import('./client.js');
|
|
await expect(updateMemberProfile(7, { isAdmin: false })).rejects.toThrow('last-admin');
|
|
});
|
|
|
|
it('throws Error("last-admin") on 422 as well (server may return either)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'basic',
|
|
status: 422,
|
|
} as unknown as Response);
|
|
|
|
const { updateMemberProfile } = await import('./client.js');
|
|
await expect(updateMemberProfile(7, { isAdmin: false })).rejects.toThrow('last-admin');
|
|
});
|
|
|
|
it('throws a generic error on any other non-ok status (not last-admin sentinel)', async () => {
|
|
vi.mocked(fetch).mockResolvedValueOnce({
|
|
ok: false,
|
|
type: 'basic',
|
|
status: 500,
|
|
} as unknown as Response);
|
|
|
|
const { updateMemberProfile, SessionExpiredError } = await import('./client.js');
|
|
await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toSatisfy(
|
|
(e: unknown) =>
|
|
e instanceof Error && !(e instanceof SessionExpiredError) && e.message !== 'last-admin',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('AdminMember.isAdmin field (Phase 20, Plan 20-02)', () => {
|
|
it('AdminMember interface has isAdmin: boolean (compile-time type check via runtime shape)', () => {
|
|
// Construct a conforming object — TypeScript will error at compile time if
|
|
// isAdmin is missing from the AdminMember interface (caught by tsc --noEmit).
|
|
const member: import('./client.js').AdminMember = {
|
|
id: 1,
|
|
displayName: 'Test',
|
|
color: '#abc',
|
|
isAdmin: true,
|
|
hasCredential: false,
|
|
hasLocalCredential: false,
|
|
};
|
|
expect(member.isAdmin).toBe(true);
|
|
});
|
|
});
|