style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
+287
-258
@@ -18,175 +18,189 @@
|
||||
* - fetchMe's existing opaqueredirect/401 path now throws SessionExpiredError (unified)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
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())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('throws SessionExpiredError on opaqueredirect', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
type: 'opaqueredirect',
|
||||
status: 0,
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchEvents, SessionExpiredError } = await import('./client.js')
|
||||
await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf(SessionExpiredError)
|
||||
})
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchEvents, SessionExpiredError } = await import('./client.js')
|
||||
await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf(SessionExpiredError)
|
||||
})
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchEvents, SessionExpiredError } = await import('./client.js')
|
||||
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())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('throws SessionExpiredError on 401', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
type: 'basic',
|
||||
status: 401,
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { createEvent, SessionExpiredError } = await import('./client.js')
|
||||
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)
|
||||
})
|
||||
})
|
||||
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())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('throws SessionExpiredError on 401', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
type: 'basic',
|
||||
status: 401,
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { updateEvent, SessionExpiredError } = await import('./client.js')
|
||||
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)
|
||||
})
|
||||
})
|
||||
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())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('throws SessionExpiredError on 401', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
type: 'basic',
|
||||
status: 401,
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { deleteEvent, SessionExpiredError } = await import('./client.js')
|
||||
await expect(deleteEvent('uid-to-delete')).rejects.toBeInstanceOf(SessionExpiredError)
|
||||
})
|
||||
})
|
||||
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())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchMe, SessionExpiredError } = await import('./client.js')
|
||||
await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError)
|
||||
})
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchMe, SessionExpiredError } = await import('./client.js')
|
||||
await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError)
|
||||
})
|
||||
})
|
||||
const { fetchMe, SessionExpiredError } = await import('./client.js');
|
||||
await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createEvent tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('createEvent', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('POSTs to /api/events/create with credentials:include', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
const mockFetch = vi.mocked(fetch);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => ({ uid: 'test-uid-123' }),
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { createEvent } = await import('./client.js')
|
||||
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)
|
||||
};
|
||||
await createEvent(payload);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/events/create',
|
||||
@@ -196,35 +210,35 @@ describe('createEvent', () => {
|
||||
// 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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { createEvent } = await import('./client.js')
|
||||
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' })
|
||||
})
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { createEvent } = await import('./client.js')
|
||||
const { createEvent } = await import('./client.js');
|
||||
await expect(
|
||||
createEvent({
|
||||
title: '',
|
||||
@@ -233,35 +247,35 @@ describe('createEvent', () => {
|
||||
end: '2026-06-15T11:00:00',
|
||||
recurrence: 'none' as const,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateEvent tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateEvent', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('PATCHes /api/events/:uid/edit', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
const mockFetch = vi.mocked(fetch);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => ({ uid: 'edit-uid-789' }),
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { updateEvent } = await import('./client.js')
|
||||
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',
|
||||
@@ -269,104 +283,119 @@ describe('updateEvent', () => {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
}),
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns { uid } on success', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => ({ uid: 'patched-uid' }),
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { updateEvent } = await import('./client.js')
|
||||
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' })
|
||||
})
|
||||
})
|
||||
expect(result).toEqual({ uid: 'patched-uid' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── fetchWritableCalendars tests ──────────────────────────────────────────────
|
||||
|
||||
describe('fetchWritableCalendars', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
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 },
|
||||
{
|
||||
url: 'https://caldav.fastmail.com/cal1',
|
||||
displayName: 'My Calendar',
|
||||
color: '#4A90D9',
|
||||
isShared: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchWritableCalendars } = await import('./client.js')
|
||||
await fetchWritableCalendars()
|
||||
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 },
|
||||
]
|
||||
{
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchWritableCalendars } = await import('./client.js')
|
||||
const result = await fetchWritableCalendars()
|
||||
const { fetchWritableCalendars } = await import('./client.js');
|
||||
const result = await fetchWritableCalendars();
|
||||
|
||||
expect(result).toEqual(mockCalendars)
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchWritableCalendars } = await import('./client.js')
|
||||
await expect(fetchWritableCalendars()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
const { fetchWritableCalendars } = await import('./client.js');
|
||||
await expect(fetchWritableCalendars()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteEvent tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('deleteEvent', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('DELETEs /api/events/:uid with credentials:include', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
const mockFetch = vi.mocked(fetch);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => ({}),
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { deleteEvent } = await import('./client.js')
|
||||
await deleteEvent('uid-to-delete')
|
||||
const { deleteEvent } = await import('./client.js');
|
||||
await deleteEvent('uid-to-delete');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/events/uid-to-delete',
|
||||
@@ -374,98 +403,98 @@ describe('deleteEvent', () => {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
}),
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves void on success (204/202)', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => ({}),
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { deleteEvent } = await import('./client.js')
|
||||
const result = await deleteEvent('uid-abc')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { deleteEvent } = await import('./client.js')
|
||||
await expect(deleteEvent('missing-uid')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
const { deleteEvent } = await import('./client.js');
|
||||
await expect(deleteEvent('missing-uid')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── fetchSyncStatus tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('fetchSyncStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchSyncStatus } = await import('./client.js')
|
||||
await fetchSyncStatus('my-uid')
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchSyncStatus } = await import('./client.js')
|
||||
const result = await fetchSyncStatus('test-uid')
|
||||
expect(result).toEqual({ uid: 'test-uid', status: 'done' })
|
||||
})
|
||||
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)
|
||||
} 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' })
|
||||
})
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchSyncStatus } = await import('./client.js')
|
||||
await expect(fetchSyncStatus('any-uid')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
const { fetchSyncStatus } = await import('./client.js');
|
||||
await expect(fetchSyncStatus('any-uid')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── fetchMe tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('fetchMe', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('requests /api/me with credentials:include AND redirect:manual', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
@@ -473,16 +502,16 @@ describe('fetchMe', () => {
|
||||
type: 'basic',
|
||||
status: 200,
|
||||
json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchMe } = await import('./client.js')
|
||||
await fetchMe()
|
||||
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({
|
||||
@@ -490,12 +519,12 @@ describe('fetchMe', () => {
|
||||
type: 'basic',
|
||||
status: 200,
|
||||
json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchMe } = await import('./client.js')
|
||||
const result = await fetchMe()
|
||||
expect(result).toEqual({ user: { id: 2, displayName: 'Me', color: '#E8734A' } })
|
||||
})
|
||||
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
|
||||
@@ -505,123 +534,123 @@ describe('fetchMe', () => {
|
||||
type: 'opaqueredirect',
|
||||
status: 0,
|
||||
json: () => {
|
||||
throw new Error('body not accessible on opaqueredirect')
|
||||
throw new Error('body not accessible on opaqueredirect');
|
||||
},
|
||||
} as unknown as Response)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchMe } = await import('./client.js')
|
||||
await expect(fetchMe()).rejects.toThrow(/authentication required/i)
|
||||
})
|
||||
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)
|
||||
} as unknown as Response);
|
||||
|
||||
const { fetchMe } = await import('./client.js')
|
||||
await expect(fetchMe()).rejects.toThrow(/authentication required/i)
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
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')
|
||||
})
|
||||
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()
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
const { useCalendarStore } = await import('../store/calendarStore.js');
|
||||
// Open first
|
||||
useCalendarStore.getState().setEventForm(true, 'edit', 'uid-abc')
|
||||
useCalendarStore.getState().setEventForm(true, 'edit', 'uid-abc');
|
||||
// Close
|
||||
useCalendarStore.getState().setEventForm(false)
|
||||
const state = useCalendarStore.getState()
|
||||
expect(state.eventFormOpen).toBe(false)
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
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()
|
||||
})
|
||||
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()
|
||||
})
|
||||
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')
|
||||
})
|
||||
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)
|
||||
})
|
||||
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')
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
const { useCalendarStore } = await import('../store/calendarStore.js');
|
||||
useCalendarStore.getState().setLastSyncedUid('some-uid');
|
||||
useCalendarStore.getState().setLastSyncedUid(null);
|
||||
const state = useCalendarStore.getState();
|
||||
expect(state.lastSyncedUid).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+67
-70
@@ -31,10 +31,10 @@
|
||||
* breaks the prototype chain.
|
||||
*/
|
||||
export class SessionExpiredError extends Error {
|
||||
readonly name = 'SessionExpiredError'
|
||||
readonly name = 'SessionExpiredError';
|
||||
constructor() {
|
||||
super('Session expired — re-authentication required')
|
||||
Object.setPrototypeOf(this, SessionExpiredError.prototype)
|
||||
super('Session expired — re-authentication required');
|
||||
Object.setPrototypeOf(this, SessionExpiredError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,23 +50,23 @@ export class SessionExpiredError extends Error {
|
||||
*/
|
||||
function handleAuthResponse(res: Response, label: string): void {
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) {
|
||||
throw new SessionExpiredError()
|
||||
throw new SessionExpiredError();
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`${label} failed: ${res.status}`)
|
||||
throw new Error(`${label} failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── /api/me ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MeUser {
|
||||
id: number
|
||||
displayName: string | null
|
||||
color: string
|
||||
id: number;
|
||||
displayName: string | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: MeUser
|
||||
user: MeUser;
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<MeResponse> {
|
||||
@@ -80,11 +80,11 @@ export async function fetchMe(): Promise<MeResponse> {
|
||||
const res = await fetch('/api/me', {
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
})
|
||||
});
|
||||
|
||||
handleAuthResponse(res, 'GET /api/me')
|
||||
handleAuthResponse(res, 'GET /api/me');
|
||||
|
||||
return res.json() as Promise<MeResponse>
|
||||
return res.json() as Promise<MeResponse>;
|
||||
}
|
||||
|
||||
// ── /api/events (windowed — Phase 2) ─────────────────────────────────────
|
||||
@@ -104,35 +104,35 @@ export async function fetchMe(): Promise<MeResponse> {
|
||||
* to build the Schedule-X calendarId that keys into buildCalendarConfig().
|
||||
*/
|
||||
export interface CalendarOccurrence {
|
||||
id: string // `ev-<sanitized-uid>-<epochMs>` — stable identity (server: expand.ts makeOccurrenceId)
|
||||
uid: string
|
||||
calendarId: number // DB calendar-row id — do NOT use for SX calendarId routing
|
||||
calendarName: string
|
||||
ownerUserId: number // DB user id — the correct Schedule-X routing key
|
||||
id: string; // `ev-<sanitized-uid>-<epochMs>` — stable identity (server: expand.ts makeOccurrenceId)
|
||||
uid: string;
|
||||
calendarId: number; // DB calendar-row id — do NOT use for SX calendarId routing
|
||||
calendarName: string;
|
||||
ownerUserId: number; // DB user id — the correct Schedule-X routing key
|
||||
/**
|
||||
* Display name of the calendar owner (users.displayName from the API).
|
||||
* Null when the user has not configured a display name.
|
||||
* Popover renders: isShared ? 'Family' : (ownerName ?? calendarName)
|
||||
*/
|
||||
ownerName: string | null
|
||||
color: string // hex from users.color or shared-family constant
|
||||
isShared: boolean // true → 'shared' slot; false → String(ownerUserId) slot
|
||||
title: string
|
||||
start: string // 'YYYY-MM-DD' for allDay:true; ISO 8601 with IANA tz for timed
|
||||
end: string
|
||||
allDay: boolean
|
||||
location: string | null
|
||||
description: string | null
|
||||
ownerName: string | null;
|
||||
color: string; // hex from users.color or shared-family constant
|
||||
isShared: boolean; // true → 'shared' slot; false → String(ownerUserId) slot
|
||||
title: string;
|
||||
start: string; // 'YYYY-MM-DD' for allDay:true; ISO 8601 with IANA tz for timed
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
location: string | null;
|
||||
description: string | null;
|
||||
/**
|
||||
* True when this occurrence belongs to a recurring series (has an RRULE).
|
||||
* Mirrors CalendarOccurrence.hasRrule in apps/api/src/broker/expand.ts — must
|
||||
* stay in sync with the server type (Pitfall 4 — atomic mirror, Plan 06-05).
|
||||
*/
|
||||
hasRrule: boolean
|
||||
hasRrule: boolean;
|
||||
}
|
||||
|
||||
export interface OccurrencesResponse {
|
||||
occurrences: CalendarOccurrence[]
|
||||
occurrences: CalendarOccurrence[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,18 +144,15 @@ export interface OccurrencesResponse {
|
||||
* @param start ISO date string 'YYYY-MM-DD' — window start (inclusive)
|
||||
* @param end ISO date string 'YYYY-MM-DD' — window end (exclusive)
|
||||
*/
|
||||
export async function fetchEvents(
|
||||
start: string,
|
||||
end: string,
|
||||
): Promise<OccurrencesResponse> {
|
||||
export async function fetchEvents(start: string, end: string): Promise<OccurrencesResponse> {
|
||||
const res = await fetch(`/api/events?start=${start}&end=${end}`, {
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
})
|
||||
});
|
||||
|
||||
handleAuthResponse(res, 'GET /api/events')
|
||||
handleAuthResponse(res, 'GET /api/events');
|
||||
|
||||
return res.json() as Promise<OccurrencesResponse>
|
||||
return res.json() as Promise<OccurrencesResponse>;
|
||||
}
|
||||
|
||||
// Phase 1 legacy types (CalendarEvent, EventsResponse, fetchEventsLegacy) removed in Plan 05
|
||||
@@ -167,40 +164,40 @@ export async function fetchEvents(
|
||||
* Recurrence presets supported by the EventForm.
|
||||
* Maps 1:1 to the RRULE frequency values the API accepts.
|
||||
*/
|
||||
export type RecurrencePreset = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
export type RecurrencePreset = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
|
||||
/**
|
||||
* Payload for creating or updating a calendar event.
|
||||
* Mirrors the Zod schema on POST /api/events/create and PATCH /api/events/:uid/edit.
|
||||
*/
|
||||
export interface CreateEventPayload {
|
||||
title: string
|
||||
allDay: boolean
|
||||
start: string // 'YYYY-MM-DD' for allDay; ISO 8601 for timed
|
||||
end: string // same format as start
|
||||
title: string;
|
||||
allDay: boolean;
|
||||
start: string; // 'YYYY-MM-DD' for allDay; ISO 8601 for timed
|
||||
end: string; // same format as start
|
||||
// WR-01: optional. CREATE always sends it; EDIT omits it so the API/worker preserve
|
||||
// the event's existing RRULE (the occurrence contract does not expose recurrence, so
|
||||
// the form cannot echo it back without silently resetting it to 'none').
|
||||
recurrence?: RecurrencePreset
|
||||
recurrence?: RecurrencePreset;
|
||||
/**
|
||||
* RRULE UNTIL date (D-06). ISO 'YYYY-MM-DD' string. Only sent when recurrence !== 'none'
|
||||
* and the user selects the "On date" bound. Mutually exclusive with recurrenceCount.
|
||||
* The outbox worker converts this to RRULE UNTIL format (DATE for all-day, DATETIME UTC for timed).
|
||||
*/
|
||||
recurrenceUntil?: string
|
||||
recurrenceUntil?: string;
|
||||
/**
|
||||
* RRULE COUNT (D-06). Integer >= 1. Only sent when recurrence !== 'none' and the user
|
||||
* selects the "After N times" bound. Mutually exclusive with recurrenceUntil.
|
||||
*/
|
||||
recurrenceCount?: number
|
||||
location?: string
|
||||
description?: string
|
||||
calendarUrl?: string // omit to use the member's default writable calendar (D-01)
|
||||
recurrenceCount?: number;
|
||||
location?: string;
|
||||
description?: string;
|
||||
calendarUrl?: string; // omit to use the member's default writable calendar (D-01)
|
||||
}
|
||||
|
||||
/** Response from POST /api/events/create and PATCH /api/events/:uid/edit */
|
||||
export interface CreateEventResponse {
|
||||
uid: string
|
||||
uid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,10 +206,10 @@ export interface CreateEventResponse {
|
||||
* The client never derives writability — it reads this endpoint verbatim.
|
||||
*/
|
||||
export interface WritableCalendar {
|
||||
url: string
|
||||
displayName: string
|
||||
color: string
|
||||
isShared: boolean
|
||||
url: string;
|
||||
displayName: string;
|
||||
color: string;
|
||||
isShared: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,11 +225,11 @@ export async function createEvent(payload: CreateEventPayload): Promise<CreateEv
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
});
|
||||
|
||||
handleAuthResponse(res, 'POST /api/events/create')
|
||||
handleAuthResponse(res, 'POST /api/events/create');
|
||||
|
||||
return res.json() as Promise<CreateEventResponse>
|
||||
return res.json() as Promise<CreateEventResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,11 +248,11 @@ export async function updateEvent(
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
});
|
||||
|
||||
handleAuthResponse(res, `PATCH /api/events/${uid}/edit`)
|
||||
handleAuthResponse(res, `PATCH /api/events/${uid}/edit`);
|
||||
|
||||
return res.json() as Promise<CreateEventResponse>
|
||||
return res.json() as Promise<CreateEventResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,9 +266,9 @@ export async function deleteEvent(uid: string): Promise<void> {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
})
|
||||
});
|
||||
|
||||
handleAuthResponse(res, `DELETE /api/events/${uid}`)
|
||||
handleAuthResponse(res, `DELETE /api/events/${uid}`);
|
||||
}
|
||||
|
||||
// ── /api/events/sync-status (Plan 03-06) ─────────────────────────────────────
|
||||
@@ -280,17 +277,17 @@ export async function deleteEvent(uid: string): Promise<void> {
|
||||
* Status values for an outbox write operation.
|
||||
* Mirrors the calendarOutbox.status enum on the server.
|
||||
*/
|
||||
export type SyncStatusValue = 'pending' | 'done' | 'failed' | 'dead'
|
||||
export type SyncStatusValue = 'pending' | 'done' | 'failed' | 'dead';
|
||||
|
||||
/**
|
||||
* Response from GET /api/events/sync-status?uid=
|
||||
* The server returns the current outbox status for the given UID + member.
|
||||
*/
|
||||
export interface SyncStatus {
|
||||
uid: string
|
||||
status: SyncStatusValue
|
||||
uid: string;
|
||||
status: SyncStatusValue;
|
||||
/** Present on failed status — may contain '412' prefix for conflict detection. */
|
||||
error?: string
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,11 +300,11 @@ export async function fetchSyncStatus(uid: string): Promise<SyncStatus> {
|
||||
const res = await fetch(`/api/events/sync-status?uid=${uid}`, {
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
})
|
||||
});
|
||||
|
||||
handleAuthResponse(res, 'GET /api/events/sync-status')
|
||||
handleAuthResponse(res, 'GET /api/events/sync-status');
|
||||
|
||||
return res.json() as Promise<SyncStatus>
|
||||
return res.json() as Promise<SyncStatus>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,10 +318,10 @@ export async function fetchWritableCalendars(): Promise<WritableCalendar[]> {
|
||||
const res = await fetch('/api/events/writable-calendars', {
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
})
|
||||
});
|
||||
|
||||
handleAuthResponse(res, 'GET /api/events/writable-calendars')
|
||||
handleAuthResponse(res, 'GET /api/events/writable-calendars');
|
||||
|
||||
const body = (await res.json()) as { calendars: WritableCalendar[] }
|
||||
return body.calendars
|
||||
const body = (await res.json()) as { calendars: WritableCalendar[] };
|
||||
return body.calendars;
|
||||
}
|
||||
|
||||
@@ -9,63 +9,60 @@
|
||||
* Types: List, ListItem, ListsResponse, ListItemsResponse
|
||||
*/
|
||||
|
||||
const BASE = '/api'
|
||||
const BASE = '/api';
|
||||
|
||||
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
})
|
||||
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`)
|
||||
return res
|
||||
});
|
||||
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface List {
|
||||
id: number
|
||||
name: string
|
||||
isShared: boolean
|
||||
ownerId: number
|
||||
activeCount: number
|
||||
doneCount: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
id: number;
|
||||
name: string;
|
||||
isShared: boolean;
|
||||
ownerId: number;
|
||||
activeCount: number;
|
||||
doneCount: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
id: number
|
||||
listId: number
|
||||
text: string
|
||||
checked: boolean
|
||||
rank: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
id: number;
|
||||
listId: number;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
rank: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListsResponse {
|
||||
lists: List[]
|
||||
lists: List[];
|
||||
}
|
||||
|
||||
export interface ListItemsResponse {
|
||||
items: ListItem[]
|
||||
items: ListItem[];
|
||||
}
|
||||
|
||||
// ── Functions ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchLists(): Promise<ListsResponse> {
|
||||
return apiFetch('/lists').then((r) => r.json() as Promise<ListsResponse>)
|
||||
return apiFetch('/lists').then((r) => r.json() as Promise<ListsResponse>);
|
||||
}
|
||||
|
||||
export async function createList(payload: {
|
||||
name: string
|
||||
isShared: boolean
|
||||
}): Promise<List> {
|
||||
export async function createList(payload: { name: string; isShared: boolean }): Promise<List> {
|
||||
return apiFetch('/lists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then((r) => r.json() as Promise<List>)
|
||||
}).then((r) => r.json() as Promise<List>);
|
||||
}
|
||||
|
||||
export async function patchList(
|
||||
@@ -76,13 +73,13 @@ export async function patchList(
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
}).then((r) => r.json() as Promise<List>)
|
||||
}).then((r) => r.json() as Promise<List>);
|
||||
}
|
||||
|
||||
export async function deleteList(id: number): Promise<{ id: number }> {
|
||||
return apiFetch(`/lists/${id}`, {
|
||||
method: 'DELETE',
|
||||
}).then((r) => r.json() as Promise<{ id: number }>)
|
||||
}).then((r) => r.json() as Promise<{ id: number }>);
|
||||
}
|
||||
|
||||
// ── Item functions (LIST-02) ────────────────────────────────────────────────
|
||||
@@ -92,23 +89,18 @@ export async function deleteList(id: number): Promise<{ id: number }> {
|
||||
* Returns active and completed items; the UI splits them into sections.
|
||||
*/
|
||||
export async function fetchListItems(listId: number): Promise<ListItemsResponse> {
|
||||
return apiFetch(`/lists/${listId}/items`).then(
|
||||
(r) => r.json() as Promise<ListItemsResponse>,
|
||||
)
|
||||
return apiFetch(`/lists/${listId}/items`).then((r) => r.json() as Promise<ListItemsResponse>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to a list. Server assigns the fractional rank (D-13).
|
||||
*/
|
||||
export async function addItem(
|
||||
listId: number,
|
||||
payload: { text: string },
|
||||
): Promise<ListItem> {
|
||||
export async function addItem(listId: number, payload: { text: string }): Promise<ListItem> {
|
||||
return apiFetch(`/lists/${listId}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then((r) => r.json() as Promise<ListItem>)
|
||||
}).then((r) => r.json() as Promise<ListItem>);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +116,7 @@ export async function patchListItem(
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
}).then((r) => r.json() as Promise<ListItem>)
|
||||
}).then((r) => r.json() as Promise<ListItem>);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,5 +126,5 @@ export async function patchListItem(
|
||||
export async function deleteItem(itemId: number): Promise<{ id: number }> {
|
||||
return apiFetch(`/list-items/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
}).then((r) => r.json() as Promise<{ id: number }>)
|
||||
}).then((r) => r.json() as Promise<{ id: number }>);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user