Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
Showing only changes of commit 509f4b26e0 - Show all commits
+50 -54
View File
@@ -646,64 +646,60 @@ describe('CR-06: OIDC iss/sub → users.id resolution on write handlers', () =>
// clause → the /inner join.*calendars/ assertion fails.
// GREEN: After adding .innerJoin(calendars, ...) the SQL contains the join.
// ---------------------------------------------------------------------------
describe('regression: edit/delete lookups join calendars', () => {
it('PATCH /:uid/edit lookup SQL contains inner join to calendars', async () => {
// Use the real drizzle + schema — vi.importActual bypasses the vi.mock for db/client.
// drizzle does NOT need a live DB to produce SQL via toSQL().
const { drizzle } = await vi.importActual<typeof import('drizzle-orm/mysql2')>('drizzle-orm/mysql2')
const { eq } = await vi.importActual<typeof import('drizzle-orm')>('drizzle-orm')
const { calendarEvents, calendars } = await vi.importActual<typeof import('../../src/db/schema.js')>('../../src/db/schema.js')
// Regression: BUG 1 — the edit/delete lookups select calendars.url/userId but
// must JOIN calendars to do so. Without the join Drizzle throws at query-build
// the handler's catch returns 503 (delete dialog never closes).
//
// This couples to the HANDLER, not a query the test rebuilds. The select-chain
// mock exposes from() → innerJoin() → where(); a no-join handler that calls
// .from().where() hits an undefined .where() → throws → 503, AND never invokes
// the innerJoin spy. So both assertions go RED if the join is removed from
// events.ts; they pass only because the handler actually joins.
describe('regression: edit/delete lookups join calendars (BUG 1)', () => {
const seededRow = {
uid: 'uid-001@familysync',
etag: '"etag-abc"',
objectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/uid-001.ics',
calendarId: 1,
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
userId: 1,
}
let innerJoinSpy: ReturnType<typeof vi.fn>
// Construct a throwaway drizzle instance — client is never called by toSQL()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = drizzle({ client: {} as any, mode: 'default' })
// Build the lookup query AS THE HANDLER SHOULD (with join).
// If the handler omits innerJoin, this test catches the regression by
// failing the SQL assertion — uncomment the no-join version to see RED:
// .from(calendarEvents)
// .where(eq(calendarEvents.uid, 'test-uid')) ← no join → toSQL omits join clause → FAILS
const lookupQuery = db
.select({
uid: calendarEvents.uid,
etag: calendarEvents.etag,
objectUrl: calendarEvents.objectUrl,
calendarId: calendarEvents.calendarId,
calendarUrl: calendars.url,
userId: calendars.userId,
})
.from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.where(eq(calendarEvents.uid, 'test-uid'))
const { sql: generatedSql } = lookupQuery.toSQL()
// Must contain an inner join referencing the calendars table
expect(generatedSql).toMatch(/inner join[\s\S]*`calendars`/i)
beforeEach(() => {
mockDbRows = [seededRow]
// Wire from() → innerJoin() → where(); the innerJoin spy proves the handler
// routes through the join rather than calling .where() directly on from().
const innerJoinWhere = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
innerJoinSpy = vi.fn().mockReturnValue({ where: innerJoinWhere })
mockFromFn.mockReturnValue({ innerJoin: innerJoinSpy })
mockSelectFn.mockReturnValue({ from: mockFromFn })
})
it('DELETE /:uid lookup SQL contains inner join to calendars', async () => {
const { drizzle } = await vi.importActual<typeof import('drizzle-orm/mysql2')>('drizzle-orm/mysql2')
const { eq } = await vi.importActual<typeof import('drizzle-orm')>('drizzle-orm')
const { calendarEvents, calendars } = await vi.importActual<typeof import('../../src/db/schema.js')>('../../src/db/schema.js')
it('PATCH /:uid/edit joins calendars (no 503) and invokes innerJoin', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events/uid-001%40familysync/edit', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Updated title',
allDay: false,
start: '2026-06-15T10:00:00Z',
end: '2026-06-15T11:00:00Z',
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
}),
})
expect(res.status).toBe(202)
expect(innerJoinSpy).toHaveBeenCalled()
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = drizzle({ client: {} as any, mode: 'default' })
const lookupQuery = db
.select({
uid: calendarEvents.uid,
etag: calendarEvents.etag,
objectUrl: calendarEvents.objectUrl,
calendarId: calendarEvents.calendarId,
calendarUrl: calendars.url,
userId: calendars.userId,
})
.from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.where(eq(calendarEvents.uid, 'test-uid'))
const { sql: generatedSql } = lookupQuery.toSQL()
expect(generatedSql).toMatch(/inner join[\s\S]*`calendars`/i)
it('DELETE /:uid joins calendars (no 503) and invokes innerJoin', async () => {
const { app } = await import('../../src/index.js')
const res = await app.request('/api/events/uid-001%40familysync', {
method: 'DELETE',
})
expect(res.status).toBe(202)
expect(innerJoinSpy).toHaveBeenCalled()
})
})