diff --git a/server/tests/e2e/packing.e2e.test.ts b/server/tests/e2e/packing.e2e.test.ts index b7ac0bb3a..8d4cb14f2 100644 --- a/server/tests/e2e/packing.e2e.test.ts +++ b/server/tests/e2e/packing.e2e.test.ts @@ -1,9 +1,9 @@ /** * Packing module e2e — exercises the migrated /api/trips/:tripId/packing - * endpoints through the real JwtAuthGuard against a temp SQLite db. + * endpoints through the real JwtAuthGuard against a migrated temp SQLite db. * PackingService runs its real SQL via DatabaseModule (the DATABASE_CONNECTION - * factory picks up the mocked db singleton); trip access resolves through a - * real-SQL canAccessTrip over the temp db. Only the permission check, the + * factory picks up the mocked db singleton); trip access resolves through the + * real repositories over the temp db. Only the permission check, the * WebSocket broadcast and the notification sender stay mocked. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; @@ -11,111 +11,12 @@ 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, - display_name TEXT, avatar TEXT);`); - tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, currency TEXT);'); - tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);'); - // The post-migration shape of the packing tables (schema.ts + migrations.ts). - tmp.exec(`CREATE TABLE packing_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL, - name TEXT NOT NULL, - checked INTEGER DEFAULT 0, - category TEXT, - sort_order INTEGER DEFAULT 0, - weight_grams INTEGER, - bag_id INTEGER, - quantity INTEGER NOT NULL DEFAULT 1, - is_private INTEGER NOT NULL DEFAULT 0, - owner_id INTEGER, - updated_at DATETIME, - packed_quantity INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - );`); - tmp.exec(`CREATE TABLE packing_bags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL, - name TEXT NOT NULL, - color TEXT NOT NULL DEFAULT '#6366f1', - weight_limit_grams INTEGER, - sort_order INTEGER DEFAULT 0, - user_id INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - );`); - tmp.exec(`CREATE TABLE packing_bag_members ( - bag_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - PRIMARY KEY (bag_id, user_id) - );`); - tmp.exec(`CREATE TABLE packing_item_recipients ( - item_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - PRIMARY KEY (item_id, user_id) - );`); - tmp.exec(`CREATE TABLE packing_item_contributors ( - item_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'accepted', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (item_id, user_id) - );`); - tmp.exec(`CREATE TABLE packing_category_assignees ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL, - category_name TEXT NOT NULL, - user_id INTEGER NOT NULL, - UNIQUE(trip_id, category_name, user_id) - );`); - tmp.exec(`CREATE TABLE packing_templates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - created_by INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - );`); - tmp.exec(`CREATE TABLE packing_template_categories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - template_id INTEGER NOT NULL, - name TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0 - );`); - tmp.exec(`CREATE TABLE packing_template_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - category_id INTEGER NOT NULL, - name TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0, - weight_grams INTEGER, - quantity INTEGER NOT NULL DEFAULT 1, - bag_name TEXT - );`); - // 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, - // Real-SQL trip access over the temp db — PackingService.verifyTripAccess and - // DatabaseModule both read the mocked singleton. - canAccessTrip: (tripId: number | string, userId: number) => - db.prepare(` - SELECT t.id, t.user_id FROM trips t - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? - WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) - `).get(userId, tripId, userId), - isOwner: () => false, - getPlaceWithTags: () => null, - closeDb: () => {}, - reinitialize: () => {}, -})); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); import { PermissionsService } from '../../src/nest/permissions/permissions.service'; @@ -129,16 +30,34 @@ import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; 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 { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { db } from '../../src/db/database'; +import { dbNow } from '../../src/db/types/db-timestamp.type'; +import { PackingBags } from '../../src/db/entities/PackingBags.entity'; +import { PackingItems } from '../../src/db/entities/PackingItems.entity'; +import { PackingTemplateCategories } from '../../src/db/entities/PackingTemplateCategories.entity'; +import { PackingTemplateItems } from '../../src/db/entities/PackingTemplateItems.entity'; +import { PackingTemplates } from '../../src/db/entities/PackingTemplates.entity'; +import { TripMembers } from '../../src/db/entities/TripMembers.entity'; +import { makeAdmin, makeUser } from '../helpers/factories/users'; +import { makeTrip } from '../helpers/factories/trips'; +import { countRows, findRow, findRows, insertRow } from '../helpers/factories/rows'; -function insertItem(tripId: number, name: string, extra: Partial<{ sort_order: number; category: string; is_private: number; owner_id: number }> = {}): number { - const res = db - .prepare('INSERT INTO packing_items (trip_id, name, sort_order, category, is_private, owner_id, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)') - .run(tripId, name, extra.sort_order ?? 0, extra.category ?? null, extra.is_private ?? 0, extra.owner_id ?? null); - return Number(res.lastInsertRowid); +let orm: TestOrm; + +function insertItem(tripId: number, name: string, extra: Partial<{ sort_order: number; category: string; is_private: number; owner_id: number }> = {}): Promise { + return insertRow(orm, PackingItems, { + trip: tripId, name, sort_order: extra.sort_order ?? 0, category: extra.category ?? null, + is_private: extra.is_private ?? 0, owner: extra.owner_id ?? null, updated_at: dbNow(), + }); } -describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { +/** A trip owned by user 1, the way every case starts. */ +async function insertTrip(title: string): Promise { + return (await makeTrip(orm, 1, { title })).id; +} + +describe('Packing e2e (real auth guard + real SQL over migrated temp SQLite)', () => { let server: Server; let app: Awaited>; let tripId: number; @@ -156,15 +75,17 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { } beforeAll(async () => { - seedUser(db as never, { id: 1 }); - seedUser(db as never, { id: 2, email: 'stranger@example.test' }); - seedUser(db as never, { id: 3, email: 'admin@example.test', role: 'admin' }); + orm = await createTestOrm(db); + // Pinned ids: sessionCookie(1), (2) and (3) sign for exactly these users. + await makeUser(orm, { id: 1, email: 'e2e@example.test' }); + await makeUser(orm, { id: 2, email: 'stranger@example.test' }); + await makeAdmin(orm, { id: 3, email: 'admin@example.test' }); app = await build(); checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); server = app.getHttpServer(); }); - beforeEach(() => { + beforeEach(async () => { db.exec('DELETE FROM packing_category_assignees'); db.exec('DELETE FROM packing_template_items'); db.exec('DELETE FROM packing_template_categories'); @@ -176,12 +97,13 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { db.exec('DELETE FROM packing_bags'); db.exec('DELETE FROM trip_members'); db.exec('DELETE FROM trips'); - tripId = Number(db.prepare('INSERT INTO trips (user_id, title) VALUES (1, ?)').run('Trip').lastInsertRowid); + tripId = await insertTrip('Trip'); checkPermission.mockReturnValue(true); }); afterAll(async () => { await app.close(); + await orm.close(); }); it('401 without a session cookie', async () => { @@ -190,8 +112,8 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { }); it('200 list, hiding another member\'s private items from the viewer (#858)', async () => { - insertItem(tripId, 'Shared', { sort_order: 0 }); - insertItem(tripId, 'Secret', { sort_order: 1, is_private: 1, owner_id: 2 }); + await insertItem(tripId, 'Shared', { sort_order: 0 }); + await insertItem(tripId, 'Secret', { sort_order: 1, is_private: 1, owner_id: 2 }); const res = await request(server).get(`/api/trips/${tripId}/packing`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body.items.map((i: { name: string }) => i.name)).toEqual(['Shared']); @@ -204,12 +126,12 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { }); it('201 on create, inserting the row with the legacy defaults', async () => { - insertItem(tripId, 'Existing', { sort_order: 4 }); + await insertItem(tripId, 'Existing', { sort_order: 4 }); const res = await request(server).post(`/api/trips/${tripId}/packing`).set('Cookie', sessionCookie(1)).send({ name: 'Socks' }); expect(res.status).toBe(201); // 'Other' is the unified category default (shared with bulkImport/saveAsTemplate). expect(res.body.item).toMatchObject({ name: 'Socks', checked: 0, category: 'Other', quantity: 1, sort_order: 5, owner_id: 1 }); - expect(db.prepare('SELECT name FROM packing_items WHERE id = ?').get(res.body.item.id)).toEqual({ name: 'Socks' }); + expect((await findRow(orm, PackingItems, { id: res.body.item.id }))?.name).toBe('Socks'); }); it('201 on create persists weight_grams, bag_id and quantity (#2154)', async () => { @@ -220,14 +142,14 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { .send({ name: 'Tent', weight_grams: 250, bag_id: bag.body.bag.id, quantity: 3 }); expect(res.status).toBe(201); expect(res.body.item).toMatchObject({ name: 'Tent', weight_grams: 250, bag_id: bag.body.bag.id, quantity: 3 }); - expect(db.prepare('SELECT weight_grams, bag_id, quantity FROM packing_items WHERE id = ?').get(res.body.item.id)) - .toEqual({ weight_grams: 250, bag_id: bag.body.bag.id, quantity: 3 }); + expect(await findRow(orm, PackingItems, { id: res.body.item.id })) + .toMatchObject({ weight_grams: 250, bag_id: bag.body.bag.id, quantity: 3 }); }); it('400 "Bag not found" on create for a bag off the trip; camelCase keys still strip (#2154)', async () => { // A REAL bag on a different trip — existence alone must not be enough. - const otherTripId = Number(db.prepare('INSERT INTO trips (user_id, title) VALUES (1, ?)').run('Other').lastInsertRowid); - const foreignBag = Number(db.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(otherTripId, 'Foreign').lastInsertRowid); + const otherTripId = await insertTrip('Other'); + const foreignBag = await insertRow(orm, PackingBags, { trip: otherTripId, name: 'Foreign' }); const cross = await request(server).post(`/api/trips/${tripId}/packing`).set('Cookie', sessionCookie(1)).send({ name: 'Tent', bag_id: foreignBag }); expect(cross.status).toBe(400); @@ -235,7 +157,7 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { const dead = await request(server).post(`/api/trips/${tripId}/packing`).set('Cookie', sessionCookie(1)).send({ name: 'Tent', bag_id: 99999 }); expect(dead.status).toBe(400); expect(dead.body).toEqual({ error: 'Bag not found' }); - expect((db.prepare('SELECT COUNT(*) AS n FROM packing_items WHERE trip_id = ?').get(tripId) as { n: number }).n).toBe(0); + expect(await countRows(orm, PackingItems, { trip: tripId })).toBe(0); // camelCase was never part of the contract: the keys strip as they always did. const camel = await request(server).post(`/api/trips/${tripId}/packing`).set('Cookie', sessionCookie(1)).send({ name: 'Probe', weightGrams: 250, bagId: foreignBag }); @@ -244,13 +166,13 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { }); it('400 "Bag not found" on update for a bag off the trip, leaving the row alone (#2154)', async () => { - const otherTripId = Number(db.prepare('INSERT INTO trips (user_id, title) VALUES (1, ?)').run('Other').lastInsertRowid); - const foreignBag = Number(db.prepare('INSERT INTO packing_bags (trip_id, name) VALUES (?, ?)').run(otherTripId, 'Foreign').lastInsertRowid); - const id = insertItem(tripId, 'Tent'); + const otherTripId = await insertTrip('Other'); + const foreignBag = await insertRow(orm, PackingBags, { trip: otherTripId, name: 'Foreign' }); + const id = await insertItem(tripId, 'Tent'); const res = await request(server).put(`/api/trips/${tripId}/packing/${id}`).set('Cookie', sessionCookie(1)).send({ bag_id: foreignBag }); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'Bag not found' }); - expect((db.prepare('SELECT bag_id FROM packing_items WHERE id = ?').get(id) as { bag_id: number | null }).bag_id).toBeNull(); + expect((await findRow(orm, PackingItems, { id: id }))?.bag_id).toBeNull(); }); it('403 on create without permission, writing nothing', async () => { @@ -258,11 +180,11 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { const res = await request(server).post(`/api/trips/${tripId}/packing`).set('Cookie', sessionCookie(1)).send({ name: 'X' }); expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'No permission' }); - expect((db.prepare('SELECT COUNT(*) AS n FROM packing_items').get() as { n: number }).n).toBe(0); + expect(await countRows(orm, PackingItems)).toBe(0); }); it('200 on update; bodyKeys gate the sentinel columns, omitted keys stay', async () => { - const id = insertItem(tripId, 'Tent', { category: 'Gear' }); + const id = await insertItem(tripId, 'Tent', { category: 'Gear' }); const renamed = await request(server).put(`/api/trips/${tripId}/packing/${id}`).set('Cookie', sessionCookie(1)).send({ name: 'Big tent' }); expect(renamed.status).toBe(200); expect(renamed.body.item).toMatchObject({ id, name: 'Big tent', category: 'Gear' }); @@ -291,7 +213,7 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { }); it('409 with the server row when the x-base-updated-at token is stale (#1135)', async () => { - const id = insertItem(tripId, 'Original'); + const id = await insertItem(tripId, 'Original'); const res = await request(server) .put(`/api/trips/${tripId}/packing/${id}`) .set('Cookie', sessionCookie(1)) @@ -301,15 +223,15 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { expect(res.body.error).toBe('conflict'); expect(res.body.server.name).toBe('Original'); // The row must NOT have been overwritten. - expect((db.prepare('SELECT name FROM packing_items WHERE id = ?').get(id) as { name: string }).name).toBe('Original'); + expect((await findRow(orm, PackingItems, { id: id }))?.name).toBe('Original'); }); it('200 on delete, removing the row; 404 when already gone', async () => { - const id = insertItem(tripId, 'Gone'); + const id = await insertItem(tripId, 'Gone'); const ok = await request(server).delete(`/api/trips/${tripId}/packing/${id}`).set('Cookie', sessionCookie(1)); expect(ok.status).toBe(200); expect(ok.body).toEqual({ success: true }); - expect(db.prepare('SELECT id FROM packing_items WHERE id = ?').get(id)).toBeUndefined(); + expect(await findRow(orm, PackingItems, { id })).toBeNull(); const missing = await request(server).delete(`/api/trips/${tripId}/packing/${id}`).set('Cookie', sessionCookie(1)); expect(missing.status).toBe(404); expect(missing.body).toEqual({ error: 'Item not found' }); @@ -323,12 +245,12 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { }); it('200 on reorder, persisting the new sort_order', async () => { - const a = insertItem(tripId, 'A', { sort_order: 0 }); - const b = insertItem(tripId, 'B', { sort_order: 1 }); + const a = await insertItem(tripId, 'A', { sort_order: 0 }); + const b = await insertItem(tripId, 'B', { sort_order: 1 }); const res = await request(server).put(`/api/trips/${tripId}/packing/reorder`).set('Cookie', sessionCookie(1)).send({ orderedIds: [b, a] }); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); - const rows = db.prepare('SELECT id FROM packing_items WHERE trip_id = ? ORDER BY sort_order').all(tripId) as { id: number }[]; + const rows = await findRows(orm, PackingItems, { trip: tripId }, { sort_order: 'asc' }); expect(rows.map((r) => r.id)).toEqual([b, a]); }); @@ -339,7 +261,7 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { .send({ items: [{ name: 'Shirt', bag: 'Carry-On' }, { name: ' ' }, { name: 'Pants', bag: 'Carry-On' }] }); expect(res.status).toBe(201); expect(res.body.count).toBe(2); - const bags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ?').all(tripId) as { name: string }[]; + const bags = await findRows(orm, PackingBags, { trip: tripId }); expect(bags).toHaveLength(1); expect(bags[0].name).toBe('Carry-On'); }); @@ -363,21 +285,21 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { }); it('400 from the Zod pipe on sharing with an invalid visibility', async () => { - const id = insertItem(tripId, 'Tent'); + const id = await insertItem(tripId, 'Tent'); const res = await request(server).put(`/api/trips/${tripId}/packing/${id}/sharing`).set('Cookie', sessionCookie(1)).send({ visibility: 'secret' }); expect(res.status).toBe(400); expect(res.body.error).toContain('visibility'); }); it('accepts the legacy numeric checked form through the pipe', async () => { - const id = insertItem(tripId, 'Toggle me'); + const id = await insertItem(tripId, 'Toggle me'); const res = await request(server).put(`/api/trips/${tripId}/packing/${id}`).set('Cookie', sessionCookie(1)).send({ checked: 1 }); expect(res.status).toBe(200); expect(res.body.item.checked).toBe(1); }); it('counts packed pieces and ticks the item once the count is full (#2296)', async () => { - const id = insertItem(tripId, 'Shirts'); + const id = await insertItem(tripId, 'Shirts'); await request(server).put(`/api/trips/${tripId}/packing/${id}`).set('Cookie', sessionCookie(1)).send({ quantity: 3 }); const partial = await request(server).put(`/api/trips/${tripId}/packing/${id}`).set('Cookie', sessionCookie(1)).send({ packed_quantity: 2 }); expect(partial.status).toBe(200); @@ -424,11 +346,11 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { const res = await request(server).post(`/api/trips/${tripId}/packing/bags`).set('Cookie', sessionCookie(1)).send({ name: 'Backpack', weight_limit_grams: 8000 }); expect(res.status).toBe(201); expect(res.body.bag).toMatchObject({ name: 'Backpack', weight_limit_grams: 8000 }); - expect((db.prepare('SELECT weight_limit_grams FROM packing_bags WHERE id = ?').get(res.body.bag.id) as { weight_limit_grams: number | null }).weight_limit_grams).toBe(8000); + expect((await findRow(orm, PackingBags, { id: res.body.bag.id }))?.weight_limit_grams).toBe(8000); }); it('bag members: sets roster members only, dropping off-trip user ids', async () => { - db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, 2); + await insertRow(orm, TripMembers, { trip: tripId, user: 2 }); const created = await request(server).post(`/api/trips/${tripId}/packing/bags`).set('Cookie', sessionCookie(1)).send({ name: 'Main' }); const bagId = created.body.bag.id; const res = await request(server) @@ -440,24 +362,24 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { }); it('apply-template: 200 with the added items; 404 for an empty template', async () => { - const templateId = Number(db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, 1)').run('Camping').lastInsertRowid); - const catId = Number(db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, 0)').run(templateId, 'Gear').lastInsertRowid); - db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, 0)').run(catId, 'Tent'); + const templateId = await insertRow(orm, PackingTemplates, { name: 'Camping', createdByRef: 1 }); + const catId = await insertRow(orm, PackingTemplateCategories, { template: templateId, name: 'Gear', sort_order: 0 }); + await insertRow(orm, PackingTemplateItems, { category: catId, name: 'Tent', sort_order: 0 }); const ok = await request(server).post(`/api/trips/${tripId}/packing/apply-template/${templateId}`).set('Cookie', sessionCookie(1)).send({}); expect(ok.status).toBe(200); // @HttpCode(200) — the legacy POST returned 200 expect(ok.body.count).toBe(1); expect(ok.body.items[0]).toMatchObject({ name: 'Tent', category: 'Gear' }); - const emptyId = Number(db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, 1)').run('Empty').lastInsertRowid); + const emptyId = await insertRow(orm, PackingTemplates, { name: 'Empty', createdByRef: 1 }); const missing = await request(server).post(`/api/trips/${tripId}/packing/apply-template/${emptyId}`).set('Cookie', sessionCookie(1)).send({}); expect(missing.status).toBe(404); expect(missing.body).toEqual({ error: 'Template not found or empty' }); }); it('save-as-template: 403 for non-admins, 201 for an admin with items', async () => { - db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, 3); - insertItem(tripId, 'Shirt', { category: 'Clothes' }); + await insertRow(orm, TripMembers, { trip: tripId, user: 3 }); + await insertItem(tripId, 'Shirt', { category: 'Clothes' }); const denied = await request(server).post(`/api/trips/${tripId}/packing/save-as-template`).set('Cookie', sessionCookie(1)).send({ name: 'Tpl' }); expect(denied.status).toBe(403); @@ -469,7 +391,7 @@ describe('Packing e2e (real auth guard + real SQL over temp SQLite)', () => { }); it('category assignees round-trip: PUT replaces, GET groups by category', async () => { - db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, 2); + await insertRow(orm, TripMembers, { trip: tripId, user: 2 }); const put = await request(server) .put(`/api/trips/${tripId}/packing/category-assignees/Clothes`) .set('Cookie', sessionCookie(1)) diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index 92869c18a..b71732f00 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -181,20 +181,41 @@ vi.mock('../../src/utils/ssrfGuard', async () => { }); import { db as testDb } from '../../src/db/database'; +import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits, setAddonEnabled } from '../helpers/test-db'; +import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { createRow, deleteRows, findRow, findRows, insertRow, insertRowIgnoringConflict, updateRows } from '../helpers/factories/rows'; +import { readUser } from '../helpers/factories/users'; +import { PhotoProviders } from '../../src/db/entities/PhotoProviders.entity'; +import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity'; +import { TripAlbumLinks } from '../../src/db/entities/TripAlbumLinks.entity'; +import { TripPhotos } from '../../src/db/entities/TripPhotos.entity'; +import { Users } from '../../src/db/entities/Users.entity'; import { safeFetch } from '../../src/utils/ssrfGuard'; let nestApp: INestApplication; let app: Application; +let orm: FactoryOrm; const SYNO = '/api/integrations/memories/synologyphotos'; +/** The user's photo rows on the trip, each with the provider and passphrase of the photo it points at. */ +async function tripPhotoRows(tripId: number, userId: number) { + const rows = []; + for (const tp of await findRows(orm, TripPhotos, { trip: tripId, user: userId })) { + const tkp = await findRow(orm, TrekPhotos, { id: tp.photo_id }); + if (tkp) rows.push({ ...tp, provider: tkp.provider, passphrase: tkp.passphrase }); + } + return rows; +} + beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { @@ -234,7 +255,7 @@ describe('Synology settings', () => { expect(res.status).toBe(200); - const row = testDb.prepare('SELECT synology_url, synology_username FROM users WHERE id = ?').get(user.id) as any; + const row = await readUser(orm, user.id); expect(row.synology_url).toBe('https://synology.example.com'); expect(row.synology_username).toBe('admin'); }); @@ -559,10 +580,7 @@ describe('Synology asset access', () => { const { user } = createUser(testDb); setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); - const insert = testDb.prepare( - 'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)' - ).run('synologyphotos', '101_cachekey', user.id); - const trekPhotoId = Number(insert.lastInsertRowid); + const trekPhotoId = await insertRow(orm, TrekPhotos, { provider: 'synologyphotos', asset_id: '101_cachekey', owner: user.id }); vi.mocked(safeFetch).mockClear(); @@ -606,11 +624,9 @@ describe('Synology asset access', () => { const { user: member } = createUser(testDb); // Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily) testDb.exec('PRAGMA foreign_keys = OFF'); - testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('synologyphotos', '101_cachekey', owner.id); - const tkpSyno35 = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('synologyphotos', '101_cachekey', owner.id) as any; - testDb.prepare( - 'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)' - ).run(9999, owner.id, tkpSyno35.id, 1); + const known = await findRow(orm, TrekPhotos, { provider: 'synologyphotos', asset_id: '101_cachekey', owner: owner.id }); + const tkpSyno35 = known?.id ?? (await insertRow(orm, TrekPhotos, { provider: 'synologyphotos', asset_id: '101_cachekey', owner: owner.id })); + await insertRow(orm, TripPhotos, { trip: 9999, user: owner.id, photo: tkpSyno35, shared: 1 }); testDb.exec('PRAGMA foreign_keys = ON'); const res = await request(app) @@ -694,7 +710,7 @@ describe('Synology syncSynologyAlbumLink', () => { setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); // The migration inserts synologyphotos with enabled=0; ensure it is enabled for this test. // A provider only counts as enabled under an enabled journey addon (also seeded off). - testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run(); + await updateRows(orm, PhotoProviders, { id: 'synologyphotos' }, { enabled: 1 }); setAddonEnabled(testDb, 'journey', true); // album_id must be a numeric string so getAlbumIdFromLink returns it and // syncSynologyAlbumLink passes Number(album_id) to the API. @@ -709,11 +725,7 @@ describe('Synology syncSynologyAlbumLink', () => { expect(typeof res.body.total).toBe('number'); // Verify photos were inserted into the DB - const photos = testDb.prepare(` - SELECT tp.*, tkp.provider FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tp.trip_id = ? AND tp.user_id = ? - `).all(trip.id, user.id) as any[]; + const photos = await tripPhotoRows(trip.id, user.id); expect(photos.length).toBeGreaterThan(0); expect(photos[0].provider).toBe('synologyphotos'); }); @@ -754,15 +766,14 @@ describe('Synology syncSynologyAlbumLink', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); - testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run(); + await updateRows(orm, PhotoProviders, { id: 'synologyphotos' }, { enabled: 1 }); setAddonEnabled(testDb, 'journey', true); // Insert a link with an encrypted passphrase directly into the DB. const rawPassphrase = 'syno-share-pass-abc'; - const result = testDb.prepare( - 'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)' - ).run(trip.id, user.id, 'synologyphotos', '99', 'Shared Album', encrypt_api_key(rawPassphrase)); - const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(result.lastInsertRowid) as any; + const link = await createRow(orm, TripAlbumLinks, { + trip: trip.id, user: user.id, provider: 'synologyphotos', album_id: '99', album_name: 'Shared Album', passphrase: encrypt_api_key(rawPassphrase), + }); // Override safeFetch so browse-item only succeeds when called with the passphrase param. vi.mocked(safeFetch).mockImplementation(async (url: any, init?: any) => { @@ -804,12 +815,7 @@ describe('Synology syncSynologyAlbumLink', () => { expect(res.body.added).toBeGreaterThan(0); // The trek_photos row for the synced photo must have a non-null passphrase. - const photo = testDb.prepare(` - SELECT tkp.passphrase FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tp.trip_id = ? AND tp.user_id = ? - LIMIT 1 - `).get(trip.id, user.id) as { passphrase: string | null } | undefined; + const [photo] = await tripPhotoRows(trip.id, user.id); expect(photo).toBeDefined(); expect(photo!.passphrase).not.toBeNull(); @@ -1170,7 +1176,6 @@ describe('Synology SSRF blocked error handling', () => { // ── Passphrase persistence fixes ───────────────────────────────────────────── -import { MikroORM } from '@mikro-orm/core'; import { withRequestContext } from '../../src/nest/database/request-context'; import { TrekPhotoRegistrationService } from '../../src/nest/photos/trek-photo-registration.service'; @@ -1184,10 +1189,8 @@ import { TrekPhotoRegistrationService } from '../../src/nest/photos/trek-photo-r // `withRequestContext` supplies the same per-call EntityManager fork a real // request's `mikroOrmRequestContext` middleware (bootstrap.ts) would. let trekPhotos: TrekPhotoRegistrationService; -let orm: MikroORM; beforeAll(() => { trekPhotos = nestApp.get(TrekPhotoRegistrationService); - orm = nestApp.get(MikroORM); }); const getOrCreateTrekPhoto = (...a: Parameters) => withRequestContext(orm, () => trekPhotos.getOrCreate(...a)); const deleteTrekPhotoIfOrphan = (id: number) => withRequestContext(orm, () => trekPhotos.deleteIfOrphan(id)); @@ -1201,13 +1204,13 @@ describe('trek_photos passphrase healing (SYNO-090)', () => { const correctPass = 'correct-passphrase'; const id1 = await getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, wrongPass); - const row1 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id1) as { passphrase: string }; - expect(decrypt_api_key(row1.passphrase)).toBe(wrongPass); + const row1 = (await findRow(orm, TrekPhotos, { id: id1 })); + expect(decrypt_api_key(String(row1?.passphrase))).toBe(wrongPass); const id2 = await getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, correctPass); expect(id2).toBe(id1); - const row2 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string }; - expect(decrypt_api_key(row2.passphrase)).toBe(correctPass); + const row2 = (await findRow(orm, TrekPhotos, { id: id2 })); + expect(decrypt_api_key(String(row2?.passphrase))).toBe(correctPass); }); }); @@ -1215,47 +1218,43 @@ describe('trek_photos orphan cleanup (SYNO-091)', () => { it('SYNO-091 — deleteTrekPhotoIfOrphan removes the trek_photos row when no trip_photos or journey_photos reference it', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run(); + await updateRows(orm, PhotoProviders, { id: 'synologyphotos' }, { enabled: 1 }); const trekPhotoId = await getOrCreateTrekPhoto('synologyphotos', 'asset-orphan-test', user.id, 'pass-A'); - testDb.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)' - ).run(trip.id, user.id, trekPhotoId); + await insertRowIgnoringConflict(orm, TripPhotos, { trip: trip.id, user: user.id, photo: trekPhotoId, shared: 1 }); // Still referenced — must not be deleted. await deleteTrekPhotoIfOrphan(trekPhotoId); - expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeDefined(); + expect((await findRow(orm, TrekPhotos, { id: trekPhotoId }))).not.toBeNull(); // Remove the reference, then orphan-cleanup should delete the trek_photos row. - testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(trekPhotoId); + await deleteRows(orm, TripPhotos, { photo: trekPhotoId }); await deleteTrekPhotoIfOrphan(trekPhotoId); - expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeUndefined(); + expect((await findRow(orm, TrekPhotos, { id: trekPhotoId }))).toBeNull(); }); it('SYNO-092 — re-adding a previously removed Synology photo stores the new passphrase correctly', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run(); + await updateRows(orm, PhotoProviders, { id: 'synologyphotos' }, { enabled: 1 }); const firstPass = 'first-passphrase'; const secondPass = 'second-passphrase'; // Add with wrong passphrase, then remove (simulating the bug scenario). const id1 = await getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, firstPass); - testDb.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)' - ).run(trip.id, user.id, id1); - testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(id1); + await insertRowIgnoringConflict(orm, TripPhotos, { trip: trip.id, user: user.id, photo: id1, shared: 1 }); + await deleteRows(orm, TripPhotos, { photo: id1 }); await deleteTrekPhotoIfOrphan(id1); // trek_photos row should be gone. - expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(id1)).toBeUndefined(); + expect((await findRow(orm, TrekPhotos, { id: id1 }))).toBeNull(); // Re-add with the correct passphrase. const id2 = await getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, secondPass); - const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string }; - expect(decrypt_api_key(row.passphrase)).toBe(secondPass); + const row = (await findRow(orm, TrekPhotos, { id: id2 })); + expect(decrypt_api_key(String(row?.passphrase))).toBe(secondPass); }); }); @@ -1276,15 +1275,13 @@ describe('Synology skip-SSL forwarding to image fetches (#1611)', () => { let assetSeq = 0; const uniqueAssetId = () => `${Date.now()}${++assetSeq}_test1611`; - function createSynologyTrekPhoto(skipSsl: 0 | 1) { + async function createSynologyTrekPhoto(skipSsl: 0 | 1) { const { user } = createUser(testDb); setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass'); - testDb.prepare('UPDATE users SET synology_skip_ssl = ? WHERE id = ?').run(skipSsl, user.id); + await updateRows(orm, Users, { id: user.id }, { synology_skip_ssl: skipSsl }); const assetId = uniqueAssetId(); - const insert = testDb.prepare( - 'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)' - ).run('synologyphotos', assetId, user.id); - return { user, trekPhotoId: Number(insert.lastInsertRowid) }; + const trekPhotoId = await insertRow(orm, TrekPhotos, { provider: 'synologyphotos', asset_id: assetId, owner: user.id }); + return { user, trekPhotoId }; } function thumbnailFetchCalls() { @@ -1292,7 +1289,7 @@ describe('Synology skip-SSL forwarding to image fetches (#1611)', () => { } it('SYNO-100 — thumbnail fetch passes rejectUnauthorized: false when skip-SSL is enabled', async () => { - const { user, trekPhotoId } = createSynologyTrekPhoto(1); + const { user, trekPhotoId } = await createSynologyTrekPhoto(1); vi.mocked(safeFetch).mockClear(); const res = await request(app) @@ -1308,7 +1305,7 @@ describe('Synology skip-SSL forwarding to image fetches (#1611)', () => { }); it('SYNO-101 — original fetch passes rejectUnauthorized: false when skip-SSL is enabled', async () => { - const { user, trekPhotoId } = createSynologyTrekPhoto(1); + const { user, trekPhotoId } = await createSynologyTrekPhoto(1); vi.mocked(safeFetch).mockClear(); const res = await request(app) @@ -1324,7 +1321,7 @@ describe('Synology skip-SSL forwarding to image fetches (#1611)', () => { }); it('SYNO-102 — image fetches verify TLS when skip-SSL is disabled', async () => { - const { user, trekPhotoId } = createSynologyTrekPhoto(0); + const { user, trekPhotoId } = await createSynologyTrekPhoto(0); vi.mocked(safeFetch).mockClear(); const res = await request(app) diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts index 9ef8e3bd7..38ffe64a2 100644 --- a/server/tests/integration/systemNotices.test.ts +++ b/server/tests/integration/systemNotices.test.ts @@ -26,10 +26,17 @@ 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 } from '../helpers/test-db'; +import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createAdmin } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { insertRow, updateRows } from '../helpers/factories/rows'; +import { setAppSetting } from '../helpers/factories/settings'; +import { Addons } from '../../src/db/entities/Addons.entity'; +import { UserNoticeDismissals } from '../../src/db/entities/UserNoticeDismissals.entity'; +import { Users } from '../../src/db/entities/Users.entity'; import { SYSTEM_NOTICES } from '../../src/systemNotices/registry'; import { getCurrentAppVersion } from '../../src/systemNotices/service'; import type { SystemNotice } from '../../src/systemNotices/types'; @@ -37,6 +44,7 @@ import { ADDON_IDS } from '../../src/addons'; let nestApp: INestApplication; let app: Application; +let orm: FactoryOrm; // Test notice injected into the registry for notice-specific tests const TEST_NOTICE: SystemNotice = { @@ -69,6 +77,7 @@ const TEST_NOTICE_ADDON: SystemNotice = { beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(() => { @@ -97,7 +106,7 @@ describe('GET /api/system-notices/active', () => { // nothing but the install being self-hosted still apply, and which ones those are // changes every release (the thank-you modal handed over to the release notes at 4.0.0). // Read the set out of the registry rather than naming them, or this ages out again. - testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); + await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); const alwaysOn = new Set( SYSTEM_NOTICES .filter(n => (n.conditions ?? []).every(c => c.kind === 'managed')) @@ -115,7 +124,7 @@ describe('GET /api/system-notices/active', () => { try { const { user } = createUser(testDb); // Set login_count to 1 (first login) - testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id); + await updateRows(orm, Users, { id: user.id }, { login_count: 1 }); const res = await request(app) .get('/api/system-notices/active') @@ -139,7 +148,7 @@ describe('GET /api/system-notices/active', () => { SYSTEM_NOTICES.push(TEST_NOTICE_ADDON); try { const { user } = createUser(testDb); - testDb.prepare('UPDATE addons SET enabled = 0 WHERE id = ?').run(ADDON_IDS.JOURNEY); + await updateRows(orm, Addons, { id: ADDON_IDS.JOURNEY }, { enabled: false }); const off = await request(app) .get('/api/system-notices/active') @@ -147,7 +156,7 @@ describe('GET /api/system-notices/active', () => { expect(off.status).toBe(200); expect(off.body.find((n: { id: string }) => n.id === TEST_NOTICE_ADDON.id)).toBeUndefined(); - testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run(ADDON_IDS.JOURNEY); + await updateRows(orm, Addons, { id: ADDON_IDS.JOURNEY }, { enabled: true }); const on = await request(app) .get('/api/system-notices/active') @@ -164,7 +173,7 @@ describe('GET /api/system-notices/active', () => { SYSTEM_NOTICES.push(TEST_NOTICE); try { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); + await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); const res = await request(app) .get('/api/system-notices/active') @@ -181,12 +190,10 @@ describe('GET /api/system-notices/active', () => { SYSTEM_NOTICES.push(TEST_NOTICE); try { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id); + await updateRows(orm, Users, { id: user.id }, { login_count: 1 }); // Dismiss the notice directly in DB - testDb.prepare( - 'INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, ?, ?)' - ).run(user.id, TEST_NOTICE.id, Date.now()); + await insertRow(orm, UserNoticeDismissals, { user: user.id, notice_id: TEST_NOTICE.id, dismissed_at: Date.now() }); const res = await request(app) .get('/api/system-notices/active') @@ -220,7 +227,7 @@ describe('GET /api/system-notices/active', () => { SYSTEM_NOTICES.push(RECURRING); try { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); + await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); const shows = async () => { const res = await request(app) @@ -234,15 +241,13 @@ describe('GET /api/system-notices/active', () => { expect(await shows()).toBe(true); // Dismissed at an old version → it returns once the running version is newer. - testDb.prepare( - 'INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at, dismissed_app_version) VALUES (?, ?, ?, ?)' - ).run(user.id, RECURRING.id, Date.now(), '0.0.1'); + await insertRow(orm, UserNoticeDismissals, { + user: user.id, notice_id: RECURRING.id, dismissed_at: Date.now(), dismissed_app_version: '0.0.1', + }); expect(await shows()).toBe(true); // Dismissed at a version >= the running one → stays hidden until the next upgrade. - testDb.prepare( - 'UPDATE user_notice_dismissals SET dismissed_app_version = ? WHERE user_id = ? AND notice_id = ?' - ).run('99.0.0', user.id, RECURRING.id); + await updateRows(orm, UserNoticeDismissals, { user: user.id, notice_id: RECURRING.id }, { dismissed_app_version: '99.0.0' }); expect(await shows()).toBe(false); } finally { const idx = SYSTEM_NOTICES.indexOf(RECURRING); @@ -256,7 +261,7 @@ describe('GET /api/system-notices/active', () => { // dismiss route, so neither half can drift away from the other unnoticed. it('shows the release notes once per update, however often they were closed before', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); + await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); // The client announces the release layout and the version it was built as; a // bundle that predates that, or one built for another version, never gets the @@ -284,9 +289,7 @@ describe('GET /api/system-notices/active', () => { expect(await shows()).toBe(false); // The next update brings it back, although it was closed before... - testDb.prepare( - 'UPDATE user_notice_dismissals SET dismissed_app_version = ? WHERE user_id = ? AND notice_id = ?' - ).run('4.0.0', user.id, 'release-notes'); + await updateRows(orm, UserNoticeDismissals, { user: user.id, notice_id: 'release-notes' }, { dismissed_app_version: '4.0.0' }); expect(await shows()).toBe(true); expect(await shows()).toBe(true); @@ -302,7 +305,7 @@ describe('GET /api/system-notices/active', () => { // was built for the version now running; the reload brings one. it('keeps the release notes from a client that does not announce the release layout for this version', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); + await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); const fetchActive = (query: Record) => request(app) .get('/api/system-notices/active') @@ -382,7 +385,7 @@ describe('POST /api/system-notices/:id/dismiss', () => { SYSTEM_NOTICES.push(TEST_NOTICE); try { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET login_count = 1 WHERE id = ?').run(user.id); + await updateRows(orm, Users, { id: user.id }, { login_count: 1 }); // Confirm TEST_NOTICE is visible before dismiss const before = await request(app) @@ -417,9 +420,9 @@ describe('POST /api/system-notices/:id/dismiss', () => { * (so existingUserBeforeVersion('3.0.14') passes) and whose login_count is * high enough to suppress the firstLogin and v3-upgrade notice conditions. */ -function setupCollisionAdmin() { +async function setupCollisionAdmin() { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); + await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); return user; } @@ -440,8 +443,8 @@ describe('v3014-whitespace-collision notice', () => { }); it('SN-COLLISION-1 — shown to admin when collision flag is set and user predates 3.0.14', async () => { - const user = setupCollisionAdmin(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); + const user = await setupCollisionAdmin(); + await setAppSetting(orm, 'whitespace_migration_collision', 'true'); const res = await request(app) .get('/api/system-notices/active') @@ -452,7 +455,7 @@ describe('v3014-whitespace-collision notice', () => { }); it('SN-COLLISION-2 — hidden when collision flag is absent', async () => { - const user = setupCollisionAdmin(); + const user = await setupCollisionAdmin(); const res = await request(app) .get('/api/system-notices/active') @@ -463,8 +466,8 @@ describe('v3014-whitespace-collision notice', () => { }); it('SN-COLLISION-3 — hidden when collision flag is explicitly false', async () => { - const user = setupCollisionAdmin(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'false')").run(); + const user = await setupCollisionAdmin(); + await setAppSetting(orm, 'whitespace_migration_collision', 'false'); const res = await request(app) .get('/api/system-notices/active') @@ -476,8 +479,8 @@ describe('v3014-whitespace-collision notice', () => { it('SN-COLLISION-4 — hidden for non-admin user even when collision flag is set', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); + await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.0' }); + await setAppSetting(orm, 'whitespace_migration_collision', 'true'); const res = await request(app) .get('/api/system-notices/active') @@ -489,8 +492,8 @@ describe('v3014-whitespace-collision notice', () => { it('SN-COLLISION-5 — hidden for user whose first_seen_version is >= 3.0.14 (new account)', async () => { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.14', user.id); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); + await updateRows(orm, Users, { id: user.id }, { login_count: 5, first_seen_version: '3.0.14' }); + await setAppSetting(orm, 'whitespace_migration_collision', 'true'); const res = await request(app) .get('/api/system-notices/active') @@ -502,8 +505,8 @@ describe('v3014-whitespace-collision notice', () => { it('SN-COLLISION-6 — hidden when app version is below 3.0.14', async () => { process.env.APP_VERSION = '3.0.13'; - const user = setupCollisionAdmin(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); + const user = await setupCollisionAdmin(); + await setAppSetting(orm, 'whitespace_migration_collision', 'true'); const res = await request(app) .get('/api/system-notices/active') @@ -514,8 +517,8 @@ describe('v3014-whitespace-collision notice', () => { }); it('SN-COLLISION-7 — hidden after admin dismisses it', async () => { - const user = setupCollisionAdmin(); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); + const user = await setupCollisionAdmin(); + await setAppSetting(orm, 'whitespace_migration_collision', 'true'); const before = await request(app) .get('/api/system-notices/active')