import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser, type TestUser } from '../../../helpers/factories'; import { NotificationChannelPreferences } from '../../../../src/db/entities/NotificationChannelPreferences.entity'; import { countRows, findRow, insertRow } from '../../../helpers/factories/rows'; import type { NotificationChannelPreferencesRepository } from '../../../../src/db/repositories/NotificationChannelPreferences.repository'; const testDb = createSnapshotTestDb(); let t: TestOrm; let repo: NotificationChannelPreferencesRepository; let user: TestUser; let otherUser: TestUser; beforeAll(async () => { t = await createTestOrm(testDb); repo = t.repo(NotificationChannelPreferences); }); beforeEach(() => { resetTestDb(testDb); t.clear(); user = createUser(testDb).user; otherUser = createUser(testDb).user; }); afterAll(async () => { await t.close(); testDb.close(); }); async function insertRaw(userId: number, eventType: string, channel: string, enabled: number): Promise { await insertRow(t, NotificationChannelPreferences, { user: userId, event_type: eventType, channel, enabled }); } /** The stored preference row, or null. */ function storedPref(userId: number, eventType: string, channel: string) { return findRow(t, NotificationChannelPreferences, { user: userId, event_type: eventType, channel }); } describe('notification_channel_preferences — the (user_id, event_type, channel) composite PK the ON CONFLICT target relies on (R5/§14)', () => { it("NCPREPO-SCHEMA-001: PRAGMA table_info('notification_channel_preferences') reports a 3-column PRIMARY KEY over exactly (user_id, event_type, channel), matching the migration DDL", () => { // test-sql-allow: schema introspection, which no entity or repository maps. const cols = testDb.prepare("PRAGMA table_info('notification_channel_preferences')").all() as { name: string; pk: number }[]; const pkCols = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name); expect(pkCols).toEqual(['user_id', 'event_type', 'channel']); }); it('NCPREPO-SCHEMA-002: the entity metadata declares the same 3-property composite PK, naming the RELATION property, not its persist(false) twin', () => { const meta = t.orm.getMetadata(NotificationChannelPreferences); expect(meta.getPrimaryProps().map((p) => p.name).sort()).toEqual(['channel', 'event_type', 'user'].sort()); }); }); describe('NotificationChannelPreferencesRepository — reads (NP2/NP3 parity)', () => { it('NCPREPO-001 — findEnabled matches `SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?`, and is null for an absent row (default-enabled)', async () => { await insertRaw(user.id, 'trip_invite', 'email', 0); // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?').get(user.id, 'trip_invite', 'email') as { enabled: number }; const converted = await repo.findEnabled(user.id, 'trip_invite', 'email'); // A plain-object equality against the MikroORM-returned entity would also // compare its (identical, but differently-shaped) relation/metadata // fields — the field values are the parity contract here, not the // wrapper shape (the `AppSettingsRepository.getValue` precedent). expect(converted?.enabled).toBe(legacy.enabled); expect(await repo.findEnabled(user.id, 'trip_invite', 'webhook')).toBeNull(); }); it('NCPREPO-002 — listForUser matches `SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?`, fully seeded across every event×channel combination, and never leaks another user', async () => { const combos: Array<[string, string, number]> = [ ['trip_invite', 'email', 0], ['trip_invite', 'webhook', 1], ['trip_invite', 'inapp', 0], ['booking_change', 'email', 1], ['booking_change', 'ntfy', 0], ['vacay_invite', 'inapp', 0], ]; for (const [event_type, channel, enabled] of combos) await insertRaw(user.id, event_type, channel, enabled); await insertRaw(otherUser.id, 'trip_invite', 'email', 0); // a different user's row — must not leak in // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = (testDb.prepare('SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?').all(user.id) as { event_type: string; channel: string; enabled: number }[]) .map((r) => ({ event_type: r.event_type, channel: r.channel, enabled: r.enabled })) .sort((a, b) => a.event_type.localeCompare(b.event_type) || a.channel.localeCompare(b.channel)); const converted = (await repo.listForUser(user.id)) .map((r) => ({ event_type: r.event_type, channel: r.channel, enabled: r.enabled })) .sort((a, b) => a.event_type.localeCompare(b.event_type) || a.channel.localeCompare(b.channel)); expect(converted.length).toBe(combos.length); expect(converted).toEqual(legacy); }); }); describe('NotificationChannelPreferencesRepository — writes (NP5/NP6, R5)', () => { it( "NCPREPO-UPSERT-SQL: em.upsert renders `insert into \"notification_channel_preferences\" ... on conflict (`user_id`, `event_type`, `channel`) do update set ...` — the composite-PK conflict target Task 0's R5 worked example pinned, checked directly against the rendered SQL, not assumed from onConflictFields alone", async () => { const connection = t.orm.em.getConnection(); const spy = vi.spyOn(connection, 'execute'); try { // Insert branch: no existing row. await repo.upsertPreference(user.id, 'trip_invite', 'email', 0); expect((await storedPref(user.id, 'trip_invite', 'email'))?.enabled).toBe(0); const insertSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql)); expect(insertSql).toContain('on conflict (`user_id`, `event_type`, `channel`)'); spy.mockClear(); // Merge branch: an existing row, same call shape — the SAME conflict // target both creates and merges, matching `OR REPLACE`'s semantics // (there is no other non-key column REPLACE would reset that merge // does not also set). await repo.upsertPreference(user.id, 'trip_invite', 'email', 1); expect((await storedPref(user.id, 'trip_invite', 'email'))?.enabled).toBe(1); expect(await countRows(t, NotificationChannelPreferences, { user: user.id, event_type: 'trip_invite', channel: 'email' })).toBe(1); const mergeSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql)); expect(mergeSql).toContain('on conflict (`user_id`, `event_type`, `channel`)'); } finally { spy.mockRestore(); } }, ); it('NCPREPO-003 — deletePreference matches `DELETE FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?`, scoped to exactly that triple', async () => { await insertRaw(user.id, 'trip_invite', 'email', 0); await insertRaw(user.id, 'trip_invite', 'webhook', 0); // a different channel — must survive await insertRaw(otherUser.id, 'trip_invite', 'email', 0); // a different user — must survive expect(await repo.deletePreference(user.id, 'trip_invite', 'email')).toBe(1); expect(await storedPref(user.id, 'trip_invite', 'email')).toBeNull(); expect(await storedPref(user.id, 'trip_invite', 'webhook')).not.toBeNull(); expect(await storedPref(otherUser.id, 'trip_invite', 'email')).not.toBeNull(); expect(await repo.deletePreference(user.id, 'trip_invite', 'email')).toBe(0); // already gone — no-op }); });