mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(server): seed the repository, MCP and e2e specs through the factories
Converts the raw prepare() fixtures and reads in the next set of specs to the ORM factories and readers: the password reset, webauthn, dawarich, budget, to-do and vacay repository specs, the journey, settings, addons and share MCP specs, the plugin POI and operator egress specs, the feeds, storage stats and query helper services, and the packing, collections, day reorder, leg mode, upload context and trip access integration suites. The notifications and google quota e2e suites now seed their users through makeUser with pinned ids, and the notifications suite moves from hand-rolled tables to the migrated snapshot. Legacy statements that serve as a parity oracle and the raw rows the primary-relation spike holds toObject() against stay raw, each with an allow marker naming why.
This commit is contained in:
@@ -21,7 +21,14 @@ import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
||||
import { todayUtc } from '@trek/shared';
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
import { GoogleApiUsage } from '../../src/db/entities/GoogleApiUsage.entity';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { makeAdmin, makeUser } from '../helpers/factories/users';
|
||||
import { deleteRows, upsertRow } from '../helpers/factories/rows';
|
||||
|
||||
let orm: TestOrm;
|
||||
|
||||
const USER = 1;
|
||||
const ADMIN = 2;
|
||||
@@ -45,20 +52,18 @@ describe('/api/admin/google-quota e2e (real guards + temp SQLite)', () => {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// harness.ts's seedUser() omits password_hash, which the migrated schema
|
||||
// requires NOT NULL, so the two accounts are inserted here directly.
|
||||
const insertUser = db.prepare(
|
||||
'INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (?, ?, ?, ?, ?, 0)',
|
||||
);
|
||||
insertUser.run(USER, 'e2e-user', 'e2e@example.test', 'x', 'user');
|
||||
insertUser.run(ADMIN, 'e2e-admin', 'admin@example.com', 'x', 'admin');
|
||||
db.prepare("DELETE FROM app_settings WHERE key = 'google_daily_limit'").run();
|
||||
orm = await createTestOrm(db);
|
||||
// Pinned ids: sessionCookie(USER) and (ADMIN) sign for exactly these users.
|
||||
await makeUser(orm, { id: USER, username: 'e2e-user', email: 'e2e@example.test' });
|
||||
await makeAdmin(orm, { id: ADMIN, username: 'e2e-admin', email: 'admin@example.com' });
|
||||
await deleteRows(orm, AppSettings, { key: 'google_daily_limit' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
it('GQUOTA-E2E-001: 401 without a cookie and 403 for a non-admin', async () => {
|
||||
@@ -68,7 +73,7 @@ describe('/api/admin/google-quota e2e (real guards + temp SQLite)', () => {
|
||||
});
|
||||
|
||||
it('GQUOTA-E2E-002: stores the ceiling, reports today, and audits the change', async () => {
|
||||
db.prepare("INSERT OR REPLACE INTO google_api_usage (day, calls) VALUES (date('now'), 7)").run();
|
||||
await upsertRow(orm, GoogleApiUsage, { day: todayUtc(), calls: 7 });
|
||||
const put = await request(server).put('/api/admin/google-quota').set('Cookie', sessionCookie(ADMIN)).send({ daily_limit: 5 });
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.body).toEqual({ daily_limit: 5, used_today: 7, exhausted: true });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Notifications module e2e — exercises the migrated /api/notifications endpoints
|
||||
* through the real JwtAuthGuard against a temp SQLite db. NotificationsService
|
||||
* runs its real (DI-native) in-app SQL — notifications DDL below; the channel
|
||||
* through the real JwtAuthGuard against a migrated temp SQLite db. NotificationsService
|
||||
* runs its real (DI-native) in-app SQL; the channel
|
||||
* transports and the preference matrix (still plain services/* modules) are
|
||||
* mocked. Focuses on auth, the inline admin gate on /test-smtp, routing (the
|
||||
* /in-app/all ordering trap) and status/body shapes.
|
||||
@@ -11,47 +11,13 @@ import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
import { sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
|
||||
avatar TEXT);`);
|
||||
// NotificationsService runs its real SQL (DI-native since the notifications
|
||||
// fold) — full notifications column set for listInApp/markRead/deleteAll.
|
||||
tmp.exec(`CREATE TABLE notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')),
|
||||
scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')),
|
||||
target INTEGER NOT NULL,
|
||||
sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title_key TEXT NOT NULL,
|
||||
title_params TEXT DEFAULT '{}',
|
||||
text_key TEXT NOT NULL,
|
||||
text_params TEXT DEFAULT '{}',
|
||||
positive_text_key TEXT,
|
||||
negative_text_key TEXT,
|
||||
positive_callback TEXT,
|
||||
negative_callback TEXT,
|
||||
response TEXT CHECK(response IN ('positive', 'negative')),
|
||||
navigate_text_key TEXT,
|
||||
navigate_target TEXT,
|
||||
is_read INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`);
|
||||
// StorageRegistryService (behind StorageModule, now in this module chain) reads
|
||||
// this at onModuleInit.
|
||||
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
||||
return { db: tmp };
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
// The preference matrix and the channel transports are providers since the
|
||||
// fold — overridden at the container instead of mocked by module path; the
|
||||
// in-app store is real SQL.
|
||||
@@ -62,6 +28,8 @@ const { prefs, mailer, webhook, ntfy } = vi.hoisted(() => ({
|
||||
ntfy: { testNtfy: vi.fn(), getUserNtfyConfig: vi.fn(), getAdminNtfyConfig: vi.fn() },
|
||||
}));
|
||||
|
||||
import { db } from '../../src/db/database';
|
||||
import { Notifications } from '../../src/db/entities/Notifications.entity';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { NotificationsModule } from '../../src/nest/notifications/notifications.module';
|
||||
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
|
||||
@@ -70,17 +38,23 @@ import { NtfyService } from '../../src/nest/notifications/transports/ntfy.servic
|
||||
import { WebhookService } from '../../src/nest/notifications/transports/webhook.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { makeAdmin, makeUser } from '../helpers/factories/users';
|
||||
import { makeNotification } from '../helpers/factories/notifications';
|
||||
import { deleteRows, findRows } from '../helpers/factories/rows';
|
||||
|
||||
function seedNotification(recipientId: number, overrides: { is_read?: number } = {}): number {
|
||||
const r = db.prepare(
|
||||
`INSERT INTO notifications (type, scope, target, recipient_id, title_key, text_key, is_read)
|
||||
VALUES ('simple', 'user', ?, ?, 'notif.test.title', 'notif.test.text', ?)`
|
||||
).run(recipientId, recipientId, overrides.is_read ?? 0);
|
||||
return r.lastInsertRowid as number;
|
||||
let orm: TestOrm;
|
||||
|
||||
async function seedNotification(recipientId: number, overrides: { is_read?: number } = {}): Promise<number> {
|
||||
const row = await makeNotification(orm, recipientId, {
|
||||
title_key: 'notif.test.title',
|
||||
text_key: 'notif.test.text',
|
||||
is_read: overrides.is_read ?? 0,
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
describe('Notifications e2e (real auth guard + temp SQLite)', () => {
|
||||
describe('Notifications e2e (real auth guard + migrated temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
@@ -101,8 +75,10 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1, role: 'admin', email: 'admin@example.test' });
|
||||
seedUser(db as never, { id: 2, role: 'user', email: 'user@example.test' });
|
||||
orm = await createTestOrm(db);
|
||||
// Pinned ids: sessionCookie(1) and (2) sign for exactly these users.
|
||||
await makeAdmin(orm, { id: 1, email: 'admin@example.test' });
|
||||
await makeUser(orm, { id: 2, email: 'user@example.test' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
prefs.getPreferencesMatrix.mockReturnValue({ preferences: {}, available_channels: {}, event_types: [], implemented_combos: {} });
|
||||
@@ -111,6 +87,7 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
@@ -138,25 +115,25 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => {
|
||||
});
|
||||
|
||||
it('200 unread-count from the real notifications table', async () => {
|
||||
db.prepare('DELETE FROM notifications').run();
|
||||
seedNotification(2);
|
||||
seedNotification(2);
|
||||
seedNotification(2, { is_read: 1 });
|
||||
await deleteRows(orm, Notifications);
|
||||
await seedNotification(2);
|
||||
await seedNotification(2);
|
||||
await seedNotification(2, { is_read: 1 });
|
||||
const res = await request(server).get('/api/notifications/in-app/unread-count').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ count: 2 });
|
||||
});
|
||||
|
||||
it('DELETE /in-app/all hits deleteAll, not the /:id handler', async () => {
|
||||
db.prepare('DELETE FROM notifications').run();
|
||||
for (let i = 0; i < 4; i++) seedNotification(2);
|
||||
const other = seedNotification(1);
|
||||
await deleteRows(orm, Notifications);
|
||||
for (let i = 0; i < 4; i++) await seedNotification(2);
|
||||
const other = await seedNotification(1);
|
||||
const res = await request(server).delete('/api/notifications/in-app/all').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, count: 4 });
|
||||
// Only user 2's rows are gone — the static route deleted per-recipient, it
|
||||
// did not fall through to the /:id param handler (which would 400 on 'all').
|
||||
const rows = db.prepare('SELECT id FROM notifications').all() as { id: number }[];
|
||||
const rows = await findRows(orm, Notifications);
|
||||
expect(rows.map(r => r.id)).toEqual([other]);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,37 +29,45 @@ vi.mock('../../src/config', () => ({
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) }));
|
||||
|
||||
import { db as testDb } from '../../src/db/database';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { insertRow, upsertRow } from '../helpers/factories/rows';
|
||||
import { Addons } from '../../src/db/entities/Addons.entity';
|
||||
import { Collections } from '../../src/db/entities/Collections.entity';
|
||||
import { CollectionPlaces } from '../../src/db/entities/CollectionPlaces.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: FactoryOrm;
|
||||
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const coversDir = path.join(__dirname, '../../uploads/covers');
|
||||
const placesDir = path.join(__dirname, '../../uploads/places');
|
||||
|
||||
function createCollection(ownerId: number): number {
|
||||
return Number(testDb.prepare("INSERT INTO collections (owner_id, name) VALUES (?, 'C')").run(ownerId).lastInsertRowid);
|
||||
async function createCollection(ownerId: number): Promise<number> {
|
||||
return insertRow(orm, Collections, { owner: ownerId, name: 'C' });
|
||||
}
|
||||
|
||||
function createCollectionPlace(collectionId: number, ownerId: number): number {
|
||||
return Number(testDb.prepare("INSERT INTO collection_places (collection_id, owner_id, name) VALUES (?, ?, 'P')").run(collectionId, ownerId).lastInsertRowid);
|
||||
async function createCollectionPlace(collectionId: number, ownerId: number): Promise<number> {
|
||||
return insertRow(orm, CollectionPlaces, { collection: collectionId, owner: ownerId, name: 'P' });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
await resetRateLimits(nestApp);
|
||||
// Enable the collections addon (the controller sits behind AddonGuard).
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collections', 'Collections', 'Saved places', 'global', 'Bookmark', 1, 40)"
|
||||
).run();
|
||||
await upsertRow(orm, Addons, {
|
||||
id: 'collections', name: 'Collections', description: 'Saved places', type: 'global', icon: 'Bookmark', enabled: true, sort_order: 40,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -72,7 +80,7 @@ afterAll(async () => {
|
||||
describe('Collection cover upload', () => {
|
||||
it('COLL-P01 — cover upload stores /uploads/covers/<uuid> and writes the file', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const collectionId = createCollection(user.id);
|
||||
const collectionId = await createCollection(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/addons/collections/${collectionId}/cover`)
|
||||
@@ -86,7 +94,7 @@ describe('Collection cover upload', () => {
|
||||
|
||||
it('COLL-P02 — no file → 400 "No image uploaded"', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const collectionId = createCollection(user.id);
|
||||
const collectionId = await createCollection(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/addons/collections/${collectionId}/cover`)
|
||||
@@ -97,7 +105,7 @@ describe('Collection cover upload', () => {
|
||||
|
||||
it('COLL-P03 — non-image cover is 500 (plain-Error filter quirk — pinned, do not "fix")', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const collectionId = createCollection(user.id);
|
||||
const collectionId = await createCollection(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/addons/collections/${collectionId}/cover`)
|
||||
@@ -110,8 +118,8 @@ describe('Collection cover upload', () => {
|
||||
describe('Collection place image upload', () => {
|
||||
it('COLL-P04 — place image upload stores /uploads/places/<uuid> and writes the file', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const collectionId = createCollection(user.id);
|
||||
const placeId = createCollectionPlace(collectionId, user.id);
|
||||
const collectionId = await createCollection(user.id);
|
||||
const placeId = await createCollectionPlace(collectionId, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/addons/collections/places/${placeId}/image`)
|
||||
@@ -125,8 +133,8 @@ describe('Collection place image upload', () => {
|
||||
|
||||
it('COLL-P05 — non-image place upload is 400 with the bespoke message', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const collectionId = createCollection(user.id);
|
||||
const placeId = createCollectionPlace(collectionId, user.id);
|
||||
const collectionId = await createCollection(user.id);
|
||||
const placeId = await createCollectionPlace(collectionId, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/addons/collections/places/${placeId}/image`)
|
||||
|
||||
@@ -33,7 +33,13 @@ import {
|
||||
createTestDayAccommodationsRepo,
|
||||
createTestRoadtripViasRepo,
|
||||
createTestRoadtripDayBoundariesRepo,
|
||||
sharedTestOrm,
|
||||
} from '../helpers/test-uow';
|
||||
import { findRow, findRows, updateRows } from '../helpers/factories/rows';
|
||||
import { readTripDays } from '../helpers/factories/trips';
|
||||
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
||||
import { Reservations } from '../../src/db/entities/Reservations.entity';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
|
||||
let svc: DaysService;
|
||||
beforeAll(async () => {
|
||||
@@ -65,50 +71,48 @@ beforeEach(() => {
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
const orderedDays = (tripId: number) =>
|
||||
testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as
|
||||
{ id: number; day_number: number; date: string | null }[];
|
||||
const orderedDays = async (tripId: number) => readTripDays(await sharedTestOrm(testDb), tripId);
|
||||
|
||||
describe('reorderDays', () => {
|
||||
it('permutes positions, pins dates to slots, and content rides along by id', async () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
const [d1, d2, d3] = await orderedDays(trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createDayAssignment(testDb, d2.id, place.id); // place sits on day 2
|
||||
|
||||
// Move day 2 to the front: [d2, d1, d3]
|
||||
await reorderDays(trip.id, [d2.id, d1.id, d3.id]);
|
||||
|
||||
const after = orderedDays(trip.id);
|
||||
const after = await orderedDays(trip.id);
|
||||
expect(after.map(d => d.id)).toEqual([d2.id, d1.id, d3.id]);
|
||||
// Dates stay pinned to their calendar slots
|
||||
expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02', '2026-03-03']);
|
||||
// The place rides along with its day row (still attached to d2.id, now at slot 1)
|
||||
const onD2 = testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(d2.id);
|
||||
const onD2 = await findRows(await sharedTestOrm(testDb), DayAssignments, { day: d2.id });
|
||||
expect(onD2).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('re-stamps a booking\'s date onto its day\'s new date, keeping the time', async () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
const [d1, d2, d3] = await orderedDays(trip.id);
|
||||
const res = createReservation(testDb, trip.id, { day_id: d2.id, type: 'restaurant' });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2026-03-02T19:00', res.id);
|
||||
await updateRows(await sharedTestOrm(testDb), Reservations, { id: res.id }, { reservation_time: '2026-03-02T19:00' });
|
||||
|
||||
await reorderDays(trip.id, [d2.id, d1.id, d3.id]); // d2 moves to the 2026-03-01 slot
|
||||
|
||||
const r = testDb.prepare('SELECT reservation_time FROM reservations WHERE id = ?').get(res.id) as { reservation_time: string };
|
||||
expect(r.reservation_time).toBe('2026-03-01T19:00');
|
||||
const r = await findRow(await sharedTestOrm(testDb), Reservations, { id: res.id });
|
||||
expect(r?.reservation_time).toBe('2026-03-01T19:00');
|
||||
});
|
||||
|
||||
it('rejects an orderedIds list that is not a permutation of the trip days', async () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2] = orderedDays(trip.id);
|
||||
const [d1, d2] = await orderedDays(trip.id);
|
||||
await expect(reorderDays(trip.id, [d1.id, d2.id])).rejects.toThrow(DayReorderError);
|
||||
});
|
||||
|
||||
it('blocks a move that would make an accommodation end before it starts, and rolls back', async () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
const [d1, d2, d3] = await orderedDays(trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id); // stay spans day 1 -> day 2
|
||||
|
||||
@@ -116,7 +120,7 @@ describe('reorderDays', () => {
|
||||
await expect(reorderDays(trip.id, [d2.id, d3.id, d1.id])).rejects.toThrow(DayReorderError);
|
||||
|
||||
// Transaction rolled back: original order intact
|
||||
expect(orderedDays(trip.id).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
|
||||
expect((await orderedDays(trip.id)).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,7 +133,7 @@ describe('insertDay', () => {
|
||||
|
||||
const created = await insertDay(trip.id, 1);
|
||||
|
||||
const after = orderedDays(trip.id);
|
||||
const after = await orderedDays(trip.id);
|
||||
expect(after).toHaveLength(4);
|
||||
expect(after[0].id).toBe(created.id);
|
||||
expect(after[0].date).toBeNull();
|
||||
@@ -138,18 +142,18 @@ describe('insertDay', () => {
|
||||
|
||||
it('inserts at the front of a dated trip: dates stay contiguous and the trip extends', async () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
const [d1, d2, d3] = await orderedDays(trip.id);
|
||||
|
||||
const created = await insertDay(trip.id, 1);
|
||||
|
||||
const after = orderedDays(trip.id);
|
||||
const after = await orderedDays(trip.id);
|
||||
expect(after).toHaveLength(4);
|
||||
expect(after[0].id).toBe(created.id);
|
||||
expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04']);
|
||||
// Old content shifted down a slot
|
||||
expect(after.slice(1).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
|
||||
// Trip range extended by one day
|
||||
const t = testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id) as { end_date: string };
|
||||
expect(t.end_date).toBe('2026-03-04');
|
||||
const stored = await findRow(await sharedTestOrm(testDb), Trips, { id: trip.id });
|
||||
expect(stored?.end_date).toBe('2026-03-04');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,18 +29,24 @@ vi.mock('../../src/config', () => ({
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { db as testDb2 } from '../../src/db/database';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { createUser, createTrip, createDay, createPlace } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { findRow, updateRows } from '../helpers/factories/rows';
|
||||
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
||||
|
||||
describe('incoming_leg_transport_mode read-path parity', () => {
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: FactoryOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -66,7 +72,7 @@ describe('incoming_leg_transport_mode read-path parity', () => {
|
||||
expect(create.status).toBe(201);
|
||||
const assignmentId = create.body.assignment.id;
|
||||
|
||||
testDb2.prepare('UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?').run('transit', assignmentId);
|
||||
await updateRows(orm, DayAssignments, { id: assignmentId }, { incoming_leg_transport_mode: 'transit' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days`)
|
||||
@@ -100,9 +106,9 @@ describe('incoming_leg_transport_mode read-path parity', () => {
|
||||
.send({ transport_mode: 'cycling' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = testDb2.prepare('SELECT leg_transport_mode, incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(assignmentId) as { leg_transport_mode: string | null; incoming_leg_transport_mode: string | null };
|
||||
expect(row.leg_transport_mode).toBe('cycling');
|
||||
expect(row.incoming_leg_transport_mode).toBeNull();
|
||||
const row = await findRow(orm, DayAssignments, { id: assignmentId });
|
||||
expect(row?.leg_transport_mode).toBe('cycling');
|
||||
expect(row?.incoming_leg_transport_mode).toBeNull();
|
||||
});
|
||||
|
||||
it("direction: 'incoming' writes the incoming column", async () => {
|
||||
@@ -123,8 +129,8 @@ describe('incoming_leg_transport_mode read-path parity', () => {
|
||||
.send({ transport_mode: 'transit', direction: 'incoming' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = testDb2.prepare('SELECT incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(assignmentId) as { incoming_leg_transport_mode: string | null };
|
||||
expect(row.incoming_leg_transport_mode).toBe('transit');
|
||||
const row = await findRow(orm, DayAssignments, { id: assignmentId });
|
||||
expect(row?.incoming_leg_transport_mode).toBe('transit');
|
||||
});
|
||||
|
||||
it('rejects an invalid direction with 400', async () => {
|
||||
@@ -170,9 +176,9 @@ describe('incoming_leg_transport_mode read-path parity', () => {
|
||||
.send({ transport_mode: 'transit', direction: 'incoming' })
|
||||
.expect(200);
|
||||
|
||||
const row = testDb2.prepare('SELECT leg_transport_mode, incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(assignmentId) as { leg_transport_mode: string | null; incoming_leg_transport_mode: string | null };
|
||||
expect(row.leg_transport_mode).toBe('cycling');
|
||||
expect(row.incoming_leg_transport_mode).toBe('transit');
|
||||
const row = await findRow(orm, DayAssignments, { id: assignmentId });
|
||||
expect(row?.leg_transport_mode).toBe('cycling');
|
||||
expect(row?.incoming_leg_transport_mode).toBe('transit');
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days`)
|
||||
|
||||
@@ -23,17 +23,26 @@ vi.mock('../../src/config', () => ({
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { db as testDb } from '../../src/db/database';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { createUser, createTrip, createPackingItem, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { findRows, insertRow } from '../helpers/factories/rows';
|
||||
import { PackingItems } from '../../src/db/entities/PackingItems.entity';
|
||||
import { PackingTemplates } from '../../src/db/entities/PackingTemplates.entity';
|
||||
import { PackingTemplateCategories } from '../../src/db/entities/PackingTemplateCategories.entity';
|
||||
import { PackingTemplateItems } from '../../src/db/entities/PackingTemplateItems.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: FactoryOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -352,9 +361,7 @@ describe('Reorder packing items', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const rows = testDb
|
||||
.prepare('SELECT id, sort_order FROM packing_items WHERE trip_id = ? ORDER BY sort_order')
|
||||
.all(trip.id) as Array<{ id: number; sort_order: number }>;
|
||||
const rows = await findRows(orm, PackingItems, { trip: trip.id }, { sort_order: 'asc' });
|
||||
expect(rows[0].id).toBe(i2.id);
|
||||
expect(rows[1].id).toBe(i1.id);
|
||||
});
|
||||
@@ -549,10 +556,9 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Beach', ?)").run(user.id);
|
||||
const cat = testDb.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Essentials', 0)").run(tpl.lastInsertRowid);
|
||||
testDb.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Sunscreen', 0)").run(cat.lastInsertRowid);
|
||||
const templateId = tpl.lastInsertRowid;
|
||||
const templateId = await insertRow(orm, PackingTemplates, { name: 'Beach', createdByRef: user.id });
|
||||
const categoryId = await insertRow(orm, PackingTemplateCategories, { template: templateId, name: 'Essentials', sort_order: 0 });
|
||||
await insertRow(orm, PackingTemplateItems, { category: categoryId, name: 'Sunscreen', sort_order: 0 });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/apply-template/${templateId}`)
|
||||
@@ -569,8 +575,7 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Template with no items
|
||||
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Empty', ?)").run(user.id);
|
||||
const emptyTemplateId = tpl.lastInsertRowid;
|
||||
const emptyTemplateId = await insertRow(orm, PackingTemplates, { name: 'Empty', createdByRef: user.id });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/apply-template/${emptyTemplateId}`)
|
||||
|
||||
@@ -42,18 +42,26 @@ vi.mock('../../src/config', () => ({
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { db as testDb } from '../../src/db/database';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember, createDayAssignment } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { closeMcpSessions } from '../../src/mcp/index';
|
||||
import { findRow, updateRows } from '../helpers/factories/rows';
|
||||
import { Addons } from '../../src/db/entities/Addons.entity';
|
||||
import { Categories } from '../../src/db/entities/Categories.entity';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: FactoryOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
@@ -137,8 +145,8 @@ describe('isOwner (async) — the delete_trip MCP tool', () => {
|
||||
return (JSON.parse(line.slice('data:'.length).trim()) as { result?: { isError?: boolean; content?: { type: string; text: string }[] } }).result ?? {};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
beforeEach(async () => {
|
||||
await updateRows(orm, Addons, { id: 'mcp' }, { enabled: true });
|
||||
});
|
||||
|
||||
it('PRIM-ISOWN-001 — owner: 200, deletes the trip', async () => {
|
||||
@@ -157,7 +165,7 @@ describe('isOwner (async) — the delete_trip MCP tool', () => {
|
||||
const result = toolResult(res.text);
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(JSON.parse(result.content?.[0]?.text ?? '{}')).toEqual({ success: true, tripId: trip.id });
|
||||
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeUndefined();
|
||||
expect(await findRow(orm, Trips, { id: trip.id })).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -215,7 +223,7 @@ describe('isOwner (async) — the delete_trip MCP tool', () => {
|
||||
const result = toolResult(res.text);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content?.[0]?.text).toBe('Trip not found or access denied.');
|
||||
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined();
|
||||
expect(await findRow(orm, Trips, { id: trip.id })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('PRIM-ISOWN-003 — non-member/stranger: the identical refusal isOwner gives a member, trip untouched', async () => {
|
||||
@@ -235,7 +243,7 @@ describe('isOwner (async) — the delete_trip MCP tool', () => {
|
||||
const result = toolResult(res.text);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content?.[0]?.text).toBe('Trip not found or access denied.');
|
||||
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined();
|
||||
expect(await findRow(orm, Trips, { id: trip.id })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('PRIM-ISOWN-004 — anonymous: 401, no session established', async () => {
|
||||
@@ -333,8 +341,9 @@ describe('getPlaceWithTags (async) — GET /api/trips/:tripId/places/:id', () =>
|
||||
// actually assigned rather than assuming none, so this pins the real
|
||||
// production shape instead of a factory default that happens to be null.
|
||||
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
const expectedCategory = place.category_id
|
||||
? testDb.prepare('SELECT id, name, color, icon FROM categories WHERE id = ?').get(place.category_id)
|
||||
const category = place.category_id ? await findRow(orm, Categories, { id: place.category_id }) : null;
|
||||
const expectedCategory = category
|
||||
? { id: category.id, name: category.name, color: category.color, icon: category.icon }
|
||||
: null;
|
||||
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/places/${place.id}`).set('Cookie', authCookie(owner.id));
|
||||
|
||||
@@ -55,6 +55,9 @@ import { generateToken } from '../helpers/auth';
|
||||
import { applyPlatformUploads } from '../../src/nest/platform/platform.routes';
|
||||
import { StorageService } from '../../src/nest/storage/storage.service';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
import { Photos } from '../../src/db/entities/Photos.entity';
|
||||
import { insertRow } from '../helpers/factories/rows';
|
||||
import { makeShareToken } from '../helpers/factories/trips';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
@@ -136,8 +139,8 @@ describe('GET /uploads/photos/:filename — request context (Plan 3b Task 0, D6)
|
||||
it('PHOTOCTX-004 (R1): a valid share token serves the SAME photo bytes as the JWT path, byte-identical', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare('INSERT INTO photos (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, photoName, photoName);
|
||||
testDb.prepare('INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, ?, ?)').run(trip.id, 'ratchet-valid-token', user.id);
|
||||
await insertRow(nestApp.get(MikroORM), Photos, { trip: trip.id, filename: photoName, original_name: photoName });
|
||||
await makeShareToken(nestApp.get(MikroORM), trip.id, user.id, { token: 'ratchet-valid-token' });
|
||||
|
||||
const res = await request(app).get(`/uploads/photos/${photoName}?token=ratchet-valid-token`);
|
||||
|
||||
@@ -148,7 +151,7 @@ describe('GET /uploads/photos/:filename — request context (Plan 3b Task 0, D6)
|
||||
it('PHOTOCTX-005 (R1): an invalid/unknown share token answers the legacy 401 — never `cannotUseGlobalContext`', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare('INSERT INTO photos (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, photoName, photoName);
|
||||
await insertRow(nestApp.get(MikroORM), Photos, { trip: trip.id, filename: photoName, original_name: photoName });
|
||||
// No matching share_tokens row for this token at all.
|
||||
|
||||
const res = await request(app).get(`/uploads/photos/${photoName}?token=ratchet-unknown-token`);
|
||||
|
||||
@@ -54,6 +54,7 @@ describe('primary-relation entities (Task 1 spike)', () => {
|
||||
t.em.clear();
|
||||
|
||||
const rawInsert = testDb
|
||||
// test-sql-allow: the raw row is the oracle the ORM's toObject() is held against.
|
||||
.prepare('SELECT * FROM budget_category_order WHERE trip_id = ? AND category = ?')
|
||||
.get(trip.id, 'Food');
|
||||
expect(rawInsert).toEqual({ trip_id: trip.id, category: 'Food', sort_order: 2 });
|
||||
@@ -81,6 +82,7 @@ describe('primary-relation entities (Task 1 spike)', () => {
|
||||
await t.em.persist(created).flush();
|
||||
t.em.clear();
|
||||
|
||||
// test-sql-allow: the raw row is the oracle the ORM's toObject() is held against.
|
||||
const rawInsert = testDb.prepare('SELECT * FROM vacay_user_settings WHERE user_id = ?').get(user.id);
|
||||
expect(rawInsert).toEqual({
|
||||
user_id: user.id,
|
||||
@@ -168,6 +170,7 @@ describe('primary-relation entities (Task 1 spike)', () => {
|
||||
expect(found?.key).toBe('spike_setting');
|
||||
expect(found?.value).toBe('on');
|
||||
|
||||
// test-sql-allow: the raw row is the oracle the ORM's toObject() is held against.
|
||||
const raw = testDb.prepare('SELECT * FROM app_settings WHERE key = ?').get('spike_setting');
|
||||
expect(wrap(found).toObject()).toStrictEqual(raw);
|
||||
});
|
||||
|
||||
@@ -13,18 +13,25 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createBudgetItem, createPlace, createReservation, createTrip, createUser } from '../../../helpers/factories';
|
||||
import { createTestBudgetItemsRepo } from '../../../helpers/files-repos';
|
||||
import { sharedTestOrm } from '../../../helpers/test-uow';
|
||||
import type { TestOrm } from '../../../helpers/test-orm';
|
||||
import { findRow, findRows, updateRows } from '../../../helpers/factories/rows';
|
||||
import { BudgetItems } from '../../../../src/db/entities/BudgetItems.entity';
|
||||
import type { BudgetItemsRepository } from '../../../../src/db/repositories/BudgetItems.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let budgetItemsRepo: BudgetItemsRepository;
|
||||
let orm: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
budgetItemsRepo = await createTestBudgetItemsRepo(testDb);
|
||||
orm = await sharedTestOrm(testDb);
|
||||
});
|
||||
beforeEach(() => resetTestDb(testDb));
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
function legacyPublicForShare(tripId: number): unknown {
|
||||
// test-sql-allow: the raw SELECT is the legacy oracle this parity test holds the repository to.
|
||||
return testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId);
|
||||
}
|
||||
|
||||
@@ -38,18 +45,18 @@ describe('BudgetItemsRepository — share.service.ts SH14 read', () => {
|
||||
|
||||
// Every nullable column set — sorts second by category.
|
||||
const withDetails = createBudgetItem(testDb, trip.id, { name: 'Hotel', category: 'Lodging', total_price: 250 });
|
||||
testDb.prepare(`
|
||||
UPDATE budget_items SET persons = 2, days = 3, note = 'non-refundable', sort_order = 4,
|
||||
paid_by_user_id = ?, expense_date = '2026-09-02', reservation_id = ?, currency = 'EUR',
|
||||
exchange_rate = 0.92, ticket_json = '{"seat":"12A"}', place_id = ?
|
||||
WHERE id = ?`).run(user.id, reservation.id, place.id, withDetails.id);
|
||||
await updateRows(orm, BudgetItems, { id: withDetails.id }, {
|
||||
persons: 2, days: 3, note: 'non-refundable', sort_order: 4,
|
||||
paidByUser: user.id, expense_date: '2026-09-02', reservation: reservation.id, currency: 'EUR',
|
||||
exchange_rate: 0.92, ticket_json: '{"seat":"12A"}', place: place.id,
|
||||
});
|
||||
|
||||
// Every nullable column left null — sorts first by category.
|
||||
const bare = createBudgetItem(testDb, trip.id, { name: 'Snacks', category: 'Food', total_price: 12.5 });
|
||||
testDb.prepare(`
|
||||
UPDATE budget_items SET persons = NULL, days = NULL, note = NULL, paid_by_user_id = NULL,
|
||||
expense_date = NULL, reservation_id = NULL, currency = NULL, place_id = NULL
|
||||
WHERE id = ?`).run(bare.id);
|
||||
await updateRows(orm, BudgetItems, { id: bare.id }, {
|
||||
persons: null, days: null, note: null, paidByUser: null,
|
||||
expense_date: null, reservation: null, currency: null, place: null,
|
||||
});
|
||||
|
||||
// A budget item on a different trip must never leak in.
|
||||
createBudgetItem(testDb, other.id, { category: 'Other' });
|
||||
@@ -76,7 +83,7 @@ describe('BudgetItemsRepository — reservations.service.ts RS49 delete', () =>
|
||||
const kept = createBudgetItem(testDb, trip.id, { name: 'Hotel' });
|
||||
|
||||
await budgetItemsRepo.deleteByIds([first.id, second.id]);
|
||||
expect((testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ?').all(trip.id) as { id: number }[]).map((r) => r.id))
|
||||
expect((await findRows(orm, BudgetItems, { trip: trip.id })).map((r) => r.id))
|
||||
.toEqual([kept.id]);
|
||||
});
|
||||
|
||||
@@ -85,6 +92,6 @@ describe('BudgetItemsRepository — reservations.service.ts RS49 delete', () =>
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const kept = createBudgetItem(testDb, trip.id);
|
||||
await budgetItemsRepo.deleteByIds([]);
|
||||
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(kept.id)).toEqual({ id: kept.id });
|
||||
expect(await findRow(orm, BudgetItems, { id: kept.id })).toMatchObject({ id: kept.id });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createUser } from '../../../helpers/factories';
|
||||
import { countRows, findRow, insertRow, upsertRow } from '../../../helpers/factories/rows';
|
||||
import { DawarichVisitSuggestions } from '../../../../src/db/entities/DawarichVisitSuggestions.entity';
|
||||
import { DawarichConnections } from '../../../../src/db/entities/DawarichConnections.entity';
|
||||
import type { DawarichConnectionsRepository } from '../../../../src/db/repositories/DawarichConnections.repository';
|
||||
|
||||
@@ -24,7 +26,7 @@ afterAll(async () => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function seed(userId: number, over: Partial<{
|
||||
async function seed(userId: number, over: Partial<{
|
||||
url: string | null;
|
||||
api_key: string | null;
|
||||
allow_insecure_tls: number;
|
||||
@@ -33,28 +35,22 @@ function seed(userId: number, over: Partial<{
|
||||
last_sync_state: string;
|
||||
last_sync_error: string | null;
|
||||
capabilities: string | null;
|
||||
}> = {}): void {
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO dawarich_connections
|
||||
(user_id, url, api_key, allow_insecure_tls, sync_enabled, last_sync_at, last_sync_state, last_sync_error, capabilities)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
over.url === undefined ? 'https://dawarich.example' : over.url,
|
||||
over.api_key === undefined ? 'enc:v1:cipher' : over.api_key,
|
||||
over.allow_insecure_tls ?? 0,
|
||||
over.sync_enabled ?? 1,
|
||||
over.last_sync_at ?? null,
|
||||
over.last_sync_state ?? 'never',
|
||||
over.last_sync_error ?? null,
|
||||
over.capabilities ?? null,
|
||||
);
|
||||
}> = {}): Promise<void> {
|
||||
await upsertRow(t, DawarichConnections, {
|
||||
user: userId,
|
||||
url: over.url === undefined ? 'https://dawarich.example' : over.url,
|
||||
api_key: over.api_key === undefined ? 'enc:v1:cipher' : over.api_key,
|
||||
allow_insecure_tls: over.allow_insecure_tls ?? 0,
|
||||
sync_enabled: over.sync_enabled ?? 1,
|
||||
last_sync_at: over.last_sync_at ?? null,
|
||||
last_sync_state: over.last_sync_state ?? 'never',
|
||||
last_sync_error: over.last_sync_error ?? null,
|
||||
capabilities: over.capabilities ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function raw(userId: number): Record<string, unknown> | undefined {
|
||||
return testDb.prepare('SELECT * FROM dawarich_connections WHERE user_id = ?').get(userId) as Record<string, unknown> | undefined;
|
||||
function raw(userId: number) {
|
||||
return findRow(t, DawarichConnections, { user: userId });
|
||||
}
|
||||
|
||||
describe('DawarichConnectionsRepository', () => {
|
||||
@@ -66,7 +62,7 @@ describe('DawarichConnectionsRepository', () => {
|
||||
|
||||
it('CONNREPO-002: a fully-seeded row comes back byte-identical to a legacy SELECT of the same 9 columns', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seed(user.id, {
|
||||
await seed(user.id, {
|
||||
url: 'https://dawarich.example/api',
|
||||
api_key: 'enc:v1:abcdef',
|
||||
allow_insecure_tls: 1,
|
||||
@@ -78,7 +74,8 @@ describe('DawarichConnectionsRepository', () => {
|
||||
});
|
||||
|
||||
const row = await connections.findRow(user.id);
|
||||
const legacy = raw(user.id)!;
|
||||
// test-sql-allow: the legacy SELECT of the same 9 columns is the oracle this case holds findRow to.
|
||||
const legacy = testDb.prepare('SELECT * FROM dawarich_connections WHERE user_id = ?').get(user.id) as Record<string, unknown>;
|
||||
|
||||
expect(row).toEqual({
|
||||
user_id: legacy.user_id,
|
||||
@@ -102,11 +99,11 @@ describe('DawarichConnectionsRepository', () => {
|
||||
const blankUrl = createUser(testDb).user.id;
|
||||
const noKey = createUser(testDb).user.id;
|
||||
|
||||
seed(syncable);
|
||||
seed(pollOff, { sync_enabled: 0 });
|
||||
seed(noUrl, { url: null });
|
||||
seed(blankUrl, { url: '' });
|
||||
seed(noKey, { api_key: null });
|
||||
await seed(syncable);
|
||||
await seed(pollOff, { sync_enabled: 0 });
|
||||
await seed(noUrl, { url: null });
|
||||
await seed(blankUrl, { url: '' });
|
||||
await seed(noKey, { api_key: null });
|
||||
|
||||
expect(await connections.listSyncableUserIds()).toEqual([syncable]);
|
||||
});
|
||||
@@ -116,9 +113,9 @@ describe('DawarichConnectionsRepository', () => {
|
||||
it('CONNREPO-020: the stored url, or null for no row / a null column', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(await connections.getUrl(user.id)).toBeNull();
|
||||
seed(user.id, { url: 'https://d.example' });
|
||||
await seed(user.id, { url: 'https://d.example' });
|
||||
expect(await connections.getUrl(user.id)).toBe('https://d.example');
|
||||
seed(user.id, { url: null });
|
||||
await seed(user.id, { url: null });
|
||||
expect(await connections.getUrl(user.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -128,18 +125,18 @@ describe('DawarichConnectionsRepository', () => {
|
||||
const { user } = createUser(testDb);
|
||||
await connections.upsertConnection(user.id, { url: 'https://d.example', allowInsecureTls: true, syncEnabled: false });
|
||||
|
||||
const row = raw(user.id)!;
|
||||
const row = (await raw(user.id))!;
|
||||
expect(row).toMatchObject({ url: 'https://d.example', allow_insecure_tls: 1, sync_enabled: 0, api_key: null, capabilities: null, last_sync_state: 'never' });
|
||||
expect(row.updated_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('CONNREPO-031: on conflict merges ONLY url/allow_insecure_tls/sync_enabled/updated_at — api_key and capabilities survive untouched', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seed(user.id, { url: 'https://old.example', api_key: 'enc:v1:stored', capabilities: '{"visits":true}', last_sync_state: 'ok' });
|
||||
await seed(user.id, { url: 'https://old.example', api_key: 'enc:v1:stored', capabilities: '{"visits":true}', last_sync_state: 'ok' });
|
||||
|
||||
await connections.upsertConnection(user.id, { url: 'https://new.example', allowInsecureTls: true, syncEnabled: true });
|
||||
|
||||
const row = raw(user.id)!;
|
||||
const row = (await raw(user.id))!;
|
||||
expect(row.url).toBe('https://new.example');
|
||||
expect(row.allow_insecure_tls).toBe(1);
|
||||
expect(row.api_key).toBe('enc:v1:stored');
|
||||
@@ -154,31 +151,31 @@ describe('DawarichConnectionsRepository', () => {
|
||||
|
||||
await connections.upsertConnection(user.id, { url: 'https://d.example', allowInsecureTls: false, syncEnabled: false });
|
||||
|
||||
expect(raw(user.id)!.updated_at).not.toBe('2000-01-01 00:00:00');
|
||||
expect((await raw(user.id))!.updated_at).not.toBe('2000-01-01 00:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setApiKey (DWC5/DWC7)', () => {
|
||||
it('CONNREPO-040: writes the given ciphertext, and null scrubs it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seed(user.id, { api_key: 'enc:v1:old' });
|
||||
await seed(user.id, { api_key: 'enc:v1:old' });
|
||||
|
||||
await connections.setApiKey(user.id, 'enc:v1:new');
|
||||
expect(raw(user.id)!.api_key).toBe('enc:v1:new');
|
||||
expect((await raw(user.id))!.api_key).toBe('enc:v1:new');
|
||||
|
||||
await connections.setApiKey(user.id, null);
|
||||
expect(raw(user.id)!.api_key).toBeNull();
|
||||
expect((await raw(user.id))!.api_key).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetSyncState (DWC6)', () => {
|
||||
it('CONNREPO-050: clears capabilities/last_sync_* without touching api_key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seed(user.id, { api_key: 'enc:v1:kept', capabilities: '{"a":1}', last_sync_state: 'ok', last_sync_error: 'x', last_sync_at: '2026-01-01T00:00:00Z' });
|
||||
await seed(user.id, { api_key: 'enc:v1:kept', capabilities: '{"a":1}', last_sync_state: 'ok', last_sync_error: 'x', last_sync_at: '2026-01-01T00:00:00Z' });
|
||||
|
||||
await connections.resetSyncState(user.id);
|
||||
|
||||
const row = raw(user.id)!;
|
||||
const row = (await raw(user.id))!;
|
||||
expect(row).toMatchObject({ api_key: 'enc:v1:kept', capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null });
|
||||
});
|
||||
});
|
||||
@@ -186,11 +183,11 @@ describe('DawarichConnectionsRepository', () => {
|
||||
describe('clearForRemovedUrl (DWC8)', () => {
|
||||
it('CONNREPO-060: clears api_key together with capabilities/last_sync_* in one statement', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seed(user.id, { api_key: 'enc:v1:gone', capabilities: '{"a":1}', last_sync_state: 'ok', last_sync_error: 'x', last_sync_at: '2026-01-01T00:00:00Z' });
|
||||
await seed(user.id, { api_key: 'enc:v1:gone', capabilities: '{"a":1}', last_sync_state: 'ok', last_sync_error: 'x', last_sync_at: '2026-01-01T00:00:00Z' });
|
||||
|
||||
await connections.clearForRemovedUrl(user.id);
|
||||
|
||||
expect(raw(user.id)!).toMatchObject({ api_key: null, capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null });
|
||||
expect((await raw(user.id))!).toMatchObject({ api_key: null, capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,30 +195,27 @@ describe('DawarichConnectionsRepository', () => {
|
||||
it('CONNREPO-070: removes only the named user\'s row, and does not cascade to suggestions (no FK relation exists for it to cascade through)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const other = createUser(testDb).user.id;
|
||||
seed(user.id);
|
||||
seed(other);
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT INTO dawarich_visit_suggestions
|
||||
(user_id, source_visit_id, name, lat, lng, started_at, ended_at, local_date, source_hash)
|
||||
VALUES (?, 'v1', 'Cafe', 1, 1, '2026-01-01T00:00:00Z', '2026-01-01T01:00:00Z', '2026-01-01', 'hash')`,
|
||||
)
|
||||
.run(user.id);
|
||||
await seed(user.id);
|
||||
await seed(other);
|
||||
await insertRow(t, DawarichVisitSuggestions, {
|
||||
user: user.id, source_visit_id: 'v1', name: 'Cafe', lat: 1, lng: 1,
|
||||
started_at: '2026-01-01T00:00:00Z', ended_at: '2026-01-01T01:00:00Z', local_date: '2026-01-01', source_hash: 'hash',
|
||||
});
|
||||
|
||||
await connections.disconnect(user.id);
|
||||
|
||||
expect(raw(user.id)).toBeUndefined();
|
||||
expect(raw(other)).toBeDefined();
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM dawarich_visit_suggestions WHERE user_id = ?').get(user.id)).toEqual({ n: 1 });
|
||||
expect(await raw(user.id)).toBeNull();
|
||||
expect(await raw(other)).not.toBeNull();
|
||||
expect(await countRows(t, DawarichVisitSuggestions, { user: user.id })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeCapabilities (DWC10)', () => {
|
||||
it('CONNREPO-080: writes the JSON blob and stamps updated_at', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seed(user.id);
|
||||
await seed(user.id);
|
||||
await connections.storeCapabilities(user.id, '{"visits":true,"tracks":false}');
|
||||
expect(raw(user.id)!.capabilities).toBe('{"visits":true,"tracks":false}');
|
||||
expect((await raw(user.id))!.capabilities).toBe('{"visits":true,"tracks":false}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,13 +223,13 @@ describe('DawarichConnectionsRepository', () => {
|
||||
it('CONNREPO-090: writes all four columns for the named user only', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const other = createUser(testDb).user.id;
|
||||
seed(user.id);
|
||||
seed(other, { last_sync_state: 'ok' });
|
||||
await seed(user.id);
|
||||
await seed(other, { last_sync_state: 'ok' });
|
||||
|
||||
await connections.recordSyncResult(user.id, { lastSyncAt: '2026-09-12T04:15:00.000Z', state: 'failed', error: 'unreachable' });
|
||||
|
||||
expect(raw(user.id)!).toMatchObject({ last_sync_at: '2026-09-12T04:15:00.000Z', last_sync_state: 'failed', last_sync_error: 'unreachable' });
|
||||
expect(raw(other)!.last_sync_state).toBe('ok');
|
||||
expect((await raw(user.id))!).toMatchObject({ last_sync_at: '2026-09-12T04:15:00.000Z', last_sync_state: 'failed', last_sync_error: 'unreachable' });
|
||||
expect((await raw(other))!.last_sync_state).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,7 +237,7 @@ describe('DawarichConnectionsRepository', () => {
|
||||
it('CONNREPO-100: the stored state, or null with no row at all', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(await connections.getLastSyncState(user.id)).toBeNull();
|
||||
seed(user.id, { last_sync_state: 'partial' });
|
||||
await seed(user.id, { last_sync_state: 'partial' });
|
||||
expect(await connections.getLastSyncState(user.id)).toBe('partial');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createUser } from '../../../helpers/factories';
|
||||
import { findRow, insertRow } from '../../../helpers/factories/rows';
|
||||
import { PasswordResetTokens } from '../../../../src/db/entities/PasswordResetTokens.entity';
|
||||
import type { PasswordResetTokensRepository } from '../../../../src/db/repositories/PasswordResetTokens.repository';
|
||||
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
|
||||
@@ -29,16 +30,21 @@ function hash(raw: string): string {
|
||||
return createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
function rawRow(id: number): { id: number; user_id: number; token_hash: string; expires_at: string; consumed_at: string | null; created_ip: string | null } {
|
||||
return testDb.prepare('SELECT id, user_id, token_hash, expires_at, consumed_at, created_ip FROM password_reset_tokens WHERE id = ?').get(id) as never;
|
||||
async function rawRow(id: number) {
|
||||
const row = await findRow(t, PasswordResetTokens, { id });
|
||||
if (!row) throw new Error(`no password reset token ${id}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
function seedToken(userId: number, tokenHash: string, opts: { expires_at?: string; consumed_at?: string | null; created_ip?: string | null } = {}): number {
|
||||
async function seedToken(userId: number, tokenHash: string, opts: { expires_at?: string; consumed_at?: string | null; created_ip?: string | null } = {}): Promise<number> {
|
||||
const expiresAt = opts.expires_at ?? new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const result = testDb
|
||||
.prepare('INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, consumed_at, created_ip) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(userId, tokenHash, expiresAt, opts.consumed_at ?? null, opts.created_ip ?? null);
|
||||
return Number(result.lastInsertRowid);
|
||||
return insertRow(t, PasswordResetTokens, {
|
||||
user: userId,
|
||||
token_hash: tokenHash,
|
||||
expires_at: expiresAt,
|
||||
consumed_at: opts.consumed_at ?? null,
|
||||
created_ip: opts.created_ip ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
describe('PasswordResetTokensRepository', () => {
|
||||
@@ -50,8 +56,8 @@ describe('PasswordResetTokensRepository', () => {
|
||||
|
||||
await tokens.insertToken({ user_id: user.id, token_hash: tokenHash, expires_at: expiresAt, created_ip: '1.2.3.4' });
|
||||
|
||||
const row = testDb.prepare('SELECT user_id, token_hash, expires_at, consumed_at, created_ip FROM password_reset_tokens WHERE token_hash = ?').get(tokenHash);
|
||||
expect(row).toEqual({ user_id: user.id, token_hash: tokenHash, expires_at: expiresAt, consumed_at: null, created_ip: '1.2.3.4' });
|
||||
const row = await findRow(t, PasswordResetTokens, { token_hash: tokenHash });
|
||||
expect(row).toMatchObject({ user_id: user.id, token_hash: tokenHash, expires_at: expiresAt, consumed_at: null, created_ip: '1.2.3.4' });
|
||||
});
|
||||
|
||||
it('PWDRESETREPO-002: writes a NULL created_ip', async () => {
|
||||
@@ -59,8 +65,8 @@ describe('PasswordResetTokensRepository', () => {
|
||||
const tokenHash = hash('no-ip-token');
|
||||
await tokens.insertToken({ user_id: user.id, token_hash: tokenHash, expires_at: new Date().toISOString(), created_ip: null });
|
||||
|
||||
const row = testDb.prepare('SELECT created_ip FROM password_reset_tokens WHERE token_hash = ?').get(tokenHash) as { created_ip: string | null };
|
||||
expect(row.created_ip).toBeNull();
|
||||
const row = await findRow(t, PasswordResetTokens, { token_hash: tokenHash });
|
||||
expect(row?.created_ip).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,7 +74,7 @@ describe('PasswordResetTokensRepository', () => {
|
||||
it('PWDRESETREPO-003: finds a live token by its hash', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tokenHash = hash('lookup-me');
|
||||
const id = seedToken(user.id, tokenHash);
|
||||
const id = await seedToken(user.id, tokenHash);
|
||||
|
||||
const found = await tokens.findByTokenHash(tokenHash);
|
||||
|
||||
@@ -78,7 +84,7 @@ describe('PasswordResetTokensRepository', () => {
|
||||
it('PWDRESETREPO-004: a consumed token is still found (consumed_at is checked by the caller, not filtered here)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tokenHash = hash('already-used');
|
||||
seedToken(user.id, tokenHash, { consumed_at: '2026-01-01T00:00:00.000Z' });
|
||||
await seedToken(user.id, tokenHash, { consumed_at: '2026-01-01T00:00:00.000Z' });
|
||||
|
||||
const found = await tokens.findByTokenHash(tokenHash);
|
||||
|
||||
@@ -93,54 +99,54 @@ describe('PasswordResetTokensRepository', () => {
|
||||
describe('markConsumed', () => {
|
||||
it('PWDRESETREPO-006: stamps consumed_at on the target row only', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const target = seedToken(user.id, hash('mark-me'));
|
||||
const other = seedToken(user.id, hash('leave-me'));
|
||||
const target = await seedToken(user.id, hash('mark-me'));
|
||||
const other = await seedToken(user.id, hash('leave-me'));
|
||||
|
||||
await tokens.markConsumed(target);
|
||||
|
||||
expect(rawRow(target).consumed_at).not.toBeNull();
|
||||
expect(rawRow(other).consumed_at).toBeNull();
|
||||
expect((await rawRow(target)).consumed_at).not.toBeNull();
|
||||
expect((await rawRow(other)).consumed_at).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('consumeAllLiveForUser', () => {
|
||||
it('PWDRESETREPO-007: without an exclusion, burns every live token for the user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const a = seedToken(user.id, hash('a'));
|
||||
const b = seedToken(user.id, hash('b'));
|
||||
const a = await seedToken(user.id, hash('a'));
|
||||
const b = await seedToken(user.id, hash('b'));
|
||||
|
||||
await tokens.consumeAllLiveForUser(user.id);
|
||||
|
||||
expect(rawRow(a).consumed_at).not.toBeNull();
|
||||
expect(rawRow(b).consumed_at).not.toBeNull();
|
||||
expect((await rawRow(a)).consumed_at).not.toBeNull();
|
||||
expect((await rawRow(b)).consumed_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('PWDRESETREPO-008: with an exclusion, burns every OTHER live token but leaves the excluded id alone', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const keep = seedToken(user.id, hash('keep-me'));
|
||||
const burn1 = seedToken(user.id, hash('burn-1'));
|
||||
const burn2 = seedToken(user.id, hash('burn-2'));
|
||||
const keep = await seedToken(user.id, hash('keep-me'));
|
||||
const burn1 = await seedToken(user.id, hash('burn-1'));
|
||||
const burn2 = await seedToken(user.id, hash('burn-2'));
|
||||
|
||||
await tokens.consumeAllLiveForUser(user.id, keep);
|
||||
|
||||
expect(rawRow(keep).consumed_at).toBeNull();
|
||||
expect(rawRow(burn1).consumed_at).not.toBeNull();
|
||||
expect(rawRow(burn2).consumed_at).not.toBeNull();
|
||||
expect((await rawRow(keep)).consumed_at).toBeNull();
|
||||
expect((await rawRow(burn1)).consumed_at).not.toBeNull();
|
||||
expect((await rawRow(burn2)).consumed_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('PWDRESETREPO-009: never touches another user\'s tokens or an already-consumed row', async () => {
|
||||
const { user: victim } = createUser(testDb);
|
||||
const { user: bystander } = createUser(testDb);
|
||||
const already = seedToken(victim.id, hash('already-consumed'), { consumed_at: '2026-01-01T00:00:00.000Z' });
|
||||
const bystanderToken = seedToken(bystander.id, hash('bystander'));
|
||||
const already = await seedToken(victim.id, hash('already-consumed'), { consumed_at: '2026-01-01T00:00:00.000Z' });
|
||||
const bystanderToken = await seedToken(bystander.id, hash('bystander'));
|
||||
|
||||
await tokens.consumeAllLiveForUser(victim.id);
|
||||
|
||||
// The already-consumed row's consumed_at is untouched (still the original
|
||||
// stamp, not overwritten by this call — the WHERE only matches consumed_at
|
||||
// IS NULL rows).
|
||||
expect(rawRow(already).consumed_at).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect(rawRow(bystanderToken).consumed_at).toBeNull();
|
||||
expect((await rawRow(already)).consumed_at).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect((await rawRow(bystanderToken)).consumed_at).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +182,7 @@ describe('PasswordResetTokensRepository', () => {
|
||||
describe('identity-map isolation', () => {
|
||||
it('PWDRESETREPO-010: findByTokenHash never leaves a managed entity behind', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedToken(user.id, hash('isolated'));
|
||||
const id = await seedToken(user.id, hash('isolated'));
|
||||
|
||||
await tokens.findByTokenHash(hash('isolated'));
|
||||
|
||||
@@ -185,7 +191,7 @@ describe('PasswordResetTokensRepository', () => {
|
||||
|
||||
it('PWDRESETREPO-011: findByTokenHash then markConsumed inside uow.transactional survives the closing flush (cannot fail by construction — see PWDRESETREPO-012 for the mutation-proved guard)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedToken(user.id, hash('survives'));
|
||||
const id = await seedToken(user.id, hash('survives'));
|
||||
const uow = new UnitOfWork(t.em);
|
||||
|
||||
await uow.transactional(async () => {
|
||||
@@ -194,12 +200,12 @@ describe('PasswordResetTokensRepository', () => {
|
||||
await tokens.markConsumed(id);
|
||||
});
|
||||
|
||||
expect(rawRow(id).consumed_at).not.toBeNull();
|
||||
expect((await rawRow(id)).consumed_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('PWDRESETREPO-012: findByTokenHash is the FIRST, wider read (it carries consumed_at); a narrower managed read follows; markConsumed still lands', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedToken(user.id, hash('dshape'));
|
||||
const id = await seedToken(user.id, hash('dshape'));
|
||||
const uow = new UnitOfWork(t.em);
|
||||
|
||||
await uow.transactional(async () => {
|
||||
@@ -208,12 +214,12 @@ describe('PasswordResetTokensRepository', () => {
|
||||
await tokens.markConsumed(id);
|
||||
});
|
||||
|
||||
expect(rawRow(id).consumed_at).not.toBeNull();
|
||||
expect((await rawRow(id)).consumed_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('PWDRESETREPO-013: same D-shape guard for consumeAllLiveForUser', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedToken(user.id, hash('dshape-consume-all'));
|
||||
const id = await seedToken(user.id, hash('dshape-consume-all'));
|
||||
const uow = new UnitOfWork(t.em);
|
||||
|
||||
await uow.transactional(async () => {
|
||||
@@ -222,7 +228,7 @@ describe('PasswordResetTokensRepository', () => {
|
||||
await tokens.consumeAllLiveForUser(user.id);
|
||||
});
|
||||
|
||||
expect(rawRow(id).consumed_at).not.toBeNull();
|
||||
expect((await rawRow(id)).consumed_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createUser, createTrip } from '../../../helpers/factories';
|
||||
import { findRow, insertRow } from '../../../helpers/factories/rows';
|
||||
import { TodoItems } from '../../../../src/db/entities/TodoItems.entity';
|
||||
import type { TodoItemsRepository } from '../../../../src/db/repositories/TodoItems.repository';
|
||||
|
||||
@@ -62,11 +63,12 @@ describe('listDueForReminder', () => {
|
||||
'',
|
||||
];
|
||||
for (const s of shapes) {
|
||||
testDb.prepare('INSERT INTO todo_items (trip_id, name, due_date, checked) VALUES (?, ?, ?, 0)').run(trip.id, 'n:' + s, s);
|
||||
await insertRow(t, TodoItems, { trip: trip.id, name: 'n:' + s, due_date: s, checked: 0 });
|
||||
}
|
||||
|
||||
const legacy = (
|
||||
testDb
|
||||
// test-sql-allow: the legacy statement is this parity test's oracle and has to run as written.
|
||||
.prepare(
|
||||
`SELECT ti.id FROM todo_items ti JOIN trips t ON t.id = ti.trip_id
|
||||
WHERE ti.checked = 0 AND ti.due_date IS NOT NULL AND ti.due_date <> ''
|
||||
@@ -79,9 +81,16 @@ describe('listDueForReminder', () => {
|
||||
).map((r) => r.id);
|
||||
|
||||
const head = (await repo.listDueForReminder(iso(t0), iso(plus(LEAD)))).map((r) => r.id).sort((a, b) => a - b);
|
||||
const names = (ids: number[]) => ids.map((i) => (testDb.prepare('SELECT name FROM todo_items WHERE id=?').get(i) as { name: string }).name);
|
||||
const names = (ids: number[]) =>
|
||||
Promise.all(
|
||||
ids.map(async (i) => {
|
||||
const row = await findRow(t, TodoItems, { id: i });
|
||||
if (!row) throw new Error(`no todo item ${i}`);
|
||||
return row.name;
|
||||
}),
|
||||
);
|
||||
|
||||
expect(names(head)).toEqual(names(legacy));
|
||||
expect(await names(head)).toEqual(await names(legacy));
|
||||
expect(head.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createUser } from '../../../helpers/factories';
|
||||
import { insertRows } from '../../../helpers/factories/rows';
|
||||
import { makeVacayPlan } from '../../../helpers/factories/vacay';
|
||||
import { VacayCompanyHolidays } from '../../../../src/db/entities/VacayCompanyHolidays.entity';
|
||||
import type { VacayCompanyHolidaysRepository } from '../../../../src/db/repositories/VacayCompanyHolidays.repository';
|
||||
|
||||
@@ -22,19 +24,22 @@ beforeAll(async () => {
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function insertPlan(ownerId: number): number {
|
||||
return Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(ownerId).lastInsertRowid);
|
||||
async function insertPlan(ownerId: number): Promise<number> {
|
||||
return (await makeVacayPlan(t, ownerId)).id;
|
||||
}
|
||||
|
||||
describe('VacayCompanyHolidaysRepository.listForPlan (VC29/67)', () => {
|
||||
it('VACAYCOHOLREPO-001: matches SELECT date, note, fraction FROM vacay_company_holidays WHERE plan_id = ? run raw, note both NULL and SET', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const planId = insertPlan(user.id);
|
||||
const other = insertPlan(createUser(testDb).user.id);
|
||||
testDb.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, '2026-12-25', 'Christmas');
|
||||
testDb.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, '2026-01-01', null);
|
||||
testDb.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(other, '2026-07-04', 'Not mine');
|
||||
const planId = await insertPlan(user.id);
|
||||
const other = await insertPlan(createUser(testDb).user.id);
|
||||
await insertRows(t, VacayCompanyHolidays, [
|
||||
{ plan: planId, date: '2026-12-25', note: 'Christmas' },
|
||||
{ plan: planId, date: '2026-01-01', note: null },
|
||||
{ plan: other, date: '2026-07-04', note: 'Not mine' },
|
||||
]);
|
||||
|
||||
// test-sql-allow: the raw SELECT is the legacy oracle this parity test holds the repository to.
|
||||
const legacy = testDb.prepare('SELECT date, note, fraction FROM vacay_company_holidays WHERE plan_id = ?').all(planId);
|
||||
const rows = await repo.listForPlan(planId);
|
||||
|
||||
@@ -44,7 +49,7 @@ describe('VacayCompanyHolidaysRepository.listForPlan (VC29/67)', () => {
|
||||
|
||||
it('VACAYCOHOLREPO-002: empty array for a plan with none', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const planId = insertPlan(user.id);
|
||||
const planId = await insertPlan(user.id);
|
||||
expect(await repo.listForPlan(planId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createUser } from '../../../helpers/factories';
|
||||
import { insertRow } from '../../../helpers/factories/rows';
|
||||
import { makeVacayPlan } from '../../../helpers/factories/vacay';
|
||||
import { VacayHolidayCalendars } from '../../../../src/db/entities/VacayHolidayCalendars.entity';
|
||||
import type { VacayHolidayCalendarsRepository } from '../../../../src/db/repositories/VacayHolidayCalendars.repository';
|
||||
|
||||
@@ -24,33 +26,31 @@ beforeAll(async () => {
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function insertPlan(ownerId: number): number {
|
||||
return Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(ownerId).lastInsertRowid);
|
||||
async function insertPlan(ownerId: number): Promise<number> {
|
||||
return (await makeVacayPlan(t, ownerId)).id;
|
||||
}
|
||||
|
||||
function insertCalendar(planId: number, overrides: Partial<{ type: string; region: string; label: string | null; color: string; sort_order: number }> = {}): number {
|
||||
const result = testDb.prepare(
|
||||
'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
).run(
|
||||
planId,
|
||||
overrides.type ?? 'public_holiday',
|
||||
overrides.region ?? 'DE',
|
||||
overrides.label === undefined ? null : overrides.label,
|
||||
overrides.color ?? '#fecaca',
|
||||
overrides.sort_order ?? 0,
|
||||
);
|
||||
return Number(result.lastInsertRowid);
|
||||
async function insertCalendar(planId: number, overrides: Partial<{ type: string; region: string; label: string | null; color: string; sort_order: number }> = {}): Promise<number> {
|
||||
return insertRow(t, VacayHolidayCalendars, {
|
||||
plan: planId,
|
||||
type: overrides.type ?? 'public_holiday',
|
||||
region: overrides.region ?? 'DE',
|
||||
label: overrides.label === undefined ? null : overrides.label,
|
||||
color: overrides.color ?? '#fecaca',
|
||||
sort_order: overrides.sort_order ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
describe('VacayHolidayCalendarsRepository.listForPlan (VC37/131, getPlanData)', () => {
|
||||
it('VACAYHOLCALREPO-001: matches SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id run raw, label both NULL and SET', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const planId = insertPlan(user.id);
|
||||
const other = insertPlan(createUser(testDb).user.id);
|
||||
const second = insertCalendar(planId, { region: 'DE', sort_order: 1, label: 'Bavaria' });
|
||||
const first = insertCalendar(planId, { region: 'FR', sort_order: 0, label: null });
|
||||
insertCalendar(other, { region: 'US' });
|
||||
const planId = await insertPlan(user.id);
|
||||
const other = await insertPlan(createUser(testDb).user.id);
|
||||
const second = await insertCalendar(planId, { region: 'DE', sort_order: 1, label: 'Bavaria' });
|
||||
const first = await insertCalendar(planId, { region: 'FR', sort_order: 0, label: null });
|
||||
await insertCalendar(other, { region: 'US' });
|
||||
|
||||
// test-sql-allow: the raw SELECT is the legacy oracle this parity test holds the repository to.
|
||||
const legacy = testDb.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order ASC, id ASC').all(planId);
|
||||
const rows = await repo.listForPlan(planId);
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('VacayHolidayCalendarsRepository.listForPlan (VC37/131, getPlanData)',
|
||||
|
||||
it('VACAYHOLCALREPO-002: empty array for a plan with none', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const planId = insertPlan(user.id);
|
||||
const planId = await insertPlan(user.id);
|
||||
expect(await repo.listForPlan(planId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -68,9 +68,10 @@ describe('VacayHolidayCalendarsRepository.listForPlan (VC37/131, getPlanData)',
|
||||
describe('VacayHolidayCalendarsRepository.findById (VC39/42)', () => {
|
||||
it('VACAYHOLCALREPO-003: matches SELECT * FROM vacay_holiday_calendars WHERE id = ? run raw', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const planId = insertPlan(user.id);
|
||||
const id = insertCalendar(planId, { type: 'school_holiday', region: 'DE-BY', label: 'Summer' });
|
||||
const planId = await insertPlan(user.id);
|
||||
const id = await insertCalendar(planId, { type: 'school_holiday', region: 'DE-BY', label: 'Summer' });
|
||||
|
||||
// test-sql-allow: the raw SELECT is the legacy oracle this parity test holds the repository to.
|
||||
const legacy = testDb.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(id);
|
||||
expect(await repo.findById(id)).toEqual(legacy);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createUser } from '../../../helpers/factories';
|
||||
import { countRows, findRow, findRows, insertRow } from '../../../helpers/factories/rows';
|
||||
import { WebauthnChallenges } from '../../../../src/db/entities/WebauthnChallenges.entity';
|
||||
import type { WebauthnChallengesRepository } from '../../../../src/db/repositories/WebauthnChallenges.repository';
|
||||
|
||||
@@ -17,12 +18,12 @@ beforeAll(async () => {
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function seedChallenge(challenge: string, userId: number | null, type: string, expiresAt: number): void {
|
||||
testDb.prepare('INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) VALUES (?, ?, ?, ?)').run(challenge, userId, type, expiresAt);
|
||||
async function seedChallenge(challenge: string, userId: number | null, type: string, expiresAt: number): Promise<void> {
|
||||
await insertRow(t, WebauthnChallenges, { challenge, user: userId, type, expires_at: expiresAt });
|
||||
}
|
||||
|
||||
function challengeCount(): number {
|
||||
return (testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_challenges').get() as { n: number }).n;
|
||||
function challengeCount(): Promise<number> {
|
||||
return countRows(t, WebauthnChallenges);
|
||||
}
|
||||
|
||||
describe('WebauthnChallengesRepository', () => {
|
||||
@@ -30,20 +31,20 @@ describe('WebauthnChallengesRepository', () => {
|
||||
it('WEBAUTHN-CHAL-REPO-001: deletes only rows past their expiry, live rows survive', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const now = Date.now();
|
||||
seedChallenge('expired-1', user.id, 'registration', now - 1000);
|
||||
seedChallenge('expired-2', null, 'authentication', now - 1);
|
||||
seedChallenge('live', user.id, 'registration', now + 60_000);
|
||||
await seedChallenge('expired-1', user.id, 'registration', now - 1000);
|
||||
await seedChallenge('expired-2', null, 'authentication', now - 1);
|
||||
await seedChallenge('live', user.id, 'registration', now + 60_000);
|
||||
|
||||
await challenges.purgeExpired(now);
|
||||
|
||||
expect(challengeCount()).toBe(1);
|
||||
const remaining = testDb.prepare('SELECT challenge FROM webauthn_challenges').all() as Array<{ challenge: string }>;
|
||||
expect(remaining).toEqual([{ challenge: 'live' }]);
|
||||
expect(await challengeCount()).toBe(1);
|
||||
const remaining = await findRows(t, WebauthnChallenges);
|
||||
expect(remaining.map((r) => ({ challenge: r.challenge }))).toEqual([{ challenge: 'live' }]);
|
||||
});
|
||||
|
||||
it('WEBAUTHN-CHAL-REPO-002: an empty table is a no-op', async () => {
|
||||
await challenges.purgeExpired(Date.now());
|
||||
expect(challengeCount()).toBe(0);
|
||||
expect(await challengeCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,16 +54,16 @@ describe('WebauthnChallengesRepository', () => {
|
||||
const expiresAt = Date.now() + 300_000;
|
||||
await challenges.insertChallenge({ challenge: 'reg-chal', user_id: user.id, type: 'registration', expires_at: expiresAt });
|
||||
|
||||
const row = testDb.prepare('SELECT challenge, user_id, type, expires_at FROM webauthn_challenges WHERE challenge = ?').get('reg-chal');
|
||||
expect(row).toEqual({ challenge: 'reg-chal', user_id: user.id, type: 'registration', expires_at: expiresAt });
|
||||
const row = await findRow(t, WebauthnChallenges, { challenge: 'reg-chal' });
|
||||
expect(row).toMatchObject({ challenge: 'reg-chal', user_id: user.id, type: 'registration', expires_at: expiresAt });
|
||||
});
|
||||
|
||||
it('WEBAUTHN-CHAL-REPO-004: writes a NULL user_id (anonymous authentication challenge)', async () => {
|
||||
const expiresAt = Date.now() + 300_000;
|
||||
await challenges.insertChallenge({ challenge: 'auth-chal', user_id: null, type: 'authentication', expires_at: expiresAt });
|
||||
|
||||
const row = testDb.prepare('SELECT user_id, type FROM webauthn_challenges WHERE challenge = ?').get('auth-chal');
|
||||
expect(row).toEqual({ user_id: null, type: 'authentication' });
|
||||
const row = await findRow(t, WebauthnChallenges, { challenge: 'auth-chal' });
|
||||
expect(row).toMatchObject({ user_id: null, type: 'authentication' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,17 +71,17 @@ describe('WebauthnChallengesRepository', () => {
|
||||
it('WEBAUTHN-CHAL-REPO-005: claims a live, matching row and returns its user_id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const now = Date.now();
|
||||
seedChallenge('claim-me', user.id, 'registration', now + 60_000);
|
||||
await seedChallenge('claim-me', user.id, 'registration', now + 60_000);
|
||||
|
||||
const result = await challenges.claimChallenge('claim-me', 'registration', now);
|
||||
|
||||
expect(result).toEqual({ user_id: user.id });
|
||||
expect(challengeCount()).toBe(0); // spent
|
||||
expect(await challengeCount()).toBe(0); // spent
|
||||
});
|
||||
|
||||
it('WEBAUTHN-CHAL-REPO-006: claims an anonymous (null user_id) row', async () => {
|
||||
const now = Date.now();
|
||||
seedChallenge('anon-claim', null, 'authentication', now + 60_000);
|
||||
await seedChallenge('anon-claim', null, 'authentication', now + 60_000);
|
||||
|
||||
expect(await challenges.claimChallenge('anon-claim', 'authentication', now)).toEqual({ user_id: null });
|
||||
});
|
||||
@@ -91,26 +92,26 @@ describe('WebauthnChallengesRepository', () => {
|
||||
|
||||
it('WEBAUTHN-CHAL-REPO-008: an expired row is null and stays in the table (purgeExpired\'s job, not claimChallenge\'s)', async () => {
|
||||
const now = Date.now();
|
||||
seedChallenge('stale', null, 'registration', now - 1);
|
||||
await seedChallenge('stale', null, 'registration', now - 1);
|
||||
|
||||
expect(await challenges.claimChallenge('stale', 'registration', now)).toBeNull();
|
||||
expect(challengeCount()).toBe(1); // still there — claimChallenge only deletes a MATCHING row
|
||||
expect(await challengeCount()).toBe(1); // still there — claimChallenge only deletes a MATCHING row
|
||||
});
|
||||
|
||||
it('WEBAUTHN-CHAL-REPO-009: a type mismatch is null, the row survives for its real type', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const now = Date.now();
|
||||
seedChallenge('wrong-type', user.id, 'registration', now + 60_000);
|
||||
await seedChallenge('wrong-type', user.id, 'registration', now + 60_000);
|
||||
|
||||
expect(await challenges.claimChallenge('wrong-type', 'authentication', now)).toBeNull();
|
||||
expect(challengeCount()).toBe(1);
|
||||
expect(await challengeCount()).toBe(1);
|
||||
expect(await challenges.claimChallenge('wrong-type', 'registration', now)).toEqual({ user_id: user.id });
|
||||
});
|
||||
|
||||
it('WEBAUTHN-CHAL-REPO-010: single-use — a second claim of the same challenge is null', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const now = Date.now();
|
||||
seedChallenge('once-only', user.id, 'registration', now + 60_000);
|
||||
await seedChallenge('once-only', user.id, 'registration', now + 60_000);
|
||||
|
||||
expect(await challenges.claimChallenge('once-only', 'registration', now)).toEqual({ user_id: user.id });
|
||||
expect(await challenges.claimChallenge('once-only', 'registration', now)).toBeNull();
|
||||
@@ -119,7 +120,7 @@ describe('WebauthnChallengesRepository', () => {
|
||||
it('WEBAUTHN-CHAL-REPO-011: two concurrent claims of the same challenge — exactly one winner', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const now = Date.now();
|
||||
seedChallenge('race-me', user.id, 'registration', now + 60_000);
|
||||
await seedChallenge('race-me', user.id, 'registration', now + 60_000);
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
challenges.claimChallenge('race-me', 'registration', now),
|
||||
@@ -129,7 +130,7 @@ describe('WebauthnChallengesRepository', () => {
|
||||
const winners = [a, b].filter((r) => r !== null);
|
||||
expect(winners).toHaveLength(1);
|
||||
expect(winners[0]).toEqual({ user_id: user.id });
|
||||
expect(challengeCount()).toBe(0);
|
||||
expect(await challengeCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,10 +42,15 @@ const { wiki } = vi.hoisted(() => {
|
||||
vi.mock('../../../src/nest/help/wiki', () => wiki);
|
||||
|
||||
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
import { findRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { setAppSetting } from '../../helpers/factories/settings';
|
||||
import { Addons } from '../../../src/db/entities/Addons.entity';
|
||||
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
|
||||
|
||||
const SECTIONS = [
|
||||
{ title: 'Getting Started', pages: [{ title: 'Quick Start', slug: 'Quick-Start' }] },
|
||||
@@ -128,7 +133,14 @@ beforeEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
let orm: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(testDb);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -340,10 +352,7 @@ describe('Tool: search_help', () => {
|
||||
describe('Tool: list_addons', () => {
|
||||
it('lists the enabled addons with the collab sub-features and bag tracking', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const row = testDb.prepare('SELECT name, type FROM addons WHERE id = ?').get('budget') as {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
const row = await findRow(orm, Addons, { id: 'budget' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
|
||||
expect(result.isError).toBeFalsy();
|
||||
@@ -352,8 +361,8 @@ describe('Tool: list_addons', () => {
|
||||
// on a photo provider) has to be absent rather than merely unasserted.
|
||||
expect(data.addons).toContainEqual({
|
||||
id: 'budget',
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
name: row?.name,
|
||||
type: row?.type,
|
||||
enabled: true,
|
||||
});
|
||||
expect(data.collabFeatures).toEqual({ chat: true, notes: true, links: true, polls: true, whatsnext: true });
|
||||
@@ -367,16 +376,14 @@ describe('Tool: list_addons', () => {
|
||||
setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true);
|
||||
// PhotoProviderSeeder seeds immich disabled by default (enabled: 0) — flip
|
||||
// it on the way the admin panel would, same idiom as setAddonEnabled above.
|
||||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'immich'").run();
|
||||
const row = testDb.prepare('SELECT name FROM photo_providers WHERE id = ?').get('immich') as {
|
||||
name: string;
|
||||
};
|
||||
await updateRows(orm, PhotoProviders, { id: 'immich' }, { enabled: 1 });
|
||||
const row = await findRow(orm, PhotoProviders, { id: 'immich' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
|
||||
const data = parseToolResult(result) as AddonsPayload;
|
||||
expect(data.addons).toContainEqual({
|
||||
id: 'immich',
|
||||
name: row.name,
|
||||
name: row?.name,
|
||||
type: 'photo_provider',
|
||||
enabled: true,
|
||||
});
|
||||
@@ -418,9 +425,7 @@ describe('Tool: list_addons', () => {
|
||||
});
|
||||
|
||||
it('reports bag tracking once it is switched on', async () => {
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')")
|
||||
.run();
|
||||
await setAppSetting(orm, 'bag_tracking_enabled', 'true');
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
|
||||
|
||||
@@ -38,6 +38,11 @@ vi.mock('../../../src/nest/atlas/atlas-geo', async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { createRow, findRow } from '../../helpers/factories/rows';
|
||||
import { addJourneyContributor } from '../../helpers/factories/journeys';
|
||||
import { Journeys } from '../../../src/db/entities/Journeys.entity';
|
||||
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { setAddonEnabled } from '../../helpers/test-db';
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
@@ -52,7 +57,14 @@ beforeEach(() => {
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
let orm: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(testDb);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -164,12 +176,12 @@ describe('Tool: update_journey_preferences', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A journey owned by someone else — every access check must refuse it. */
|
||||
function foreignJourney() {
|
||||
async function foreignJourney() {
|
||||
const { user: other } = createUser(testDb);
|
||||
const j = testDb.prepare(
|
||||
'INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(other.id, 'Not yours', 'draft', Date.now(), Date.now());
|
||||
return { otherId: other.id, journeyId: Number(j.lastInsertRowid) };
|
||||
const j = await createRow(orm, Journeys, {
|
||||
user: other.id, title: 'Not yours', status: 'draft', created_at: Date.now(), updated_at: Date.now(),
|
||||
});
|
||||
return { otherId: other.id, journeyId: j.id };
|
||||
}
|
||||
|
||||
async function seedJourney(h: McpHarness, title = 'J') {
|
||||
@@ -202,7 +214,7 @@ describe('journey read tools', () => {
|
||||
|
||||
it('get_journey refuses a journey the caller cannot see', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { journeyId } = foreignJourney();
|
||||
const { journeyId } = await foreignJourney();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_journey', arguments: { journeyId } });
|
||||
expect(result.isError).toBe(true);
|
||||
@@ -225,7 +237,7 @@ describe('journey read tools', () => {
|
||||
|
||||
it('list_journey_entries refuses a foreign journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { journeyId } = foreignJourney();
|
||||
const { journeyId } = await foreignJourney();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_journey_entries', arguments: { journeyId } });
|
||||
expect(result.isError).toBe(true);
|
||||
@@ -245,7 +257,7 @@ describe('journey read tools', () => {
|
||||
|
||||
it('list_journey_contributors refuses a foreign journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { journeyId } = foreignJourney();
|
||||
const { journeyId } = await foreignJourney();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_journey_contributors', arguments: { journeyId } });
|
||||
expect(result.isError).toBe(true);
|
||||
@@ -266,7 +278,7 @@ describe('journey read tools', () => {
|
||||
describe('journey write tools', () => {
|
||||
it('update_journey applies the change and refuses a foreign journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { journeyId } = foreignJourney();
|
||||
const { journeyId } = await foreignJourney();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = await seedJourney(h);
|
||||
const updated = parseToolResult(await h.client.callTool({
|
||||
@@ -280,7 +292,7 @@ describe('journey write tools', () => {
|
||||
|
||||
it('delete_journey removes it and refuses a foreign journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { journeyId } = foreignJourney();
|
||||
const { journeyId } = await foreignJourney();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = await seedJourney(h);
|
||||
const gone = parseToolResult(await h.client.callTool({
|
||||
@@ -311,7 +323,7 @@ describe('journey write tools', () => {
|
||||
it('add_journey_trip refuses a foreign journey; remove_journey_trip is idempotent but refuses one', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const { journeyId } = foreignJourney();
|
||||
const { journeyId } = await foreignJourney();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = await seedJourney(h);
|
||||
expect((await h.client.callTool({
|
||||
@@ -330,7 +342,7 @@ describe('journey write tools', () => {
|
||||
|
||||
it('create_journey_entry refuses a foreign journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { journeyId } = foreignJourney();
|
||||
const { journeyId } = await foreignJourney();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_journey_entry', arguments: { journeyId, entry_date: '2026-07-01' },
|
||||
@@ -401,7 +413,7 @@ describe('journey write tools', () => {
|
||||
it('contributor tools add, re-role and remove; each refuses a foreign journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: guest } = createUser(testDb);
|
||||
const { journeyId } = foreignJourney();
|
||||
const { journeyId } = await foreignJourney();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = await seedJourney(h);
|
||||
expect((parseToolResult(await h.client.callTool({
|
||||
@@ -427,7 +439,7 @@ describe('journey write tools', () => {
|
||||
|
||||
it('update_journey_preferences refuses a foreign journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { journeyId } = foreignJourney();
|
||||
const { journeyId } = await foreignJourney();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_journey_preferences', arguments: { journeyId, hide_skeletons: true },
|
||||
@@ -446,8 +458,10 @@ describe('journey write tools', () => {
|
||||
// could not be corrected. Asserted against the row, not the tool's echo.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function entryRow(id: number) {
|
||||
return testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(id) as any;
|
||||
async function entryRow(id: number) {
|
||||
const row = await findRow(orm, JourneyEntries, { id });
|
||||
if (!row) throw new Error(`no journey entry ${id}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function seedEntry(h: McpHarness, journeyId: number, args: Record<string, unknown> = {}) {
|
||||
@@ -474,13 +488,13 @@ describe('journey entry fields', () => {
|
||||
type: 'checkin',
|
||||
});
|
||||
|
||||
const row = entryRow(entry.id);
|
||||
const row = await entryRow(entry.id);
|
||||
expect(row.location_name).toBe('Hallgrimskirkja');
|
||||
expect(row.location_lat).toBeCloseTo(64.1418, 4);
|
||||
expect(row.location_lng).toBeCloseTo(-21.9266, 4);
|
||||
expect(row.weather).toBe('overcast, 9C');
|
||||
expect(JSON.parse(row.tags)).toEqual(['church', 'view']);
|
||||
expect(JSON.parse(row.pros_cons)).toEqual({ pros: ['the tower'], cons: ['the queue'] });
|
||||
expect(JSON.parse(String(row.tags))).toEqual(['church', 'view']);
|
||||
expect(JSON.parse(String(row.pros_cons))).toEqual({ pros: ['the tower'], cons: ['the queue'] });
|
||||
expect(row.visibility).toBe('public');
|
||||
expect(row.type).toBe('checkin');
|
||||
});
|
||||
@@ -491,7 +505,7 @@ describe('journey entry fields', () => {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = await seedJourney(h);
|
||||
const entry = await seedEntry(h, journey.id, { pros_cons: { pros: ['cheap'] } });
|
||||
expect(JSON.parse(entryRow(entry.id).pros_cons)).toEqual({ pros: ['cheap'], cons: [] });
|
||||
expect(JSON.parse(String((await entryRow(entry.id)).pros_cons))).toEqual({ pros: ['cheap'], cons: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -499,7 +513,7 @@ describe('journey entry fields', () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = await seedJourney(h);
|
||||
const row = entryRow((await seedEntry(h, journey.id)).id);
|
||||
const row = await entryRow((await seedEntry(h, journey.id)).id);
|
||||
expect(row.visibility).toBe('private');
|
||||
expect(row.type).toBe('entry');
|
||||
});
|
||||
@@ -536,7 +550,7 @@ describe('journey entry fields', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const row = entryRow(entry.id);
|
||||
const row = await entryRow(entry.id);
|
||||
expect(row.location_name).toBe('Hallgrimskirkja');
|
||||
expect(row.location_lat).toBeCloseTo(64.1418, 4);
|
||||
expect(row.location_lng).toBeCloseTo(-21.9266, 4);
|
||||
@@ -561,10 +575,10 @@ describe('journey entry fields', () => {
|
||||
},
|
||||
})) as any;
|
||||
|
||||
const row = entryRow(entry.id);
|
||||
const row = await entryRow(entry.id);
|
||||
expect(row.weather).toBe('rain all day');
|
||||
expect(JSON.parse(row.tags)).toEqual(['museum']);
|
||||
expect(JSON.parse(row.pros_cons)).toEqual({ pros: ['free on Sundays'], cons: ['packed'] });
|
||||
expect(JSON.parse(String(row.tags))).toEqual(['museum']);
|
||||
expect(JSON.parse(String(row.pros_cons))).toEqual({ pros: ['free on Sundays'], cons: ['packed'] });
|
||||
expect(row.visibility).toBe('shared');
|
||||
expect(row.sort_order).toBe(3);
|
||||
// The enrichment parses both back out, so the caller sees the objects.
|
||||
@@ -578,10 +592,10 @@ describe('journey entry fields', () => {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = await seedJourney(h);
|
||||
const entry = await seedEntry(h, journey.id, { type: 'skeleton', title: 'Blue Lagoon' });
|
||||
expect(entryRow(entry.id).type).toBe('skeleton');
|
||||
expect((await entryRow(entry.id)).type).toBe('skeleton');
|
||||
|
||||
await h.client.callTool({ name: 'update_journey_entry', arguments: { entryId: entry.id, type: 'entry' } });
|
||||
expect(entryRow(entry.id).type).toBe('entry');
|
||||
expect((await entryRow(entry.id)).type).toBe('entry');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -613,7 +627,7 @@ describe('journey entry fields', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const row = entryRow(entry.id);
|
||||
const row = await entryRow(entry.id);
|
||||
expect(row.entry_time).toBeNull();
|
||||
expect(row.location_lat).toBeNull();
|
||||
expect(row.location_lng).toBeNull();
|
||||
@@ -644,7 +658,7 @@ describe('journey entry fields', () => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'update_journey_entry', arguments: { entryId: airport.id, stats_excluded: true },
|
||||
})) as any;
|
||||
expect(entryRow(airport.id).stats_excluded).toBe(1);
|
||||
expect((await entryRow(airport.id)).stats_excluded).toBe(1);
|
||||
// The echo carries the boolean, as the REST answer does.
|
||||
expect(data.entry.stats_excluded).toBe(true);
|
||||
|
||||
@@ -661,7 +675,7 @@ describe('journey entry fields', () => {
|
||||
const on = parseToolResult(await h.client.callTool({
|
||||
name: 'get_journey_stats', arguments: { journeyId: journey.id },
|
||||
})) as any;
|
||||
expect(entryRow(airport.id).stats_excluded).toBe(0);
|
||||
expect((await entryRow(airport.id)).stats_excluded).toBe(0);
|
||||
expect(on.stats.steps).toBe(2);
|
||||
expect(on.stats.excluded).toEqual([]);
|
||||
});
|
||||
@@ -675,7 +689,7 @@ describe('journey entry fields', () => {
|
||||
expect((await h.client.callTool({
|
||||
name: 'update_journey_entry', arguments: { entryId: entry.id, stats_excluded: 'yes' },
|
||||
})).isError).toBe(true);
|
||||
expect(entryRow(entry.id).stats_excluded).toBe(0);
|
||||
expect((await entryRow(entry.id)).stats_excluded).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -690,7 +704,7 @@ describe('journey entry fields', () => {
|
||||
})).isError).toBe(true);
|
||||
}
|
||||
// And none of the refusals touched the row.
|
||||
const row = entryRow(entry.id);
|
||||
const row = await entryRow(entry.id);
|
||||
expect(row.visibility).toBe('private');
|
||||
expect(row.location_lng).toBeNull();
|
||||
});
|
||||
@@ -752,7 +766,7 @@ describe('Tool: get_journey_stats', () => {
|
||||
|
||||
it('refuses a journey the caller cannot see', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { journeyId } = foreignJourney();
|
||||
const { journeyId } = await foreignJourney();
|
||||
await withHarness(user.id, async (h) => {
|
||||
expect((await h.client.callTool({
|
||||
name: 'get_journey_stats', arguments: { journeyId },
|
||||
@@ -817,9 +831,7 @@ describe('journey share-link tools', () => {
|
||||
journeyId = journey.id;
|
||||
await h.client.callTool({ name: 'create_journey_share_link', arguments: { journeyId } });
|
||||
});
|
||||
testDb.prepare(
|
||||
'INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)',
|
||||
).run(journeyId, guest.id, 'viewer', Date.now());
|
||||
await addJourneyContributor(orm, journeyId, guest.id, 'viewer');
|
||||
await withHarness(guest.id, async (h) => {
|
||||
// The token is the whole credential: a contributor must not be able to
|
||||
// read out a link that keeps working after they are removed.
|
||||
@@ -831,7 +843,7 @@ describe('journey share-link tools', () => {
|
||||
|
||||
it('every share tool refuses a foreign journey', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { journeyId } = foreignJourney();
|
||||
const { journeyId } = await foreignJourney();
|
||||
await withHarness(user.id, async (h) => {
|
||||
for (const name of ['get_journey_share_link', 'create_journey_share_link', 'delete_journey_share_link']) {
|
||||
expect((await h.client.callTool({ name, arguments: { journeyId } })).isError).toBe(true);
|
||||
|
||||
@@ -26,8 +26,13 @@ vi.mock('../../../src/config', () => ({
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { countRows, findRow, insertRow } from '../../helpers/factories/rows';
|
||||
import { setUserSetting } from '../../helpers/factories/settings';
|
||||
import { Settings } from '../../../src/db/entities/Settings.entity';
|
||||
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
||||
import { encrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto';
|
||||
import { MASKED_SETTING_VALUE } from '@trek/shared';
|
||||
import { DISPLAY_PREFERENCE_KEYS } from '../../../src/nest/settings/settings.mcp';
|
||||
@@ -39,7 +44,14 @@ beforeEach(() => {
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
let orm: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(testDb);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -47,22 +59,17 @@ afterAll(() => {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setSetting(userId: number, key: string, value: string): void {
|
||||
testDb.prepare(
|
||||
'INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
|
||||
).run(userId, key, value);
|
||||
async function setSetting(userId: number, key: string, value: string): Promise<void> {
|
||||
await setUserSetting(orm, userId, key, value);
|
||||
}
|
||||
|
||||
function readSetting(userId: number, key: string): string | undefined {
|
||||
const row = testDb.prepare('SELECT value FROM settings WHERE user_id = ? AND key = ?').get(userId, key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
async function readSetting(userId: number, key: string): Promise<string | null | undefined> {
|
||||
const row = await findRow(orm, Settings, { user: userId, key });
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
function countSettings(userId: number): number {
|
||||
return (testDb.prepare('SELECT COUNT(*) as c FROM settings WHERE user_id = ?').get(userId) as { c: number }).c;
|
||||
function countSettings(userId: number): Promise<number> {
|
||||
return countRows(orm, Settings, { user: userId });
|
||||
}
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
@@ -100,13 +107,13 @@ describe('Tool: get_display_settings', () => {
|
||||
|
||||
it('returns the display preferences the user has stored', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSetting(user.id, 'temperature_unit', '"fahrenheit"');
|
||||
setSetting(user.id, 'distance_unit', '"imperial"');
|
||||
setSetting(user.id, 'time_format', '"12h"');
|
||||
setSetting(user.id, 'language', '"de"');
|
||||
setSetting(user.id, 'default_currency', '"USD"');
|
||||
setSetting(user.id, 'start_page', '"active_trip"');
|
||||
setSetting(user.id, 'blur_booking_codes', 'true');
|
||||
await setSetting(user.id, 'temperature_unit', '"fahrenheit"');
|
||||
await setSetting(user.id, 'distance_unit', '"imperial"');
|
||||
await setSetting(user.id, 'time_format', '"12h"');
|
||||
await setSetting(user.id, 'language', '"de"');
|
||||
await setSetting(user.id, 'default_currency', '"USD"');
|
||||
await setSetting(user.id, 'start_page', '"active_trip"');
|
||||
await setSetting(user.id, 'blur_booking_codes', 'true');
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} });
|
||||
@@ -125,8 +132,7 @@ describe('Tool: get_display_settings', () => {
|
||||
|
||||
it('falls back to the admin-set instance default for a key the user has not set', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('default_user_setting_temperature_unit', '"fahrenheit"');
|
||||
await insertRow(orm, AppSettings, { key: 'default_user_setting_temperature_unit', value: '"fahrenheit"' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} });
|
||||
@@ -141,12 +147,12 @@ describe('Tool: get_display_settings', () => {
|
||||
// getUserSettings hands them back in cleartext. That is the leak this
|
||||
// surface must not have: store them the way the real write path does and
|
||||
// assert they are nowhere in the result.
|
||||
setSetting(user.id, 'mapbox_access_token', String(encrypt_api_key('pk.super-secret-token')));
|
||||
setSetting(user.id, 'carto_api_key', String(encrypt_api_key('carto-secret')));
|
||||
setSetting(user.id, 'llm_api_key', String(encrypt_api_key('sk-secret')));
|
||||
setSetting(user.id, 'ntfy_token', String(encrypt_api_key('ntfy-secret')));
|
||||
setSetting(user.id, 'webhook_url', String(encrypt_api_key('https://hook.example/secret')));
|
||||
setSetting(user.id, 'temperature_unit', '"celsius"');
|
||||
await setSetting(user.id, 'mapbox_access_token', String(encrypt_api_key('pk.super-secret-token')));
|
||||
await setSetting(user.id, 'carto_api_key', String(encrypt_api_key('carto-secret')));
|
||||
await setSetting(user.id, 'llm_api_key', String(encrypt_api_key('sk-secret')));
|
||||
await setSetting(user.id, 'ntfy_token', String(encrypt_api_key('ntfy-secret')));
|
||||
await setSetting(user.id, 'webhook_url', String(encrypt_api_key('https://hook.example/secret')));
|
||||
await setSetting(user.id, 'temperature_unit', '"celsius"');
|
||||
|
||||
await withScopedHarness(user.id, ['settings:read'], async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} });
|
||||
@@ -166,9 +172,9 @@ describe('Tool: get_display_settings', () => {
|
||||
|
||||
it('leaves out settings that are neither display preferences nor credentials', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSetting(user.id, 'map_tile_url', '"https://tiles.example/{z}/{x}/{y}.png"');
|
||||
setSetting(user.id, 'dashboard_fx_from', '"EUR"');
|
||||
setSetting(user.id, 'time_format', '"24h"');
|
||||
await setSetting(user.id, 'map_tile_url', '"https://tiles.example/{z}/{x}/{y}.png"');
|
||||
await setSetting(user.id, 'dashboard_fx_from', '"EUR"');
|
||||
await setSetting(user.id, 'time_format', '"24h"');
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} });
|
||||
@@ -180,7 +186,7 @@ describe('Tool: get_display_settings', () => {
|
||||
it('does not leak another user\'s preferences', async () => {
|
||||
const { user: mine } = createUser(testDb);
|
||||
const { user: theirs } = createUser(testDb);
|
||||
setSetting(theirs.id, 'temperature_unit', '"fahrenheit"');
|
||||
await setSetting(theirs.id, 'temperature_unit', '"fahrenheit"');
|
||||
|
||||
await withHarness(mine.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} });
|
||||
@@ -203,17 +209,17 @@ describe('Tool: update_display_settings', () => {
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.updated).toBe(2);
|
||||
expect(data.settings).toEqual({ temperature_unit: 'fahrenheit', distance_unit: 'imperial' });
|
||||
expect(readSetting(user.id, 'temperature_unit')).toBe('fahrenheit');
|
||||
expect(readSetting(user.id, 'distance_unit')).toBe('imperial');
|
||||
expect(await readSetting(user.id, 'temperature_unit')).toBe('fahrenheit');
|
||||
expect(await readSetting(user.id, 'distance_unit')).toBe('imperial');
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves untouched keys alone', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSetting(user.id, 'time_format', '"12h"');
|
||||
await setSetting(user.id, 'time_format', '"12h"');
|
||||
await withHarness(user.id, async (h) => {
|
||||
await update(h, { temperature_unit: 'celsius' });
|
||||
expect(readSetting(user.id, 'time_format')).toBe('"12h"');
|
||||
expect(await readSetting(user.id, 'time_format')).toBe('"12h"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,7 +251,7 @@ describe('Tool: update_display_settings', () => {
|
||||
expect(set.settings.place_language).toBe('en');
|
||||
const refused = await update(h, { place_language: 'klingon' });
|
||||
expect(refused.isError).toBe(true);
|
||||
expect(readSetting(user.id, 'place_language')).toBe('en');
|
||||
expect(await readSetting(user.id, 'place_language')).toBe('en');
|
||||
const cleared = parseToolResult(await update(h, { place_language: '' })) as any;
|
||||
expect(cleared.settings.place_language).toBe('');
|
||||
});
|
||||
@@ -258,7 +264,7 @@ describe('Tool: update_display_settings', () => {
|
||||
expect(ok.settings.week_start).toBe('sunday');
|
||||
const refused = await update(h, { week_start: 'friday' });
|
||||
expect(refused.isError).toBe(true);
|
||||
expect(readSetting(user.id, 'week_start')).toBe('sunday');
|
||||
expect(await readSetting(user.id, 'week_start')).toBe('sunday');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,25 +296,24 @@ describe('Tool: update_display_settings', () => {
|
||||
const result = await update(h, { dark_mode: false });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.settings.dark_mode).toBe(false);
|
||||
expect(readSetting(user.id, 'dark_mode')).toBe('false');
|
||||
expect(await readSetting(user.id, 'dark_mode')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts an empty default_currency, which falls back to each trip\'s own', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSetting(user.id, 'default_currency', '"USD"');
|
||||
await setSetting(user.id, 'default_currency', '"USD"');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await update(h, { default_currency: '' });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(readSetting(user.id, 'default_currency')).toBe('');
|
||||
expect(await readSetting(user.id, 'default_currency')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('reads back the admin default rather than echoing the input', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('default_user_setting_distance_unit', '"imperial"');
|
||||
await insertRow(orm, AppSettings, { key: 'default_user_setting_distance_unit', value: '"imperial"' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await update(h, { temperature_unit: 'celsius' });
|
||||
const data = parseToolResult(result) as any;
|
||||
@@ -328,7 +333,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
const result = await update(h, { mapbox_access_token: 'pk.attacker-token' });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(errorText(result)).toContain('mapbox_access_token');
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,7 +342,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
await withScopedHarness(user.id, ['settings:write'], async (h) => {
|
||||
const result = await update(h, { llm_api_key: 'sk-attacker' });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,7 +353,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
const result = await update(h, settings);
|
||||
expect(result.isError).toBe(true);
|
||||
}
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -358,8 +363,8 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
const result = await update(h, { made_up_preference: 'whatever' });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(errorText(result)).toContain('made_up_preference');
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(readSetting(user.id, 'made_up_preference')).toBeUndefined();
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
expect(await readSetting(user.id, 'made_up_preference')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -368,7 +373,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await update(h, { toString: 'gotcha' });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -377,7 +382,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await update(h, { temperature_unit: 'fahrenheit', llm_api_key: 'sk-attacker' });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -386,7 +391,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await update(h, { temperature_unit: 'fahrenheit', time_format: '36h' });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -396,7 +401,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
const result = await update(h, { temperature_unit: 'kelvin' });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(errorText(result)).toContain('temperature_unit');
|
||||
expect(readSetting(user.id, 'temperature_unit')).toBeUndefined();
|
||||
expect(await readSetting(user.id, 'temperature_unit')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -407,7 +412,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
expect(bad.isError).toBe(true);
|
||||
const good = await update(h, { language: 'fr' });
|
||||
expect(good.isError).toBeFalsy();
|
||||
expect(readSetting(user.id, 'language')).toBe('fr');
|
||||
expect(await readSetting(user.id, 'language')).toBe('fr');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -418,7 +423,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
const result = await update(h, { default_currency: value });
|
||||
expect(result.isError).toBe(true);
|
||||
}
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -427,7 +432,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await update(h, { blur_booking_codes: 'yes' });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -436,7 +441,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
await withHarness(user.id, async (h) => {
|
||||
expect((await update(h, { start_trip_tab: '' })).isError).toBe(true);
|
||||
expect((await update(h, { start_trip_tab: 'x'.repeat(65) })).isError).toBe(true);
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -446,7 +451,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
const result = await update(h, { start_trip_tab: MASKED_SETTING_VALUE });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(errorText(result)).toContain('start_trip_tab');
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -456,7 +461,7 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
const result = await update(h, {});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(errorText(result)).toContain('temperature_unit');
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -466,14 +471,14 @@ describe('Tool: update_display_settings, refusals', () => {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await update(h, { temperature_unit: 'fahrenheit' });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(countSettings(user.id)).toBe(0);
|
||||
expect(await countSettings(user.id)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the demo user read', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@trek.app' });
|
||||
setSetting(user.id, 'temperature_unit', '"celsius"');
|
||||
await setSetting(user.id, 'temperature_unit', '"celsius"');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
|
||||
import { findRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { Trips } from '../../../src/db/entities/Trips.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
|
||||
@@ -86,16 +87,16 @@ afterAll(async () => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function seedTrip(token?: string) {
|
||||
async function seedTrip(token?: string) {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
if (token) testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, trip.id);
|
||||
if (token) await updateRows(t, Trips, { id: trip.id }, { feed_token: token });
|
||||
return { user, trip };
|
||||
}
|
||||
|
||||
function seedUserWithToken(token: string, overrides: Partial<{ username: string }> = {}) {
|
||||
async function seedUserWithToken(token: string, overrides: Partial<{ username: string }> = {}) {
|
||||
const { user } = createUser(testDb, overrides);
|
||||
testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { feed_token: token });
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -103,13 +104,13 @@ function seedUserWithToken(token: string, overrides: Partial<{ username: string
|
||||
|
||||
describe('trip feed token lifecycle', () => {
|
||||
it('FEED-SVC-001: reports no URL while the trip has no token', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const { user, trip } = await seedTrip();
|
||||
|
||||
expect(await svc.getTripToken(trip.id, user.id, BASE)).toEqual({ feed_url: null });
|
||||
});
|
||||
|
||||
it('FEED-SVC-002: reports the absolute feed URL once a token exists', async () => {
|
||||
const { user, trip } = seedTrip('tok-trip');
|
||||
const { user, trip } = await seedTrip('tok-trip');
|
||||
|
||||
expect(await svc.getTripToken(trip.id, user.id, BASE)).toEqual({
|
||||
feed_url: `${BASE}/api/feed/trip/tok-trip.ics`,
|
||||
@@ -119,7 +120,7 @@ describe('trip feed token lifecycle', () => {
|
||||
it('FEED-SVC-003: a trailing slash on the base is stripped, never doubled into //api', async () => {
|
||||
// APP_URL is user-supplied config; pasted with a trailing slash it would
|
||||
// otherwise produce https://host//api/feed/... which some clients reject.
|
||||
const { user, trip } = seedTrip('tok-trip');
|
||||
const { user, trip } = await seedTrip('tok-trip');
|
||||
|
||||
expect((await svc.getTripToken(trip.id, user.id, `${BASE}/`)).feed_url).toBe(
|
||||
`${BASE}/api/feed/trip/tok-trip.ics`,
|
||||
@@ -129,7 +130,7 @@ describe('trip feed token lifecycle', () => {
|
||||
it('FEED-SVC-004: a user without access gets null, not the token of a foreign trip', async () => {
|
||||
// The token is the credential for the public feed, so leaking it through the
|
||||
// authenticated GET would hand a stranger the whole trip.
|
||||
const { trip } = seedTrip('tok-trip');
|
||||
const { trip } = await seedTrip('tok-trip');
|
||||
const { user: outsider } = createUser(testDb);
|
||||
|
||||
expect(await svc.getTripToken(trip.id, outsider.id, BASE)).toEqual({ feed_url: null });
|
||||
@@ -139,7 +140,7 @@ describe('trip feed token lifecycle', () => {
|
||||
// caller may manage the credential at all is decided one layer up, by
|
||||
// TripAccessGuard + @RequirePermission('share_manage') on the controller.
|
||||
it('FEED-SVC-005: a trip shared with the user as a member resolves too', async () => {
|
||||
const { trip } = seedTrip('tok-trip');
|
||||
const { trip } = await seedTrip('tok-trip');
|
||||
const { user: member } = createUser(testDb);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
@@ -151,7 +152,7 @@ describe('trip feed token lifecycle', () => {
|
||||
it('FEED-SVC-006: generate mints a token once and stays idempotent', async () => {
|
||||
// Enabling twice must not invalidate a URL the user already handed to their
|
||||
// calendar client — that is what rotate is for.
|
||||
const { user, trip } = seedTrip();
|
||||
const { user, trip } = await seedTrip();
|
||||
|
||||
const first = await svc.generateTripToken(trip.id, user.id, BASE);
|
||||
const second = await svc.generateTripToken(trip.id, user.id, BASE);
|
||||
@@ -161,7 +162,7 @@ describe('trip feed token lifecycle', () => {
|
||||
});
|
||||
|
||||
it('FEED-SVC-007: rotate issues a fresh token and the previous URL stops resolving', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const { user, trip } = await seedTrip();
|
||||
const before = (await svc.generateTripToken(trip.id, user.id, BASE)).feed_url;
|
||||
const oldToken = before.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||||
|
||||
@@ -172,7 +173,7 @@ describe('trip feed token lifecycle', () => {
|
||||
});
|
||||
|
||||
it('FEED-SVC-008: disable clears the column so the public URL dies', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const { user, trip } = await seedTrip();
|
||||
const url = (await svc.generateTripToken(trip.id, user.id, BASE)).feed_url;
|
||||
const token = url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||||
|
||||
@@ -186,7 +187,7 @@ describe('trip feed token lifecycle', () => {
|
||||
// The route guard is what enforces share_manage; this is the second lock, so
|
||||
// a caller reaching the service another way cannot mint or clear a token on
|
||||
// a trip id it merely guessed.
|
||||
const { user, trip } = seedTrip();
|
||||
const { user, trip } = await seedTrip();
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const mine = (await svc.generateTripToken(trip.id, user.id, BASE)).feed_url;
|
||||
const myToken = mine.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||||
@@ -205,7 +206,7 @@ describe('trip feed token lifecycle', () => {
|
||||
// Named for the hole, per the task brief; see the task report for the
|
||||
// one-line fix proposal (compare the affected count and 404/refuse on 0).
|
||||
it('R4 HOLE — generateTripToken returns a feed_url for a token it never stored, for a trip the caller cannot reach', async () => {
|
||||
const { trip } = seedTrip(); // no token yet
|
||||
const { trip } = await seedTrip(); // no token yet
|
||||
const { user: stranger } = createUser(testDb);
|
||||
|
||||
const result = await svc.generateTripToken(trip.id, stranger.id, BASE);
|
||||
@@ -215,7 +216,7 @@ describe('trip feed token lifecycle', () => {
|
||||
// ...but the column was never written (setFeedTokenIfReachable affected 0
|
||||
// rows: the stranger fails REACHABLE), so the URL 404s for anyone who tries it.
|
||||
const mintedToken = result.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
|
||||
expect((testDb.prepare('SELECT feed_token FROM trips WHERE id = ?').get(trip.id) as { feed_token: string | null }).feed_token).toBeNull();
|
||||
expect((await findRow(t, Trips, { id: trip.id }))?.feed_token).toBeNull();
|
||||
expect(await svc.buildTripIcs(mintedToken)).toBeNull();
|
||||
// Proven directly at the repository too — the affected count IS the 0-row signal.
|
||||
expect(await tripsRepo.setFeedTokenIfReachable(trip.id, stranger.id, mintedToken)).toBe(0);
|
||||
@@ -230,7 +231,7 @@ describe('trip feed token lifecycle', () => {
|
||||
// one of the two minted URLs ever resolves. Pinning today's actual outcome
|
||||
// (same class as `roadtrip.service.test.ts`'s R7 vias pin), not a fix.
|
||||
it('R7: two concurrent generateTripToken calls both mint, but only the last write survives — the other caller\'s URL never resolves (unserialized FD1-then-FD2)', async () => {
|
||||
const { user, trip } = seedTrip(); // no token yet
|
||||
const { user, trip } = await seedTrip(); // no token yet
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
svc.generateTripToken(trip.id, user.id, BASE),
|
||||
@@ -242,7 +243,7 @@ describe('trip feed token lifecycle', () => {
|
||||
// FD1 saw no token for either caller, so two distinct tokens were minted.
|
||||
expect(token1).not.toBe(token2);
|
||||
|
||||
const stored = (testDb.prepare('SELECT feed_token FROM trips WHERE id = ?').get(trip.id) as { feed_token: string | null }).feed_token;
|
||||
const stored = (await findRow(t, Trips, { id: trip.id }))?.feed_token;
|
||||
expect([token1, token2]).toContain(stored);
|
||||
const loser = stored === token1 ? token2 : token1;
|
||||
// The loser's URL is well-formed, but resolves nothing.
|
||||
@@ -301,7 +302,7 @@ describe('user feed token lifecycle', () => {
|
||||
|
||||
describe('buildTripIcs', () => {
|
||||
it('FEED-SVC-013: an unknown token yields null without asking the calendar', async () => {
|
||||
seedTrip('tok-trip');
|
||||
await seedTrip('tok-trip');
|
||||
|
||||
expect(await svc.buildTripIcs('00000000-0000-0000-0000-000000000000')).toBeNull();
|
||||
expect(buildTripCalendar).not.toHaveBeenCalled();
|
||||
@@ -311,7 +312,7 @@ describe('buildTripIcs', () => {
|
||||
// The public feed is unauthenticated: a trip the calendar cannot render (a row
|
||||
// deleted mid-request, unparseable data) has to come back as a 404, not a 500
|
||||
// that a subscribing client retries hourly forever.
|
||||
seedTrip('tok-trip');
|
||||
await seedTrip('tok-trip');
|
||||
buildTripCalendar.mockImplementation(() => {
|
||||
throw new Error('calendar exploded');
|
||||
});
|
||||
@@ -324,7 +325,7 @@ describe('buildTripIcs', () => {
|
||||
// before the first component, and clients that scan only the preamble stop
|
||||
// re-fetching if they slip behind a VTIMEZONE. The document is concatenated from
|
||||
// the calendar's parts now, so the order is an assembly decision, not a given.
|
||||
const { trip } = seedTrip('tok-trip');
|
||||
const { trip } = await seedTrip('tok-trip');
|
||||
buildTripCalendar.mockImplementation(() =>
|
||||
calendarParts({
|
||||
calName: 'Golden Trip',
|
||||
@@ -351,7 +352,7 @@ describe('buildTripIcs', () => {
|
||||
|
||||
describe('buildUserIcs', () => {
|
||||
it('FEED-SVC-016: an unknown token yields null without asking the calendar', async () => {
|
||||
seedUserWithToken('tok-user');
|
||||
await seedUserWithToken('tok-user');
|
||||
|
||||
expect(await svc.buildUserIcs('00000000-0000-0000-0000-000000000000')).toBeNull();
|
||||
expect(buildTripCalendar).not.toHaveBeenCalled();
|
||||
@@ -360,7 +361,7 @@ describe('buildUserIcs', () => {
|
||||
it('FEED-SVC-017: a trip whose calendar throws is skipped, the rest are still emitted', async () => {
|
||||
// One unrenderable trip must not take the whole all-trips subscription down —
|
||||
// the user would silently lose every calendar entry because of a single bad row.
|
||||
const user = seedUserWithToken('tok-user');
|
||||
const user = await seedUserWithToken('tok-user');
|
||||
const good = createTrip(testDb, user.id, { start_date: '2026-01-01' });
|
||||
const broken = createTrip(testDb, user.id, { start_date: '2026-02-01' });
|
||||
const alsoGood = createTrip(testDb, user.id, { start_date: '2026-03-01' });
|
||||
@@ -382,7 +383,7 @@ describe('buildUserIcs', () => {
|
||||
// Two VTIMEZONE blocks with the same TZID make the document invalid and clients
|
||||
// drop the events referencing it; a block emitted after the VEVENT that uses it
|
||||
// does not resolve either (#1453). First definition wins.
|
||||
const user = seedUserWithToken('tok-user');
|
||||
const user = await seedUserWithToken('tok-user');
|
||||
const first = createTrip(testDb, user.id, { start_date: '2026-01-01' });
|
||||
createTrip(testDb, user.id, { start_date: '2026-02-01' });
|
||||
buildTripCalendar.mockImplementation((id: number) => ({
|
||||
@@ -408,7 +409,7 @@ describe('buildUserIcs', () => {
|
||||
// render as a truncated calendar title. The body still has to fold — RFC 5545
|
||||
// caps a content line at 75 octets.
|
||||
const username = 'Ferdinand-Bartholomew-'.repeat(5);
|
||||
const user = seedUserWithToken('tok-user', { username });
|
||||
const user = await seedUserWithToken('tok-user', { username });
|
||||
createTrip(testDb, user.id, { start_date: '2026-01-01' });
|
||||
const longSummary = 'A'.repeat(120);
|
||||
buildTripCalendar.mockImplementation(() => calendarParts({ events: [vevent(longSummary)] }));
|
||||
@@ -428,7 +429,7 @@ describe('buildUserIcs', () => {
|
||||
// An unescaped ; or , ends the property value early, so the calendar shows up
|
||||
// under a truncated name. The returned calName feeds the HTTP layer, not ICS,
|
||||
// and must stay verbatim.
|
||||
const user = seedUserWithToken('tok-user', { username: 'Alice; Bob, Co\\Ltd' });
|
||||
const user = await seedUserWithToken('tok-user', { username: 'Alice; Bob, Co\\Ltd' });
|
||||
createTrip(testDb, user.id, { start_date: '2026-01-01' });
|
||||
|
||||
const { ics, calName } = (await svc.buildUserIcs('tok-user'))!;
|
||||
|
||||
@@ -26,6 +26,10 @@ import type { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { makeShareToken } from '../../helpers/factories/trips';
|
||||
import { deleteRows, insertRow } from '../../helpers/factories/rows';
|
||||
import { Photos } from '../../../src/db/entities/Photos.entity';
|
||||
import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity';
|
||||
|
||||
// The serving swap addresses files as (category, name) on the injected facade;
|
||||
// these unit tests only assert routing/auth/error mapping, so a two-method stub
|
||||
@@ -212,24 +216,22 @@ describe('applyPlatformUploads', () => {
|
||||
// every case here seeds REAL rows in `uploadsTestDb` (bound to
|
||||
// `uploadsOrm.orm`) rather than mocking `db.prepare`'s return value; the
|
||||
// `h.dbPrepare` stub is dead for this whole describe block now.
|
||||
function insertShareToken(tripId: number, userId: number, token: string, expiresAt: string | null = null) {
|
||||
uploadsTestDb.prepare('INSERT INTO share_tokens (trip_id, token, created_by, expires_at) VALUES (?, ?, ?, ?)')
|
||||
.run(tripId, token, userId, expiresAt);
|
||||
async function insertShareToken(tripId: number, userId: number, token: string, expiresAt: string | null = null) {
|
||||
await makeShareToken(uploadsOrm, tripId, userId, { token, expires_at: expiresAt });
|
||||
}
|
||||
|
||||
function insertPhoto(tripId: number, filename: string) {
|
||||
uploadsTestDb.prepare('INSERT INTO photos (trip_id, filename, original_name) VALUES (?, ?, ?)')
|
||||
.run(tripId, filename, filename);
|
||||
async function insertPhoto(tripId: number, filename: string) {
|
||||
await insertRow(uploadsOrm, Photos, { trip: tripId, filename, original_name: filename });
|
||||
}
|
||||
|
||||
it('401 when a share token does not cover the photo trip', async () => {
|
||||
const { user } = createUser(uploadsTestDb);
|
||||
const photoTrip = createTrip(uploadsTestDb, user.id);
|
||||
const otherTrip = createTrip(uploadsTestDb, user.id);
|
||||
insertShareToken(otherTrip.id, user.id, 'share-mismatch');
|
||||
await insertShareToken(otherTrip.id, user.id, 'share-mismatch');
|
||||
h.exists.mockResolvedValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
insertPhoto(photoTrip.id, 'photo-mismatch.jpg');
|
||||
await insertPhoto(photoTrip.id, 'photo-mismatch.jpg');
|
||||
const res = makeRes();
|
||||
await photoHandler()({ params: { filename: 'photo-mismatch.jpg' }, headers: {}, query: { token: 'share-mismatch' } }, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
@@ -240,7 +242,7 @@ describe('applyPlatformUploads', () => {
|
||||
const photoTrip = createTrip(uploadsTestDb, user.id);
|
||||
h.exists.mockResolvedValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
insertPhoto(photoTrip.id, 'photo-unknown-token.jpg');
|
||||
await insertPhoto(photoTrip.id, 'photo-unknown-token.jpg');
|
||||
const res = makeRes();
|
||||
await photoHandler()({ params: { filename: 'photo-unknown-token.jpg' }, headers: {}, query: { token: 'never-issued' } }, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
@@ -249,11 +251,11 @@ describe('applyPlatformUploads', () => {
|
||||
it('R5: 401 when the token is revoked (deleted, not merely unknown)', async () => {
|
||||
const { user } = createUser(uploadsTestDb);
|
||||
const photoTrip = createTrip(uploadsTestDb, user.id);
|
||||
insertShareToken(photoTrip.id, user.id, 'share-revoked');
|
||||
uploadsTestDb.prepare('DELETE FROM share_tokens WHERE token = ?').run('share-revoked');
|
||||
await insertShareToken(photoTrip.id, user.id, 'share-revoked');
|
||||
await deleteRows(uploadsOrm, ShareTokens, { token: 'share-revoked' });
|
||||
h.exists.mockResolvedValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
insertPhoto(photoTrip.id, 'photo-revoked.jpg');
|
||||
await insertPhoto(photoTrip.id, 'photo-revoked.jpg');
|
||||
const res = makeRes();
|
||||
await photoHandler()({ params: { filename: 'photo-revoked.jpg' }, headers: {}, query: { token: 'share-revoked' } }, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
@@ -262,10 +264,10 @@ describe('applyPlatformUploads', () => {
|
||||
it('R5: 401 when the token is expired', async () => {
|
||||
const { user } = createUser(uploadsTestDb);
|
||||
const photoTrip = createTrip(uploadsTestDb, user.id);
|
||||
insertShareToken(photoTrip.id, user.id, 'share-expired', '2020-01-01 00:00:00');
|
||||
await insertShareToken(photoTrip.id, user.id, 'share-expired', '2020-01-01 00:00:00');
|
||||
h.exists.mockResolvedValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
insertPhoto(photoTrip.id, 'photo-expired.jpg');
|
||||
await insertPhoto(photoTrip.id, 'photo-expired.jpg');
|
||||
const res = makeRes();
|
||||
await photoHandler()({ params: { filename: 'photo-expired.jpg' }, headers: {}, query: { token: 'share-expired' } }, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
@@ -274,10 +276,10 @@ describe('applyPlatformUploads', () => {
|
||||
it('R5: 401 for a wrong-case token — no case-folding is introduced', async () => {
|
||||
const { user } = createUser(uploadsTestDb);
|
||||
const photoTrip = createTrip(uploadsTestDb, user.id);
|
||||
insertShareToken(photoTrip.id, user.id, 'share-CaseSensitive');
|
||||
await insertShareToken(photoTrip.id, user.id, 'share-CaseSensitive');
|
||||
h.exists.mockResolvedValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
insertPhoto(photoTrip.id, 'photo-case.jpg');
|
||||
await insertPhoto(photoTrip.id, 'photo-case.jpg');
|
||||
const res = makeRes();
|
||||
await photoHandler()({ params: { filename: 'photo-case.jpg' }, headers: {}, query: { token: 'share-casesensitive' } }, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
@@ -286,10 +288,10 @@ describe('applyPlatformUploads', () => {
|
||||
it('R5: 401 for a token with an embedded NUL byte', async () => {
|
||||
const { user } = createUser(uploadsTestDb);
|
||||
const photoTrip = createTrip(uploadsTestDb, user.id);
|
||||
insertShareToken(photoTrip.id, user.id, 'share-nul');
|
||||
await insertShareToken(photoTrip.id, user.id, 'share-nul');
|
||||
h.exists.mockResolvedValue(true);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
insertPhoto(photoTrip.id, 'photo-nul.jpg');
|
||||
await insertPhoto(photoTrip.id, 'photo-nul.jpg');
|
||||
const res = makeRes();
|
||||
await photoHandler()({ params: { filename: 'photo-nul.jpg' }, headers: {}, query: { token: 'share-nul\0extra' } }, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
@@ -298,11 +300,11 @@ describe('applyPlatformUploads', () => {
|
||||
it('serves the file when the share token covers the photo trip (R1 valid-token case)', async () => {
|
||||
const { user } = createUser(uploadsTestDb);
|
||||
const photoTrip = createTrip(uploadsTestDb, user.id);
|
||||
insertShareToken(photoTrip.id, user.id, 'share-valid');
|
||||
await insertShareToken(photoTrip.id, user.id, 'share-valid');
|
||||
h.exists.mockResolvedValue(true);
|
||||
h.sendToResponse.mockResolvedValue(undefined);
|
||||
h.verifyJwtAndLoadUser.mockReturnValue(null);
|
||||
insertPhoto(photoTrip.id, 'photo-valid.jpg');
|
||||
await insertPhoto(photoTrip.id, 'photo-valid.jpg');
|
||||
const res = makeRes();
|
||||
await photoHandler()(
|
||||
{ params: { filename: 'photo-valid.jpg' }, headers: { authorization: 'Bearer share-valid' }, query: {} },
|
||||
|
||||
@@ -16,6 +16,9 @@ import type { PlaceRatingsRepository } from '../../../src/db/repositories/PlaceR
|
||||
import { AssignmentParticipants } from '../../../src/db/entities/AssignmentParticipants.entity';
|
||||
import type { AssignmentParticipantsRepository } from '../../../src/db/repositories/AssignmentParticipants.repository';
|
||||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { tagPlace } from '../../helpers/factories/places';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
@@ -31,16 +34,16 @@ beforeAll(async () => {
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function attachTag(tagId: number, placeId: number): void {
|
||||
testDb.prepare('INSERT INTO place_tags (tag_id, place_id) VALUES (?, ?)').run(tagId, placeId);
|
||||
async function attachTag(tagId: number, placeId: number): Promise<void> {
|
||||
await tagPlace(t, placeId, [tagId]);
|
||||
}
|
||||
|
||||
function rate(placeId: number, userId: number, rating: number): void {
|
||||
testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)').run(placeId, userId, rating);
|
||||
async function rate(placeId: number, userId: number, rating: number): Promise<void> {
|
||||
await insertRow(t, PlaceRatings, { place: placeId, user: userId, rating });
|
||||
}
|
||||
|
||||
function addParticipant(assignmentId: number, userId: number): void {
|
||||
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignmentId, userId);
|
||||
async function addParticipant(assignmentId: number, userId: number): Promise<void> {
|
||||
await insertRow(t, AssignmentParticipants, { assignment: assignmentId, user: userId });
|
||||
}
|
||||
|
||||
describe('QueryHelpersService.loadTagsByPlaceIds', () => {
|
||||
@@ -53,7 +56,7 @@ describe('QueryHelpersService.loadTagsByPlaceIds', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const tag = createTag(testDb, user.id, { name: 'Beach', color: '#ff0000' });
|
||||
attachTag(tag.id, place.id);
|
||||
await attachTag(tag.id, place.id);
|
||||
|
||||
const byPlace = await svc.loadTagsByPlaceIds([place.id]);
|
||||
expect(byPlace[place.id]).toHaveLength(1);
|
||||
@@ -66,7 +69,7 @@ describe('QueryHelpersService.loadTagsByPlaceIds', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const tag = createTag(testDb, user.id, { name: 'Compact' });
|
||||
attachTag(tag.id, place.id);
|
||||
await attachTag(tag.id, place.id);
|
||||
|
||||
const byPlace = await svc.loadTagsByPlaceIds([place.id], { compact: true });
|
||||
expect(byPlace[place.id][0]).toEqual({ id: tag.id, name: 'Compact', color: tag.color, created_at: expect.any(String) });
|
||||
@@ -79,8 +82,8 @@ describe('QueryHelpersService.loadTagsByPlaceIds', () => {
|
||||
const placeB = createPlace(testDb, trip.id, { name: 'B' });
|
||||
const placeC = createPlace(testDb, trip.id, { name: 'C' });
|
||||
const tag = createTag(testDb, user.id);
|
||||
attachTag(tag.id, placeA.id);
|
||||
attachTag(tag.id, placeB.id);
|
||||
await attachTag(tag.id, placeA.id);
|
||||
await attachTag(tag.id, placeB.id);
|
||||
|
||||
const byPlace = await svc.loadTagsByPlaceIds([placeA.id, placeB.id, placeC.id]);
|
||||
expect(Object.keys(byPlace).map(Number).sort()).toEqual([placeA.id, placeB.id].sort());
|
||||
@@ -93,8 +96,8 @@ describe('QueryHelpersService.loadTagsByPlaceIds', () => {
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const tagA = createTag(testDb, user.id, { name: 'First' });
|
||||
const tagB = createTag(testDb, user.id, { name: 'Second' });
|
||||
attachTag(tagA.id, place.id);
|
||||
attachTag(tagB.id, place.id);
|
||||
await attachTag(tagA.id, place.id);
|
||||
await attachTag(tagB.id, place.id);
|
||||
|
||||
const byPlace = await svc.loadTagsByPlaceIds([place.id]);
|
||||
expect(byPlace[place.id].map((t) => t.name).sort()).toEqual(['First', 'Second']);
|
||||
@@ -111,7 +114,7 @@ describe('QueryHelpersService.loadRatingsByPlaceIds', () => {
|
||||
const { user: voter } = createUser(testDb, { username: 'rater1' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
rate(place.id, voter.id, 4);
|
||||
await rate(place.id, voter.id, 4);
|
||||
|
||||
const byPlace = await svc.loadRatingsByPlaceIds([place.id]);
|
||||
expect(byPlace[place.id]).toEqual([{ user_id: voter.id, username: 'rater1', avatar: null, rating: 4 }]);
|
||||
@@ -130,8 +133,8 @@ describe('QueryHelpersService.loadRatingsByPlaceIds', () => {
|
||||
const { user: voterB } = createUser(testDb, { username: 'voter-b' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
rate(place.id, voterA.id, 5);
|
||||
rate(place.id, voterB.id, 2);
|
||||
await rate(place.id, voterA.id, 5);
|
||||
await rate(place.id, voterB.id, 2);
|
||||
|
||||
const byPlace = await svc.loadRatingsByPlaceIds([place.id]);
|
||||
expect(byPlace[place.id].map((r) => r.username).sort()).toEqual(['voter-a', 'voter-b']);
|
||||
@@ -146,12 +149,12 @@ describe('QueryHelpersService.loadParticipantsByAssignmentIds', () => {
|
||||
it('QH-009: indexes participants by assignment id, with NO COALESCE(display_name, username) — raw username only', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: participant } = createUser(testDb, { username: 'raw-username' });
|
||||
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Ignored Display Name', participant.id);
|
||||
await updateRows(t, Users, { id: participant.id }, { display_name: 'Ignored Display Name' });
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
addParticipant(assignment.id, participant.id);
|
||||
await addParticipant(assignment.id, participant.id);
|
||||
|
||||
const byAssignment = await svc.loadParticipantsByAssignmentIds([assignment.id]);
|
||||
expect(byAssignment[assignment.id]).toEqual([{ user_id: participant.id, username: 'raw-username', avatar: null }]);
|
||||
@@ -174,8 +177,8 @@ describe('QueryHelpersService.loadParticipantsByAssignmentIds', () => {
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
addParticipant(assignment.id, p1.id);
|
||||
addParticipant(assignment.id, p2.id);
|
||||
await addParticipant(assignment.id, p1.id);
|
||||
await addParticipant(assignment.id, p2.id);
|
||||
|
||||
const byAssignment = await svc.loadParticipantsByAssignmentIds([assignment.id]);
|
||||
expect(byAssignment[assignment.id].map((p) => p.username).sort()).toEqual(['p1', 'p2']);
|
||||
|
||||
@@ -19,6 +19,9 @@ import { StorageRegistryService } from '../../../../src/nest/storage/storage-reg
|
||||
import { StorageService } from '../../../../src/nest/storage/storage.service';
|
||||
import { StatsBusyError, StorageStatsService } from '../../../../src/nest/storage/storage-stats.service';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../../helpers/test-uow';
|
||||
import { deleteRows } from '../../../helpers/factories/rows';
|
||||
import { readAppSetting, setAppSetting } from '../../../helpers/factories/settings';
|
||||
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
function makeTmpDir(): string {
|
||||
@@ -26,11 +29,11 @@ function makeTmpDir(): string {
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
function setSetting(key: string, value: string): void {
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
|
||||
async function setSetting(key: string, value: string): Promise<void> {
|
||||
await setAppSetting(await sharedTestOrm(testDb), key, value);
|
||||
}
|
||||
beforeEach(() => {
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'storage.%'").run();
|
||||
beforeEach(async () => {
|
||||
await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -40,7 +43,7 @@ afterEach(() => {
|
||||
async function makeWorld() {
|
||||
const uploadsRoot = makeTmpDir();
|
||||
const backupsRoot = makeTmpDir();
|
||||
setSetting(
|
||||
await setSetting(
|
||||
'storage.backends',
|
||||
JSON.stringify([
|
||||
{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } },
|
||||
@@ -77,8 +80,8 @@ describe('StorageStatsService', () => {
|
||||
expect(usage.computedAt).toBeGreaterThan(0);
|
||||
// Persisted round-trip:
|
||||
expect(await stats.readUsage()).toEqual(usage);
|
||||
const raw = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.usage'").get() as { value: string };
|
||||
expect(JSON.parse(raw.value)).toEqual(usage);
|
||||
const raw = await readAppSetting(await sharedTestOrm(testDb), 'storage.usage');
|
||||
expect(JSON.parse(raw ?? 'null')).toEqual(usage);
|
||||
});
|
||||
|
||||
it('STATS-002 photos-google/photos-trek nested content is NOT double-counted into legacy photos', async () => {
|
||||
@@ -100,7 +103,7 @@ describe('StorageStatsService', () => {
|
||||
it('STATS-004 readUsage returns null on absent or unparseable rows', async () => {
|
||||
const { stats } = await makeWorld();
|
||||
expect(await stats.readUsage()).toBeNull();
|
||||
setSetting('storage.usage', 'not json');
|
||||
await setSetting('storage.usage', 'not json');
|
||||
expect(await stats.readUsage()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/
|
||||
import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { deleteRows, upsertRow } from '../../helpers/factories/rows';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
||||
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
|
||||
@@ -36,32 +37,41 @@ import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
|
||||
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
|
||||
function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) {
|
||||
testDb.prepare(
|
||||
`INSERT OR REPLACE INTO plugins (id, name, status, enabled, version, permissions, granted_permissions, capabilities, config, operator_egress)
|
||||
VALUES (?, ?, 'inactive', 0, '1.0.0', ?, ?, '{}', '{}', ?)`,
|
||||
).run(id, id, JSON.stringify(perms), JSON.stringify(perms), operatorEgress ? 1 : 0);
|
||||
async function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) {
|
||||
await upsertRow(await sharedTestOrm(testDb), Plugins, {
|
||||
id,
|
||||
name: id,
|
||||
status: 'inactive',
|
||||
enabled: 0,
|
||||
version: '1.0.0',
|
||||
permissions: JSON.stringify(perms),
|
||||
granted_permissions: JSON.stringify(perms),
|
||||
capabilities: '{}',
|
||||
config: '{}',
|
||||
operator_egress: operatorEgress ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
let rt: PluginRuntimeService;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDb.prepare('DELETE FROM plugins').run();
|
||||
testDb.prepare('DELETE FROM plugin_egress_hosts').run();
|
||||
testDb.prepare('DELETE FROM plugin_actions').run();
|
||||
const orm = await sharedTestOrm(testDb);
|
||||
await deleteRows(orm, Plugins);
|
||||
await deleteRows(orm, PluginEgressHosts);
|
||||
await deleteRows(orm, PluginActions);
|
||||
rt = await createPluginRuntime(testDb);
|
||||
});
|
||||
|
||||
describe('operator-supplied egress hosts', () => {
|
||||
it('OEG-001 — an admin can add hosts to a plugin that declared operatorEgress', async () => {
|
||||
install('gotify', true);
|
||||
await install('gotify', true);
|
||||
expect(await rt.wantsOperatorEgress('gotify')).toBe(true);
|
||||
expect(await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com'])).toEqual(['gotify.mydomain.com']);
|
||||
expect(await rt.operatorEgressHosts('gotify')).toEqual(['gotify.mydomain.com']);
|
||||
});
|
||||
|
||||
it('OEG-002 — a plugin that did NOT declare it can never have hosts added', async () => {
|
||||
install('sneaky', false);
|
||||
await install('sneaky', false);
|
||||
expect(await rt.wantsOperatorEgress('sneaky')).toBe(false);
|
||||
// This is the load-bearing check: without it an admin could silently widen egress for
|
||||
// ANY plugin, and the install-time consent would stop bounding what's possible.
|
||||
@@ -70,7 +80,7 @@ describe('operator-supplied egress hosts', () => {
|
||||
});
|
||||
|
||||
it('OEG-003 — hosts are validated exactly like manifest egress', async () => {
|
||||
install('gotify', true);
|
||||
await install('gotify', true);
|
||||
for (const bad of ['*', '*.com', 'https://gotify.example.com', 'has space', 'a/b']) {
|
||||
await expect(rt.setOperatorEgressHosts('gotify', [bad])).rejects.toThrow(/invalid host/);
|
||||
}
|
||||
@@ -79,14 +89,14 @@ describe('operator-supplied egress hosts', () => {
|
||||
});
|
||||
|
||||
it('OEG-004 — hosts are normalized and de-duplicated', async () => {
|
||||
install('gotify', true);
|
||||
await install('gotify', true);
|
||||
expect(await rt.setOperatorEgressHosts('gotify', ['Gotify.MyDomain.com', 'gotify.mydomain.com.', ' ', ''])).toEqual([
|
||||
'gotify.mydomain.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('OEG-005 — setting the list replaces it (a removed host is really gone)', async () => {
|
||||
install('gotify', true);
|
||||
await install('gotify', true);
|
||||
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
|
||||
await rt.setOperatorEgressHosts('gotify', ['b.example.com']);
|
||||
expect(await rt.operatorEgressHosts('gotify')).toEqual(['b.example.com']);
|
||||
@@ -115,13 +125,13 @@ describe('operator-supplied egress hosts', () => {
|
||||
it('OEG-010 — an operatorEgress plugin with no configured hosts still reaches nothing', async () => {
|
||||
// It ACTIVATES (it may have useful offline features), but the child's allow-list is the
|
||||
// union of its http:outbound:<host> grants and the admin's hosts — both empty here.
|
||||
install('gotify', true, ['http:outbound']);
|
||||
await install('gotify', true, ['http:outbound']);
|
||||
expect(await rt.operatorEgressHosts('gotify')).toEqual([]);
|
||||
expect(makeHostAllow([])('gotify.mydomain.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('OEG-007 — uninstalling drops the admin’s host consent with the plugin', async () => {
|
||||
install('gotify', true);
|
||||
await install('gotify', true);
|
||||
await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com']);
|
||||
await rt.uninstall('gotify', false);
|
||||
// A LATER plugin reusing this id must not silently inherit hosts approved for another.
|
||||
@@ -130,22 +140,23 @@ describe('operator-supplied egress hosts', () => {
|
||||
});
|
||||
|
||||
describe('settings-page actions (runtime)', () => {
|
||||
function declareAction(id: string, key: string, scope: 'user' | 'instance' = 'user') {
|
||||
testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)')
|
||||
.run(id, key, key, scope);
|
||||
async function declareAction(id: string, key: string, scope: 'user' | 'instance' = 'user') {
|
||||
await upsertRow(await sharedTestOrm(testDb), PluginActions, {
|
||||
plugin_id: id, action_key: key, label: key, hint: null, danger: 0, scope, sort_order: 0,
|
||||
});
|
||||
}
|
||||
|
||||
it('ACT-001 — actionsOf returns the declared descriptors of ONE scope', async () => {
|
||||
install('p', false);
|
||||
declareAction('p', 'testConnection');
|
||||
declareAction('p', 'purge', 'instance');
|
||||
await install('p', false);
|
||||
await declareAction('p', 'testConnection');
|
||||
await declareAction('p', 'purge', 'instance');
|
||||
expect(await rt.actionsOf('p', 'user')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false, scope: 'user' }]);
|
||||
expect(await rt.actionsOf('p', 'instance')).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
|
||||
});
|
||||
|
||||
it('ACT-002 — invoking an action the plugin never declared is REFUSED', async () => {
|
||||
install('p', false);
|
||||
declareAction('p', 'testConnection');
|
||||
await install('p', false);
|
||||
await declareAction('p', 'testConnection');
|
||||
// The key is caller-supplied (it comes off the URL), so the host must check it
|
||||
// against the manifest rather than forwarding whatever it is handed to the child.
|
||||
await expect(rt.invokeAction('p', 'somethingElse', 1, 'user')).rejects.toThrow(/did not declare action/);
|
||||
@@ -153,14 +164,14 @@ describe('settings-page actions (runtime)', () => {
|
||||
});
|
||||
|
||||
it('ACT-003 — a plugin with no actions can never be invoked', async () => {
|
||||
install('p', false);
|
||||
await install('p', false);
|
||||
await expect(rt.invokeAction('p', 'testConnection', 1, 'user')).rejects.toThrow(/did not declare action/);
|
||||
});
|
||||
|
||||
it('ACT-004 — a key declared in the OTHER scope is refused (the user route cannot fire an admin button)', async () => {
|
||||
install('p', false);
|
||||
declareAction('p', 'purge', 'instance');
|
||||
declareAction('p', 'testConnection', 'user');
|
||||
await install('p', false);
|
||||
await declareAction('p', 'purge', 'instance');
|
||||
await declareAction('p', 'testConnection', 'user');
|
||||
await expect(rt.invokeAction('p', 'purge', 1, 'user')).rejects.toThrow(/did not declare action "purge" in scope user/);
|
||||
await expect(rt.invokeAction('p', 'testConnection', 1, 'instance')).rejects.toThrow(/did not declare action "testConnection" in scope instance/);
|
||||
});
|
||||
@@ -170,8 +181,8 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', (
|
||||
it('OEG-008 — reports operatorEgress + the host count', async () => {
|
||||
const { PluginsService } = await import('../../../src/nest/plugins/plugins.service');
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
install('gotify', true);
|
||||
install('plain', false);
|
||||
await install('gotify', true);
|
||||
await install('plain', false);
|
||||
|
||||
// list() resolves required-addon dependencies through AddonsService, so it gets a real
|
||||
// one over the same DB. These fixtures declare no dependencies, so it is never consulted.
|
||||
|
||||
@@ -22,6 +22,9 @@ vi.mock('../../../src/db/database', async () => {
|
||||
import { db as testDb } from '../../../src/db/database';
|
||||
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
|
||||
import { createTestPluginsRepo } from '../../helpers/share-repos';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { deleteRows, insertRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
|
||||
import { PluginPoisService } from '../../../src/nest/plugins/contributions/plugin-pois.service';
|
||||
import { PluginPoisController, parsePluginPoiQuery } from '../../../src/nest/plugins/contributions/plugin-pois.controller';
|
||||
@@ -39,17 +42,21 @@ const hit = (over: Record<string, unknown> = {}) => ({ id: 'th-1', name: 'Trailh
|
||||
|
||||
// The plugin rows every case reads, re-seeded before each one on the migrated
|
||||
// snapshot the ORM repository is bound to.
|
||||
function seedPlugins(): void {
|
||||
testDb.prepare('DELETE FROM plugins').run();
|
||||
const insert = testDb.prepare('INSERT INTO plugins (id, name, status, sort_order, capabilities) VALUES (?, ?, ?, ?, ?)');
|
||||
insert.run('trail-finder', 'Trail Finder', 'active', 1, JSON.stringify({ poiCategories: [trailheads, { ...trailheads, id: 'huts', label: 'Huts', labels: undefined, icon: 'Tent' }] }));
|
||||
insert.run('water-map', 'Water Map', 'active', 0, JSON.stringify({ poiCategories: [{ id: 'taps', label: 'Taps', icon: 'Droplet', color: '#2b6cb0' }] }));
|
||||
insert.run('quiet', 'Quiet', 'active', 2, JSON.stringify({ poiCategories: [{ id: 'benches', label: 'Benches', icon: 'Info', color: '#000000' }] }));
|
||||
async function seedPlugins(): Promise<void> {
|
||||
const orm = await sharedTestOrm(testDb);
|
||||
await deleteRows(orm, Plugins);
|
||||
await insertRows(orm, Plugins, [
|
||||
{ id: 'trail-finder', name: 'Trail Finder', status: 'active', sort_order: 1, capabilities: JSON.stringify({ poiCategories: [trailheads, { ...trailheads, id: 'huts', label: 'Huts', labels: undefined, icon: 'Tent' }] }) },
|
||||
{ id: 'water-map', name: 'Water Map', status: 'active', sort_order: 0, capabilities: JSON.stringify({ poiCategories: [{ id: 'taps', label: 'Taps', icon: 'Droplet', color: '#2b6cb0' }] }) },
|
||||
{ id: 'quiet', name: 'Quiet', status: 'active', sort_order: 2, capabilities: JSON.stringify({ poiCategories: [{ id: 'benches', label: 'Benches', icon: 'Info', color: '#000000' }] }) },
|
||||
]);
|
||||
}
|
||||
|
||||
let pluginsRepo: PluginsRepository;
|
||||
beforeAll(async () => { pluginsRepo = await createTestPluginsRepo(testDb); });
|
||||
beforeEach(() => seedPlugins());
|
||||
beforeEach(async () => {
|
||||
await seedPlugins();
|
||||
});
|
||||
afterAll(() => { testDb.close(); });
|
||||
|
||||
function makeService(answer: () => unknown, providers = ['trail-finder', 'water-map']) {
|
||||
@@ -229,8 +236,9 @@ describe('PluginPoisService', () => {
|
||||
});
|
||||
|
||||
it('PLUGPOI-016: flattens and caps the plugin name an assistant reads, and falls back to the id', async () => {
|
||||
testDb.prepare('UPDATE plugins SET name = ? WHERE id = ?').run(`Trails\u202E\n\n## System ${'x'.repeat(4000)}`, 'trail-finder');
|
||||
testDb.prepare('UPDATE plugins SET name = ? WHERE id = ?').run('\u0007\u2028\u2066', 'water-map');
|
||||
const orm = await sharedTestOrm(testDb);
|
||||
await updateRows(orm, Plugins, { id: 'trail-finder' }, { name: `Trails\u202E\n\n## System ${'x'.repeat(4000)}` });
|
||||
await updateRows(orm, Plugins, { id: 'water-map' }, { name: '\u0007\u2028\u2066' });
|
||||
const names = (await makeService(() => []).service.available()).map((c) => [c.id, c.pluginName]);
|
||||
const trails = names.find(([id]) => id === 'trailheads')?.[1] ?? '';
|
||||
expect(trails.startsWith('Trails ## System xxx')).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user