Files
TREK/server/tests/unit/db/repositories/DemoRepository.test.ts
T
Maurice c524454f47 refactor(db): read new ids with RETURNING instead of insertId
Kysely fills InsertResult.insertId from SQLite's last_insert_rowid. On
Postgres it is undefined, so the sixteen repository inserts that returned
Number(result.insertId) would hand NaN ids to their services without an
error. They now add .returning('id') and read the id from the row, which
SQLite (3.35 and later, bundled by better-sqlite3) and Postgres both answer
with the same value.

DemoRepository ran its example-trip seed as nine SQL strings through
connection.execute, including INSERT OR IGNORE and a bare CURRENT_TIMESTAMP.
Those are Kysely inserts on the generated table types now: ON CONFLICT
(trip_id, user_id) DO NOTHING for the membership row, the dialect helper for
the timestamp, RETURNING id where the caller needs it. With no SQL text left
it drops off the ESLint exemption list and its pin.

ESLint now refuses any read of insertId in src/db/repositories and
src/db/seeders, so the pattern cannot come back.
2026-10-08 18:39:33 +02:00

171 lines
10 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser, createAdmin } from '../../../helpers/factories';
import { DemoRepository, type NewDemoPlaceRow } from '../../../../src/db/repositories/DemoRepository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let demo: DemoRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
demo = new DemoRepository(t.em);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
describe('DemoRepository', () => {
it('DEMOREPO-001: findUserByEmail — DMS1, case-sensitive, no guest filter', async () => {
const { user } = createUser(testDb, { email: 'admin@trek.app' });
await expect(demo.findUserByEmail('admin@trek.app')).resolves.toEqual({ id: user.id });
await expect(demo.findUserByEmail('nope@trek.app')).resolves.toBeNull();
});
it('DEMOREPO-002: createUser — DMS2, leaves first_seen_version/login_count/oidc/avatar to the entity default', async () => {
const id = await demo.createUser({ username: 'demo', email: 'demo@trek.app', password_hash: 'x', role: 'user' });
const row = testDb.prepare('SELECT username, email, role, first_seen_version, login_count FROM users WHERE id = ?').get(id) as {
username: string; email: string; role: string; first_seen_version: string; login_count: number;
};
expect(row).toEqual({ username: 'demo', email: 'demo@trek.app', role: 'user', first_seen_version: '0.0.0', login_count: 0 });
});
it('DEMOREPO-003: setAllowRegistrationFalse — DMS3', async () => {
await demo.setAllowRegistrationFalse();
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string };
expect(row.value).toBe('false');
// Idempotent — a second call still lands 'false' (INSERT OR REPLACE dialect).
await demo.setAllowRegistrationFalse();
const row2 = testDb.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string };
expect(row2.value).toBe('false');
});
it('DEMOREPO-004: countTripsByUser / listTripIdsByUser — DMS4/DMS5', async () => {
const { user } = createAdmin(testDb);
expect(await demo.countTripsByUser(user.id)).toBe(0);
const t1 = await demo.insertTrip(user.id, 'A', 'desc', '2026-01-01', '2026-01-02', 'EUR');
const t2 = await demo.insertTrip(user.id, 'B', 'desc', '2026-02-01', '2026-02-02', 'EUR');
expect(await demo.countTripsByUser(user.id)).toBe(2);
expect((await demo.listTripIdsByUser(user.id)).sort()).toEqual([t1, t2].sort());
});
it('DEMOREPO-005: addTripMember — DMS6, INSERT OR IGNORE (a repeat call does not throw)', async () => {
const { user: owner } = createAdmin(testDb);
const { user: member } = createUser(testDb);
const tripId = await demo.insertTrip(owner.id, 'A', 'desc', '2026-01-01', '2026-01-02', 'EUR');
await demo.addTripMember(tripId, member.id, owner.id);
await demo.addTripMember(tripId, member.id, owner.id); // no UNIQUE-constraint throw
const rows = testDb.prepare('SELECT trip_id, user_id, invited_by FROM trip_members WHERE trip_id = ?').all(tripId);
expect(rows).toEqual([{ trip_id: tripId, user_id: member.id, invited_by: owner.id }]);
});
it('DEMOREPO-006: insertDay/insertPlace/insertDayAssignment/insertPackingItem/insertBudgetItem/insertReservation/insertDayNote — DMS7-14', async () => {
const { user } = createAdmin(testDb);
const tripId = await demo.insertTrip(user.id, 'Test Trip', 'd', '2026-04-15', '2026-04-16', 'JPY');
const dayId = await demo.insertDay(tripId, 1, '2026-04-15');
expect(testDb.prepare('SELECT trip_id, day_number, date FROM days WHERE id = ?').get(dayId)).toEqual({ trip_id: tripId, day_number: 1, date: '2026-04-15' });
const place: NewDemoPlaceRow = [tripId, 'Hotel X', 35.1, 139.1, 'Addr', 1, '15:00', 60, 'notes', null, 'gpid', null, null];
const placeId = await demo.insertPlace(place);
const placeRow = testDb.prepare('SELECT trip_id, name, category_id FROM places WHERE id = ?').get(placeId) as { trip_id: number; name: string; category_id: number };
expect(placeRow).toEqual({ trip_id: tripId, name: 'Hotel X', category_id: 1 });
await demo.insertDayAssignment(dayId, placeId, 0);
expect(testDb.prepare('SELECT day_id, place_id, order_index FROM day_assignments WHERE day_id = ?').get(dayId)).toEqual({ day_id: dayId, place_id: placeId, order_index: 0 });
await demo.insertPackingItem(tripId, 'Passport', 1, 'Documents', 0);
const packing = testDb.prepare('SELECT trip_id, name, checked, category, sort_order FROM packing_items WHERE trip_id = ?').get(tripId);
expect(packing).toEqual({ trip_id: tripId, name: 'Passport', checked: 1, category: 'Documents', sort_order: 0 });
await demo.insertBudgetItem(tripId, 'Food', 'Lunch', 1000, 2, null);
const budget = testDb.prepare('SELECT trip_id, category, name, total_price, persons, note FROM budget_items WHERE trip_id = ?').get(tripId);
expect(budget).toEqual({ trip_id: tripId, category: 'Food', name: 'Lunch', total_price: 1000, persons: 2, note: null });
await demo.insertReservation(tripId, dayId, 'Check-in', '2026-04-15T15:00', 'CONF-1', 'confirmed', 'hotel', 'Tokyo');
const reservation = testDb.prepare('SELECT trip_id, day_id, title, reservation_time, confirmation_number, status, type, location FROM reservations WHERE trip_id = ?').get(tripId);
expect(reservation).toEqual({ trip_id: tripId, day_id: dayId, title: 'Check-in', reservation_time: '2026-04-15T15:00', confirmation_number: 'CONF-1', status: 'confirmed', type: 'hotel', location: 'Tokyo' });
await demo.insertDayNote(dayId, tripId, 'Note text', '13:00', 'Info', 0.5);
const note = testDb.prepare('SELECT day_id, trip_id, text, time, icon, sort_order FROM day_notes WHERE day_id = ?').get(dayId);
expect(note).toEqual({ day_id: dayId, trip_id: tripId, text: 'Note text', time: '13:00', icon: 'Info', sort_order: 0.5 });
});
it('DEMOREPO-007: the inserts read the new id back with RETURNING, and a packing item gets its updated_at stamp', async () => {
const { user } = createAdmin(testDb);
const tripId = await demo.insertTrip(user.id, 'Ids', 'd', '2026-05-01', '2026-05-02', 'EUR');
expect(typeof tripId).toBe('number');
const latest = testDb.prepare('SELECT MAX(id) AS id FROM trips').get() as { id: number };
expect(tripId).toBe(latest.id);
const dayId = await demo.insertDay(tripId, 1, '2026-05-01');
const secondDayId = await demo.insertDay(tripId, 2, '2026-05-02');
expect(secondDayId).toBeGreaterThan(dayId);
await demo.insertPackingItem(tripId, 'Charger', 0, 'Tech', 1);
const packing = testDb.prepare('SELECT updated_at FROM packing_items WHERE trip_id = ?').get(tripId) as { updated_at: string | null };
expect(packing.updated_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
it('DEMOREPO-CRED-001 (DMR1/DMR5 round trip): getAdminCredentials reads exactly what restoreAdminCredentials wrote', async () => {
const { user } = createAdmin(testDb, { email: 'admin@nomad.app' });
testDb.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE id = ?')
.run('maps-key', 'ow-key', 'un-key', 'avatar.png', user.id);
const before = await demo.getAdminCredentials('admin@nomad.app');
expect(before).toEqual({
password_hash: expect.any(String),
maps_api_key: 'maps-key',
openweather_api_key: 'ow-key',
unsplash_api_key: 'un-key',
avatar: 'avatar.png',
});
// Simulate a baseline restore clobbering the row, then restore the preserved values.
testDb.prepare('UPDATE users SET password_hash = ?, maps_api_key = NULL, openweather_api_key = NULL, unsplash_api_key = NULL, avatar = NULL WHERE id = ?')
.run('stale-hash-from-baseline', user.id);
await demo.restoreAdminCredentials('admin@nomad.app', before!);
const after = testDb.prepare('SELECT password_hash, maps_api_key, openweather_api_key, unsplash_api_key, avatar FROM users WHERE id = ?').get(user.id);
expect(after).toEqual(before);
});
it('DEMOREPO-CRED-002 (DMR5): writes exactly the 5 named columns, no updated_at bump', async () => {
const { user } = createAdmin(testDb, { email: 'admin@nomad.app' });
const before = testDb.prepare('SELECT updated_at FROM users WHERE id = ?').get(user.id) as { updated_at: string | null };
await demo.restoreAdminCredentials('admin@nomad.app', {
password_hash: 'h', maps_api_key: 'm', openweather_api_key: 'o', unsplash_api_key: 'u', avatar: 'a',
});
const after = testDb.prepare('SELECT updated_at FROM users WHERE id = ?').get(user.id) as { updated_at: string | null };
expect(after.updated_at).toBe(before.updated_at);
});
it('DEMOREPO-KEYS-001 (DMR2/DMR6 round trip): getInstanceApiKeys preserves a NULL value, restoreInstanceApiKeys writes it back as NULL', async () => {
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'a-maps-key')").run();
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('unsplash_api_key', NULL)").run();
const rows = await demo.getInstanceApiKeys();
expect(rows.sort((a, b) => a.key.localeCompare(b.key))).toEqual([
{ key: 'maps_api_key', value: 'a-maps-key' },
{ key: 'unsplash_api_key', value: null },
]);
// Clobber, then restore from what was read.
testDb.prepare("UPDATE app_settings SET value = 'clobbered' WHERE key = 'maps_api_key'").run();
await demo.restoreInstanceApiKeys(rows);
const after = testDb.prepare("SELECT key, value FROM app_settings WHERE key IN ('maps_api_key', 'unsplash_api_key') ORDER BY key").all();
expect(after).toEqual([
{ key: 'maps_api_key', value: 'a-maps-key' },
{ key: 'unsplash_api_key', value: null },
]);
});
it('DEMOREPO-KEYS-002: restoreInstanceApiKeys upserts a row that did not previously exist', async () => {
await demo.restoreInstanceApiKeys([{ key: 'maps_api_key', value: 'brand-new' }]);
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'maps_api_key'").get() as { value: string };
expect(row.value).toBe('brand-new');
});
});