Files
TREK/server/tests/unit/db/repositories/NotificationChannelPreferences.repository.test.ts
T
Maurice d28e91d056 test(server): seed the repository specs through the ORM factories
The repository suites now write their fixtures and read back what a call
left behind through the factories and readers, not hand-written SQLite on
the better-sqlite3 handle. What stays raw is marked with test-sql-allow:
the legacy statements the parity cases hold a repository read to, rows
read as the table stores them (a BLOB, an integer the entity maps to a
boolean), and a write that has to land on the connection an open
transaction holds.

Cases that pinned a "raw UPDATE" between two repository reads now make
that write through a context of their own, which the repository's
identity map knows as little about, so they still prove the same thing.
2026-10-08 18:46:13 +02:00

133 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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
});
});