test(server): seed the system notice, packing e2e and synology specs through the factories

Converts the raw prepare() fixtures and reads in the system notices
integration suite, the packing e2e and the synology memories integration
suite to the ORM factories and readers.

The packing e2e moves from its hand-rolled tables to the migrated snapshot,
seeds its three users with the ids their session cookies are signed for,
and drops the raw canAccessTrip on its database mock, which nothing reads
any more. The synology suite now shares one ORM handle for the photo
registration helpers and the readers instead of declaring a second one
halfway down the file.
This commit is contained in:
Maurice
2026-10-08 16:47:07 +02:00
parent 2a9730d7c5
commit 63bcf61a2b
3 changed files with 170 additions and 248 deletions
+75 -153
View File
@@ -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<number> {
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<number> {
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<ReturnType<typeof build>>;
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))
@@ -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<TrekPhotoRegistrationService['getOrCreate']>) => 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)
+40 -37
View File
@@ -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<string, string>) => 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')