Files
TREK/server/tests/unit/db/repositories/NotificationChannelPreferences.repository.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

163 lines
8.2 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 { NotificationChannelPreferences } from '../../../../src/db/entities/NotificationChannelPreferences.entity';
import type { NotificationChannelPreferencesRepository } from '../../../../src/db/repositories/NotificationChannelPreferences.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createUser, type TestUser } from '../../../helpers/factories';
import { countRows, findRow, insertRow } from '../../../helpers/factories/rows';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
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
});
});