mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(server): seed the collections, storage registry, transit, token and immich specs through the factories
Converts the raw prepare() fixtures and reads in the collections e2e, the storage registry service spec, the transit MCP spec, the MCP token repository spec and the immich memories integration suite to the ORM factories and readers. The collections e2e drops the raw canAccessTrip on its database mock, which nothing reads any more. Reads that used to join trip_photos to trek_photos in SQL resolve the photo row through the readers instead, and the orphan trip photo of IMMICH-057 is still written with foreign keys switched off, now through the ORM on the same connection.
This commit is contained in:
@@ -22,8 +22,6 @@ vi.mock('../../src/db/database', async () => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -39,7 +37,13 @@ import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { countRows, findRow } from '../helpers/factories/rows';
|
||||
import { CollectionPlaces } from '../../src/db/entities/CollectionPlaces.entity';
|
||||
import { Collections } from '../../src/db/entities/Collections.entity';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
|
||||
let orm: TestOrm;
|
||||
|
||||
describe('Collections e2e (real auth guard + real service + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
@@ -64,6 +68,7 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(db);
|
||||
ownerId = createUser(db as never, { username: 'owner', email: 'owner@test.example' }).user.id;
|
||||
otherId = createUser(db as never, { username: 'other', email: 'other@test.example' }).user.id;
|
||||
createCategory(db as never);
|
||||
@@ -78,6 +83,7 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
// ── Addon gate ───────────────────────────────────────────────────────────
|
||||
@@ -128,8 +134,8 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
|
||||
.set('Cookie', sessionCookie(ownerId)).send({ trip_id: tripId, place_ids: [saved.body.place.id] });
|
||||
expect(copy.status).toBe(200);
|
||||
expect(copy.body.copied).toBe(1);
|
||||
const placed = db.prepare("SELECT reservation_status FROM places WHERE trip_id = ? AND name = 'Trevi Fountain'").get(tripId) as { reservation_status: string };
|
||||
expect(placed.reservation_status).toBe('none'); // itinerary defaults
|
||||
const placed = await findRow(orm, Places, { trip: tripId, name: 'Trevi Fountain' });
|
||||
expect(placed?.reservation_status).toBe('none'); // itinerary defaults
|
||||
});
|
||||
|
||||
// #2483: a place from the TREK index can carry its website without a scheme,
|
||||
@@ -147,9 +153,7 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
|
||||
});
|
||||
expect(saved.status).toBe(200);
|
||||
expect(saved.body.place.website).toBe('https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët');
|
||||
expect(db.prepare('SELECT website FROM collection_places WHERE id = ?').get(saved.body.place.id)).toEqual({
|
||||
website: 'https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët',
|
||||
});
|
||||
expect((await findRow(orm, CollectionPlaces, { id: saved.body.place.id }))?.website).toBe('https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët');
|
||||
});
|
||||
|
||||
it('COLLECTIONS-E2E-090: a script link on save is still a 400 and stores nothing', async () => {
|
||||
@@ -160,7 +164,7 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/^website: /);
|
||||
}
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 });
|
||||
expect(await countRows(orm, CollectionPlaces, { collection: col.id })).toBe(0);
|
||||
});
|
||||
|
||||
// Regression for #1437: editing a place (PATCH without a status field) must NOT
|
||||
@@ -171,12 +175,12 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
|
||||
.set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Colosseum', status: 'want' });
|
||||
expect(saved.status).toBe(200);
|
||||
const placeId = saved.body.place.id;
|
||||
expect(db.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId)).toEqual({ status: 'want' });
|
||||
expect((await findRow(orm, CollectionPlaces, { id: placeId }))?.status).toBe('want');
|
||||
|
||||
const patched = await request(server).patch(`/api/addons/collections/places/${placeId}`)
|
||||
.set('Cookie', sessionCookie(ownerId)).send({ name: 'Colosseo' });
|
||||
expect(patched.status).toBe(200);
|
||||
expect(db.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId)).toEqual({ status: 'want' });
|
||||
expect((await findRow(orm, CollectionPlaces, { id: placeId }))?.status).toBe('want');
|
||||
});
|
||||
|
||||
// #1870: the address was missing from the update contract, so the pipe stripped
|
||||
@@ -192,19 +196,19 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
|
||||
.set('Cookie', sessionCookie(ownerId)).send({ address: 'Via Nuova 1' });
|
||||
expect(patched.status).toBe(200);
|
||||
expect(patched.body.address).toBe('Via Nuova 1');
|
||||
expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: 'Via Nuova 1' });
|
||||
expect((await findRow(orm, CollectionPlaces, { id: placeId }))?.address).toBe('Via Nuova 1');
|
||||
|
||||
// A rename must not wipe the address that was just corrected.
|
||||
const renamed = await request(server).patch(`/api/addons/collections/places/${placeId}`)
|
||||
.set('Cookie', sessionCookie(ownerId)).send({ name: 'Trattoria da Enzo' });
|
||||
expect(renamed.status).toBe(200);
|
||||
expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: 'Via Nuova 1' });
|
||||
expect((await findRow(orm, CollectionPlaces, { id: placeId }))?.address).toBe('Via Nuova 1');
|
||||
|
||||
// null clears it again.
|
||||
const cleared = await request(server).patch(`/api/addons/collections/places/${placeId}`)
|
||||
.set('Cookie', sessionCookie(ownerId)).send({ address: null });
|
||||
expect(cleared.status).toBe(200);
|
||||
expect(db.prepare('SELECT address FROM collection_places WHERE id = ?').get(placeId)).toEqual({ address: null });
|
||||
expect((await findRow(orm, CollectionPlaces, { id: placeId }))?.address).toBeNull();
|
||||
});
|
||||
|
||||
// ── Cross-user isolation ─────────────────────────────────────────────────
|
||||
@@ -464,6 +468,6 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
|
||||
|
||||
expect((await request(server).delete(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(otherId))).status).toBe(403);
|
||||
expect((await request(server).delete(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(ownerId))).status).toBe(200);
|
||||
expect(db.prepare('SELECT COUNT(*) n FROM collections WHERE id = ?').get(col.id)).toEqual({ n: 0 });
|
||||
expect(await countRows(orm, Collections, { id: col.id })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,20 +198,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, addAlbumLink, setImmichCredentials } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { safeFetch } from '../../src/utils/ssrfGuard';
|
||||
import { findRow, findRows, insertRow, 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 { TripPhotos } from '../../src/db/entities/TripPhotos.entity';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: FactoryOrm;
|
||||
|
||||
const IMMICH = '/api/integrations/memories/immich';
|
||||
|
||||
/** The trip's photo rows, each with the provider and asset id of the photo it points at. */
|
||||
async function tripPhotoAssets(tripId: number, userId?: number) {
|
||||
const links = await findRows(orm, TripPhotos, userId === undefined ? { trip: tripId } : { trip: tripId, user: userId });
|
||||
const rows = [];
|
||||
for (const link of links) {
|
||||
const photo = await findRow(orm, TrekPhotos, { id: link.photo_id });
|
||||
if (photo) rows.push({ ...link, provider: photo.provider, asset_id: photo.asset_id });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -223,7 +244,7 @@ beforeEach(async () => {
|
||||
// configure it before it's usable in production); the legacy test helper always
|
||||
// seeded it enabled, which is what these tests assume. Same convention
|
||||
// memories-synology.test.ts already uses for its own provider.
|
||||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'immich'").run();
|
||||
await updateRows(orm, PhotoProviders, { id: 'immich' }, { enabled: 1 });
|
||||
immichState.albumAssets = DEFAULT_ALBUM_ASSETS.map((a) => ({ ...a }));
|
||||
immichState.albumAssetPages = null;
|
||||
immichState.searchCalls = [];
|
||||
@@ -462,11 +483,9 @@ describe('Immich asset proxy', () => {
|
||||
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('immich', 'asset-notrip', owner.id);
|
||||
const tkpNotrip = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-notrip', owner.id) as any;
|
||||
testDb.prepare(
|
||||
'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
|
||||
).run(9999, owner.id, tkpNotrip.id, 1);
|
||||
const known = await findRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-notrip', owner: owner.id });
|
||||
const photoId = known?.id ?? (await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-notrip', owner: owner.id }));
|
||||
await insertRow(orm, TripPhotos, { trip: 9999, user: owner.id, photo: photoId, shared: 1 });
|
||||
testDb.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
const res = await request(app)
|
||||
@@ -768,11 +787,7 @@ describe('Immich syncAlbumAssets', () => {
|
||||
expect(res.body.added).toBe(1);
|
||||
|
||||
// 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 tripPhotoAssets(trip.id, user.id);
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos[0].provider).toBe('immich');
|
||||
});
|
||||
@@ -799,11 +814,7 @@ describe('Immich syncAlbumAssets', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.total).toBe(1);
|
||||
|
||||
const rows = testDb.prepare(`
|
||||
SELECT tkp.asset_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ?
|
||||
`).all(trip.id) as any[];
|
||||
const rows = await tripPhotoAssets(trip.id);
|
||||
expect(rows.map((r) => r.asset_id)).toEqual(['visible-still']);
|
||||
});
|
||||
|
||||
@@ -1091,8 +1102,7 @@ describe('Immich saveImmichSettings clearing URL', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.immich_url).toBeNull();
|
||||
expect((await readUser(orm, user.id)).immich_url).toBeNull();
|
||||
});
|
||||
|
||||
it('IMMICH-096 — PUT /settings with empty string URL clears immich_url', async () => {
|
||||
@@ -1107,8 +1117,7 @@ describe('Immich saveImmichSettings clearing URL', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.immich_url).toBeNull();
|
||||
expect((await readUser(orm, user.id)).immich_url).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1164,8 +1173,8 @@ describe('Immich self-signed certificate switch (#2475)', () => {
|
||||
return { ok: true, status: 200, url: '', headers: { get: () => null }, json: async () => json, body: null } as unknown as Response;
|
||||
}
|
||||
|
||||
function allowInsecureTls(userId: number): number {
|
||||
return (testDb.prepare('SELECT immich_allow_insecure_tls AS v FROM users WHERE id = ?').get(userId) as { v: number }).v;
|
||||
async function allowInsecureTls(userId: number): Promise<number> {
|
||||
return (await readUser(orm, userId)).immich_allow_insecure_tls;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1196,20 +1205,20 @@ describe('Immich self-signed certificate switch (#2475)', () => {
|
||||
request(app).put(`${IMMICH}/settings`).set('Cookie', authCookie(user.id)).send(body);
|
||||
|
||||
await put({ immich_url: 'https://immich.example.com', immich_api_key: 'k', allow_insecure_tls: true });
|
||||
expect(allowInsecureTls(user.id)).toBe(1);
|
||||
expect(await allowInsecureTls(user.id)).toBe(1);
|
||||
|
||||
// What a client that predates the switch sends.
|
||||
await put({ immich_url: 'https://immich.example.com', immich_api_key: 'k' });
|
||||
expect(allowInsecureTls(user.id)).toBe(1);
|
||||
expect(await allowInsecureTls(user.id)).toBe(1);
|
||||
|
||||
// A different server is a different trust decision: without the switch it starts off.
|
||||
await put({ immich_url: 'https://photos.example.com', immich_api_key: 'k' });
|
||||
expect(allowInsecureTls(user.id)).toBe(0);
|
||||
expect(await allowInsecureTls(user.id)).toBe(0);
|
||||
await put({ immich_url: 'https://photos.example.com', immich_api_key: 'k', allow_insecure_tls: true });
|
||||
expect(allowInsecureTls(user.id)).toBe(1);
|
||||
expect(await allowInsecureTls(user.id)).toBe(1);
|
||||
|
||||
await put({ immich_url: '', immich_api_key: 'k', allow_insecure_tls: true });
|
||||
expect(allowInsecureTls(user.id)).toBe(0);
|
||||
expect(await allowInsecureTls(user.id)).toBe(0);
|
||||
});
|
||||
|
||||
it('IMMICH-104: browse, search and the thumbnail proxy follow the stored switch', async () => {
|
||||
@@ -1225,13 +1234,13 @@ describe('Immich self-signed certificate switch (#2475)', () => {
|
||||
expect((await request(app).get(`${IMMICH}/assets/${trip.id}/asset-tls/${user.id}/thumbnail`).set('Cookie', cookie)).status).toBe(200);
|
||||
};
|
||||
|
||||
testDb.prepare('UPDATE users SET immich_allow_insecure_tls = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { immich_allow_insecure_tls: 1 });
|
||||
await hitEveryPath();
|
||||
expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3);
|
||||
for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toMatchObject(LAX);
|
||||
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
testDb.prepare('UPDATE users SET immich_allow_insecure_tls = 0 WHERE id = ?').run(user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { immich_allow_insecure_tls: 0 });
|
||||
await hitEveryPath();
|
||||
expect(vi.mocked(safeFetch).mock.calls).toHaveLength(3);
|
||||
for (const call of vi.mocked(safeFetch).mock.calls) expect(call[2]).toMatchObject(STRICT);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createMcpToken, createUser } from '../../../helpers/factories';
|
||||
import { McpTokens } from '../../../../src/db/entities/McpTokens.entity';
|
||||
import type { McpTokensRepository } from '../../../../src/db/repositories/McpTokens.repository';
|
||||
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
|
||||
import { countRows, findRow, updateRows } from '../../../helpers/factories/rows';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
@@ -25,8 +26,9 @@ afterAll(async () => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function rawToken(id: number): unknown {
|
||||
return testDb.prepare('SELECT * FROM mcp_tokens WHERE id = ?').get(id);
|
||||
/** The stored row, read past the shared identity map; undefined when it is gone. */
|
||||
async function storedToken(id: number) {
|
||||
return (await findRow(t, McpTokens, { id })) ?? undefined;
|
||||
}
|
||||
|
||||
describe('McpTokensRepository', () => {
|
||||
@@ -35,9 +37,9 @@ describe('McpTokensRepository', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const older = createMcpToken(testDb, user.id, { name: 'older', kind: 'mcp' });
|
||||
testDb.prepare("UPDATE mcp_tokens SET created_at = '2020-01-01 00:00:00' WHERE id = ?").run(older.id);
|
||||
await updateRows(t, McpTokens, { id: older.id }, { created_at: '2020-01-01 00:00:00' });
|
||||
const newer = createMcpToken(testDb, user.id, { name: 'newer', kind: 'mcp' });
|
||||
testDb.prepare("UPDATE mcp_tokens SET created_at = '2020-01-02 00:00:00' WHERE id = ?").run(newer.id);
|
||||
await updateRows(t, McpTokens, { id: newer.id }, { created_at: '2020-01-02 00:00:00' });
|
||||
createMcpToken(testDb, user.id, { name: 'an api key', kind: 'api' });
|
||||
createMcpToken(testDb, other.id, { name: 'not mine', kind: 'mcp' });
|
||||
|
||||
@@ -56,7 +58,7 @@ describe('McpTokensRepository', () => {
|
||||
it('MCPTOKREPO-002b: created_at NULL comes back null, not undefined (coverage: rule 16)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const token = createMcpToken(testDb, user.id, { name: 'null-created', kind: 'mcp' });
|
||||
testDb.prepare('UPDATE mcp_tokens SET created_at = NULL WHERE id = ?').run(token.id);
|
||||
await updateRows(t, McpTokens, { id: token.id }, { created_at: null });
|
||||
const [row] = await tokens.listByUserAndKind(user.id, 'mcp');
|
||||
expect(row.created_at).toBeNull();
|
||||
});
|
||||
@@ -90,7 +92,7 @@ describe('McpTokensRepository', () => {
|
||||
// else) to a caller that only asked to mint a row. `findBasic` (TK4)
|
||||
// stays the one place a caller reads the row back.
|
||||
expect(Object.keys(inserted)).toEqual(['id']);
|
||||
const row = rawToken(inserted.id) as Record<string, unknown>;
|
||||
const row = (await storedToken(inserted.id)) as Record<string, unknown>;
|
||||
expect(row.user_id).toBe(user.id);
|
||||
expect(row.name).toBe('My Token');
|
||||
expect(row.token_hash).toBe('hash-value');
|
||||
@@ -112,7 +114,7 @@ describe('McpTokensRepository', () => {
|
||||
scope_mode: 'limited',
|
||||
api_scopes: '["stats","trips"]',
|
||||
});
|
||||
const row = rawToken(inserted.id) as { scope_mode: string; api_scopes: string };
|
||||
const row = (await storedToken(inserted.id)) as { scope_mode: string; api_scopes: string };
|
||||
expect(row.scope_mode).toBe('limited');
|
||||
expect(row.api_scopes).toBe('["stats","trips"]');
|
||||
});
|
||||
@@ -176,7 +178,7 @@ describe('McpTokensRepository', () => {
|
||||
it('MCPTOKREPO-007c: created_at NULL comes back null, not undefined (coverage: rule 16)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = createMcpToken(testDb, user.id, { name: 'null-created-basic' });
|
||||
testDb.prepare('UPDATE mcp_tokens SET created_at = NULL WHERE id = ?').run(created.id);
|
||||
await updateRows(t, McpTokens, { id: created.id }, { created_at: null });
|
||||
const row = await tokens.findBasic(created.id);
|
||||
expect(row?.created_at).toBeNull();
|
||||
});
|
||||
@@ -185,7 +187,7 @@ describe('McpTokensRepository', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = createMcpToken(testDb, user.id, { name: 'before' });
|
||||
await tokens.findBasic(created.id); // populate identity map
|
||||
testDb.prepare("UPDATE mcp_tokens SET name = 'after' WHERE id = ?").run(created.id);
|
||||
await updateRows(t, McpTokens, { id: created.id }, { name: 'after' });
|
||||
const row = await tokens.findBasic(created.id);
|
||||
expect(row?.name).toBe('after');
|
||||
});
|
||||
@@ -217,7 +219,7 @@ describe('McpTokensRepository', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = createMcpToken(testDb, user.id);
|
||||
await tokens.deleteById(created.id);
|
||||
expect(rawToken(created.id)).toBeUndefined();
|
||||
expect((await storedToken(created.id))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,9 +228,9 @@ describe('McpTokensRepository', () => {
|
||||
const { user: ada } = createUser(testDb, { username: 'ada' });
|
||||
const { user: bob } = createUser(testDb, { username: 'bob' });
|
||||
const first = createMcpToken(testDb, ada.id, { name: 'first' });
|
||||
testDb.prepare("UPDATE mcp_tokens SET created_at = '2020-01-01 00:00:00' WHERE id = ?").run(first.id);
|
||||
await updateRows(t, McpTokens, { id: first.id }, { created_at: '2020-01-01 00:00:00' });
|
||||
const second = createMcpToken(testDb, bob.id, { name: 'second' });
|
||||
testDb.prepare("UPDATE mcp_tokens SET created_at = '2020-06-01 00:00:00' WHERE id = ?").run(second.id);
|
||||
await updateRows(t, McpTokens, { id: second.id }, { created_at: '2020-06-01 00:00:00' });
|
||||
|
||||
const rows = await tokens.listAllWithUsername();
|
||||
expect(rows.map((r) => r.name)).toEqual(['second', 'first']);
|
||||
@@ -254,7 +256,7 @@ describe('McpTokensRepository', () => {
|
||||
it('MCPTOKREPO-012c: created_at NULL comes back null, not undefined (coverage: rule 16)', async () => {
|
||||
const { user } = createUser(testDb, { username: 'null-created-user' });
|
||||
const created = createMcpToken(testDb, user.id, { name: 'null-created-list' });
|
||||
testDb.prepare('UPDATE mcp_tokens SET created_at = NULL WHERE id = ?').run(created.id);
|
||||
await updateRows(t, McpTokens, { id: created.id }, { created_at: null });
|
||||
const rows = await tokens.listAllWithUsername();
|
||||
expect(rows.find((r) => r.id === created.id)?.created_at).toBeNull();
|
||||
});
|
||||
@@ -349,15 +351,15 @@ describe('McpTokensRepository', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const target = createMcpToken(testDb, user.id, { rawToken: 'trek_touch_me' });
|
||||
const other = createMcpToken(testDb, user.id, { rawToken: 'trek_leave_me' });
|
||||
const before = rawToken(target.id) as { name: string; token_hash: string };
|
||||
const before = (await storedToken(target.id)) as { name: string; token_hash: string };
|
||||
|
||||
await tokens.touchLastUsedByHash(target.tokenHash);
|
||||
|
||||
const after = rawToken(target.id) as Record<string, unknown>;
|
||||
const after = (await storedToken(target.id)) as Record<string, unknown>;
|
||||
expect(after.last_used_at).not.toBeNull();
|
||||
expect(after.name).toBe(before.name);
|
||||
expect(after.token_hash).toBe(before.token_hash);
|
||||
expect((rawToken(other.id) as { last_used_at: string | null }).last_used_at).toBeNull();
|
||||
expect(((await storedToken(other.id)) as { last_used_at: string | null }).last_used_at).toBeNull();
|
||||
});
|
||||
|
||||
it('MCPTOKREPO-019b: an unknown hash touches nothing (no error, no row)', async () => {
|
||||
@@ -375,8 +377,8 @@ describe('McpTokensRepository', () => {
|
||||
|
||||
await tokens.deleteAllForUser(user.id);
|
||||
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(user.id)).toEqual({ c: 0 });
|
||||
expect(rawToken(untouched.id)).toBeDefined();
|
||||
expect(await countRows(t, McpTokens, { user: user.id })).toBe(0);
|
||||
expect((await storedToken(untouched.id))).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -455,7 +457,7 @@ describe('McpTokensRepository', () => {
|
||||
await tokens.touchLastUsedByHash(created.tokenHash);
|
||||
});
|
||||
|
||||
const row = rawToken(created.id) as { last_used_at: string | null; name: string };
|
||||
const row = (await storedToken(created.id)) as { last_used_at: string | null; name: string };
|
||||
expect(row.last_used_at).not.toBeNull();
|
||||
expect(row.name).toBe('stays-original');
|
||||
});
|
||||
@@ -485,8 +487,8 @@ describe('McpTokensRepository', () => {
|
||||
api_scopes: null,
|
||||
});
|
||||
|
||||
expect((rawToken(untouched.id) as { name: string }).name).toBe('do-not-flush-me');
|
||||
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE token_hash = ?').get('insert-no-side-effect-hash')).toBeDefined();
|
||||
expect(((await storedToken(untouched.id)) as { name: string }).name).toBe('do-not-flush-me');
|
||||
expect(await findRow(t, McpTokens, { token_hash: 'insert-no-side-effect-hash' })).not.toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -524,7 +526,7 @@ describe('McpTokensRepository', () => {
|
||||
await t.em.nativeUpdate(McpTokens, { id: created.id }, { scope_mode: 'limited' });
|
||||
});
|
||||
|
||||
const row = rawToken(created.id) as { scope_mode: string; name: string };
|
||||
const row = (await storedToken(created.id)) as { scope_mode: string; name: string };
|
||||
expect(row.scope_mode).toBe('limited');
|
||||
expect(row.name).toBe('stays-original');
|
||||
});
|
||||
|
||||
@@ -27,6 +27,10 @@ import { ReservationsService } from '../../../src/nest/reservations/reservations
|
||||
import type { TransitPlace } from '../../../src/nest/transit/transit.helpers';
|
||||
import { TransitService } from '../../../src/nest/transit/transit.service';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { countRows, findRow } from '../../helpers/factories/rows';
|
||||
import { Days } from '../../../src/db/entities/Days.entity';
|
||||
import { Reservations } from '../../../src/db/entities/Reservations.entity';
|
||||
|
||||
// savePermissions is no longer bridged; write through a service instance — the
|
||||
// permissions cache is module-scoped, so the MCP _shared checkPermission path
|
||||
@@ -117,7 +121,25 @@ beforeEach(async () => {
|
||||
await invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
let orm: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(testDb);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
/** The trip's day, on the given date or the first one; fails the test when there is none. */
|
||||
async function dayOf(tripId: number, date?: string) {
|
||||
const day = await findRow(orm, Days, date === undefined ? { trip: tripId } : { trip: tripId, date });
|
||||
if (!day) throw new Error(`no day for trip ${tripId}`);
|
||||
return day;
|
||||
}
|
||||
|
||||
const transitCount = () => countRows(orm, Reservations, { type: 'transit' });
|
||||
|
||||
async function withHarness(userId: number, scopes: string[] | null, fn: (harness: McpHarness) => Promise<void>) {
|
||||
const harness = await createMcpHarness({ userId, scopes, withResources: false });
|
||||
@@ -197,7 +219,7 @@ describe('MCP transit tools', () => {
|
||||
it('persists a selected itinerary with local dates, endpoints, and transit metadata', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
|
||||
const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2026-12-03') as any;
|
||||
const day = await dayOf(trip.id, '2026-12-03');
|
||||
await withHarness(user.id, ['reservations:write'], async (harness) => {
|
||||
const result = parseToolResult(
|
||||
await harness.client.callTool({
|
||||
@@ -232,9 +254,7 @@ describe('MCP transit tools', () => {
|
||||
const datelessTrip = createTrip(testDb, user.id);
|
||||
const datelessDay = createDay(testDb, datelessTrip.id);
|
||||
const datedTrip = createTrip(testDb, user.id, { start_date: '2026-12-02', end_date: '2026-12-03' });
|
||||
const datedDay = testDb
|
||||
.prepare('SELECT * FROM days WHERE trip_id = ? AND date = ?')
|
||||
.get(datedTrip.id, '2026-12-02') as any;
|
||||
const datedDay = await dayOf(datedTrip.id, '2026-12-02');
|
||||
await withHarness(user.id, ['reservations:write'], async (harness) => {
|
||||
const dateless = await harness.client.callTool({
|
||||
name: 'create_transit_journey',
|
||||
@@ -259,9 +279,7 @@ describe('MCP transit tools', () => {
|
||||
: leg,
|
||||
),
|
||||
};
|
||||
const startDay = testDb
|
||||
.prepare('SELECT * FROM days WHERE trip_id = ? AND date = ?')
|
||||
.get(datedTrip.id, '2026-12-03') as any;
|
||||
const startDay = await dayOf(datedTrip.id, '2026-12-03');
|
||||
const outside = await harness.client.callTool({
|
||||
name: 'create_transit_journey',
|
||||
arguments: { tripId: datedTrip.id, dayId: startDay.id, from, to, itinerary: nextDayItinerary },
|
||||
@@ -274,7 +292,7 @@ describe('MCP transit tools', () => {
|
||||
it('rejects malformed provider data before persistence', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-03' });
|
||||
const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any;
|
||||
const day = await dayOf(trip.id);
|
||||
const allWalk = { ...itinerary, legs: [itinerary.legs[0]] };
|
||||
await withHarness(user.id, ['reservations:write'], async (harness) => {
|
||||
const result = await harness.client.callTool({
|
||||
@@ -282,9 +300,7 @@ describe('MCP transit tools', () => {
|
||||
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary: allWalk },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
expect(await transitCount()).toBe(0);
|
||||
|
||||
const wrongDestination = {
|
||||
...itinerary,
|
||||
@@ -298,9 +314,7 @@ describe('MCP transit tools', () => {
|
||||
});
|
||||
expect(mismatch.isError).toBe(true);
|
||||
expect((mismatch.content[0] as any).text).toContain('does not match');
|
||||
expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
expect(await transitCount()).toBe(0);
|
||||
|
||||
const invalidTime = {
|
||||
...itinerary,
|
||||
@@ -390,9 +404,7 @@ describe('MCP transit tools', () => {
|
||||
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary: disconnected },
|
||||
});
|
||||
expect(disconnectedResult.isError).toBe(true);
|
||||
expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
expect(await transitCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -402,7 +414,7 @@ describe('MCP transit tools', () => {
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { start_date: '2026-12-03', end_date: '2026-12-03' });
|
||||
const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any;
|
||||
const day = await dayOf(trip.id);
|
||||
addTripMember(testDb, trip.id, demo.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
@@ -436,9 +448,7 @@ describe('MCP transit tools', () => {
|
||||
expect((result.content[0] as any).text).toContain('permission');
|
||||
});
|
||||
|
||||
expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({
|
||||
count: 0,
|
||||
});
|
||||
expect(await transitCount()).toBe(0);
|
||||
expect(broadcastMock).not.toHaveBeenCalled();
|
||||
expect(notifyBookingChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -446,7 +456,7 @@ describe('MCP transit tools', () => {
|
||||
it('uses scheduled times when realtime stop times are unavailable', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-03' });
|
||||
const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any;
|
||||
const day = await dayOf(trip.id);
|
||||
const scheduledOnly = {
|
||||
...itinerary,
|
||||
legs: itinerary.legs.map((leg) => ({
|
||||
@@ -476,7 +486,7 @@ describe('MCP transit tools', () => {
|
||||
it('accepts provider leg modes outside the requestable mode whitelist', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-03' });
|
||||
const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any;
|
||||
const day = await dayOf(trip.id);
|
||||
const flying = {
|
||||
...itinerary,
|
||||
legs: itinerary.legs.map((leg) => (leg.mode === 'WALK' ? leg : { ...leg, mode: 'AIRPLANE' })),
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
} from '../../../../src/nest/storage/storage-paths';
|
||||
import { STORAGE_CATEGORIES } from '../../../../src/nest/storage/storage.types';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../../helpers/test-uow';
|
||||
import { deleteRows, findRow } from '../../../helpers/factories/rows';
|
||||
import { setAppSetting } from '../../../helpers/factories/settings';
|
||||
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -53,8 +56,13 @@ function makeEnvStub(initial: EnvPaths): { env: { placePhotoDir: string | undefi
|
||||
return { env: { placePhotoDir: initial.placePhotoDir } };
|
||||
}
|
||||
|
||||
function setSetting(key: string, value: string): void {
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
|
||||
async function setSetting(key: string, value: string): Promise<void> {
|
||||
await setAppSetting(await sharedTestOrm(testDb), key, value);
|
||||
}
|
||||
|
||||
/** The stored value of an app setting, undefined when there is no row. */
|
||||
async function storedSetting(key: string): Promise<string | undefined> {
|
||||
return (await findRow(await sharedTestOrm(testDb), AppSettings, { key }))?.value ?? undefined;
|
||||
}
|
||||
|
||||
function uploadsOverride(root: string): unknown {
|
||||
@@ -78,8 +86,8 @@ interface RegistryOpts {
|
||||
*/
|
||||
async function makeRegistry(opts: RegistryOpts = {}) {
|
||||
const uploadsRoot = opts.uploadsRoot ?? makeTmpDir();
|
||||
setSetting('storage.backends', JSON.stringify([uploadsOverride(uploadsRoot), ...(opts.backends ?? [])]));
|
||||
if (opts.categories) setSetting('storage.categories', JSON.stringify(opts.categories));
|
||||
await setSetting('storage.backends', JSON.stringify([uploadsOverride(uploadsRoot), ...(opts.backends ?? [])]));
|
||||
if (opts.categories) await setSetting('storage.categories', JSON.stringify(opts.categories));
|
||||
const stub = makeEnvStub({ placePhotoDir: opts.placePhotoDir });
|
||||
const registry = new StorageRegistryService(
|
||||
await createTestAppSettingsRepo(testDb),
|
||||
@@ -93,17 +101,15 @@ async function makeRegistry(opts: RegistryOpts = {}) {
|
||||
}
|
||||
|
||||
/** For reload tests: swap the override row's root in place. */
|
||||
function rewriteUploadsOverride(root: string): void {
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.backends'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
const entries = row?.value ? (JSON.parse(row.value) as Array<{ name: string; options: { root: string } }>) : [];
|
||||
async function rewriteUploadsOverride(root: string): Promise<void> {
|
||||
const value = await storedSetting('storage.backends');
|
||||
const entries = value ? (JSON.parse(value) as Array<{ name: string; options: { root: string } }>) : [];
|
||||
const next = entries.map((e) => (e.name === 'uploads-local' ? { ...e, options: { root } } : e));
|
||||
setSetting('storage.backends', JSON.stringify(next));
|
||||
await setSetting('storage.backends', JSON.stringify(next));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'storage.%'").run();
|
||||
beforeEach(async () => {
|
||||
await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -165,7 +171,7 @@ describe('StorageRegistryService defaults', () => {
|
||||
|
||||
it('roots uploads-local at the computed default when no override row exists', async () => {
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'storage.backends'").run();
|
||||
await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: 'storage.backends' });
|
||||
await registry.onModuleInit();
|
||||
const files = registry.resolve('files');
|
||||
expect(files.backendName).toBe('uploads-local');
|
||||
@@ -316,8 +322,8 @@ describe('StorageRegistryService settings', () => {
|
||||
['malformed JSON', 'not json at all', undefined],
|
||||
])('falls back to built-in defaults at boot on invalid settings: %s', async (_label, backendsRow, categoriesRow) => {
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
if (backendsRow !== undefined) setSetting('storage.backends', backendsRow);
|
||||
if (categoriesRow !== undefined) setSetting('storage.categories', categoriesRow);
|
||||
if (backendsRow !== undefined) await setSetting('storage.backends', backendsRow);
|
||||
if (categoriesRow !== undefined) await setSetting('storage.categories', categoriesRow);
|
||||
await registry.onModuleInit();
|
||||
|
||||
expect(registry.resolve('files').backendName).toBe('uploads-local');
|
||||
@@ -337,8 +343,8 @@ describe('StorageRegistryService settings', () => {
|
||||
async (_label, backendsRow, categoriesRow) => {
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
if (backendsRow !== undefined) setSetting('storage.backends', backendsRow);
|
||||
if (categoriesRow !== undefined) setSetting('storage.categories', categoriesRow);
|
||||
if (backendsRow !== undefined) await setSetting('storage.backends', backendsRow);
|
||||
if (categoriesRow !== undefined) await setSetting('storage.categories', categoriesRow);
|
||||
await registry.onModuleInit();
|
||||
|
||||
expect(registry.lastLoadError()).not.toBeNull();
|
||||
@@ -357,7 +363,7 @@ describe('StorageRegistryService settings', () => {
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
const secretToken = 'AKIA_SUPER_SECRET_TOKEN_1234567890';
|
||||
setSetting('storage.backends', `{"secretAccessKey": ${secretToken}_undefined_broken}`);
|
||||
await setSetting('storage.backends', `{"secretAccessKey": ${secretToken}_undefined_broken}`);
|
||||
await registry.onModuleInit();
|
||||
|
||||
const err = registry.lastLoadError();
|
||||
@@ -370,11 +376,11 @@ describe('StorageRegistryService settings', () => {
|
||||
it('lastLoadError() clears on the next successful load/reload', async () => {
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
setSetting('storage.categories', 'garbage {');
|
||||
await setSetting('storage.categories', 'garbage {');
|
||||
await registry.onModuleInit();
|
||||
expect(registry.lastLoadError()).not.toBeNull();
|
||||
|
||||
setSetting('storage.categories', '{}');
|
||||
await setSetting('storage.categories', '{}');
|
||||
await registry.reload();
|
||||
expect(registry.lastLoadError()).toBeNull();
|
||||
});
|
||||
@@ -390,7 +396,7 @@ describe('StorageRegistryService settings', () => {
|
||||
});
|
||||
expect(registry.resolve('backups').backendName).toBe('backup-mirror');
|
||||
|
||||
setSetting('storage.categories', 'garbage {');
|
||||
await setSetting('storage.categories', 'garbage {');
|
||||
await registry.reload();
|
||||
|
||||
// last-good, i.e. the mirror config — NOT the built-in defaults
|
||||
@@ -408,7 +414,7 @@ describe('StorageRegistryService reload', () => {
|
||||
const before = registry.resolve('files');
|
||||
await before.driver.put('files/pre-reload.bin', Readable.from('old root'));
|
||||
|
||||
setUploadsRoot(makeTmpDir());
|
||||
await setUploadsRoot(makeTmpDir());
|
||||
await registry.reload();
|
||||
|
||||
const after = registry.resolve('files');
|
||||
@@ -446,17 +452,13 @@ describe('StorageRegistryService reload', () => {
|
||||
describe('StorageRegistryService assignCategory', () => {
|
||||
it('REG-ASSIGN-001 throws on an unknown backend and persists nothing (belt-and-braces alongside the migration job\'s own guard)', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
const before = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
const before = (await findRow(await sharedTestOrm(testDb), AppSettings, { key: 'storage.categories' }));
|
||||
|
||||
await expect(registry.assignCategory('files', 'ghost-backend')).rejects.toThrow(
|
||||
"cannot assign 'files' to unknown backend 'ghost-backend'",
|
||||
);
|
||||
|
||||
const after = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
const after = (await findRow(await sharedTestOrm(testDb), AppSettings, { key: 'storage.categories' }));
|
||||
expect(after).toEqual(before); // no write happened
|
||||
expect(registry.snapshot().categories.files.backend).toBe('uploads-local'); // unchanged
|
||||
});
|
||||
@@ -467,10 +469,8 @@ describe('StorageRegistryService assignCategory', () => {
|
||||
await registry.assignCategory('files', 'dest-local');
|
||||
|
||||
expect(registry.snapshot().categories.files.backend).toBe('dest-local');
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as {
|
||||
value: string;
|
||||
};
|
||||
expect((JSON.parse(row.value) as Record<string, string>).files).toBe('dest-local');
|
||||
const row = (await findRow(await sharedTestOrm(testDb), AppSettings, { key: 'storage.categories' }));
|
||||
expect((JSON.parse(String(row?.value)) as Record<string, string>).files).toBe('dest-local');
|
||||
});
|
||||
|
||||
it('REG-ASSIGN-003 bumps the shared optimistic-concurrency version counter by exactly one, in the same write (audit #7)', async () => {
|
||||
@@ -483,9 +483,7 @@ describe('StorageRegistryService assignCategory', () => {
|
||||
await registry.assignCategory('journey', 'dest-local');
|
||||
expect(await registry.currentConfigVersion()).toBe(2);
|
||||
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.config_version'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
const row = (await findRow(await sharedTestOrm(testDb), AppSettings, { key: 'storage.config_version' }));
|
||||
expect(row?.value).toBe('2');
|
||||
});
|
||||
|
||||
@@ -565,10 +563,8 @@ describe('StorageRegistryService assignCategory', () => {
|
||||
// not by this call) is byte-unchanged — no 'files' key was added.
|
||||
expect(registry.snapshot().categories.files.backend).toBe('uploads-local');
|
||||
expect(await registry.currentConfigVersion()).toBe(0);
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as {
|
||||
value: string;
|
||||
};
|
||||
expect(JSON.parse(row.value)).toEqual({ backups: 'm-backups' });
|
||||
const row = (await findRow(await sharedTestOrm(testDb), AppSettings, { key: 'storage.categories' }));
|
||||
expect(JSON.parse(String(row?.value))).toEqual({ backups: 'm-backups' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -824,11 +820,8 @@ describe('seed-once storage-config.json import', () => {
|
||||
);
|
||||
}
|
||||
|
||||
function readRow(key: string): string | undefined {
|
||||
const row = testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return row?.value;
|
||||
function readRow(key: string): Promise<string | undefined> {
|
||||
return storedSetting(key);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -865,13 +858,13 @@ describe('seed-once storage-config.json import', () => {
|
||||
await registry.onModuleInit();
|
||||
|
||||
expect(registry.resolve('backups').driver).toBeInstanceOf(S3Driver);
|
||||
const storedBackends = JSON.parse(readRow('storage.backends')!) as Array<{
|
||||
const storedBackends = JSON.parse((await readRow('storage.backends'))!) as Array<{
|
||||
name: string;
|
||||
options: Record<string, unknown>;
|
||||
}>;
|
||||
const offBox = storedBackends.find((b) => b.name === 'off-box')!;
|
||||
expect(String(offBox.options.secretAccessKey).startsWith('enc:v1:')).toBe(true); // never plaintext at rest
|
||||
expect(readRow('storage.categories')).toBe(JSON.stringify({ backups: 'off-box' }));
|
||||
expect((await readRow('storage.categories'))).toBe(JSON.stringify({ backups: 'off-box' }));
|
||||
const logged = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(logged).toContain('storage config seeded from');
|
||||
expect(logged).toContain('the file is now ignored; manage storage in the admin UI');
|
||||
@@ -890,7 +883,7 @@ describe('seed-once storage-config.json import', () => {
|
||||
const registry = await makeUnseededRegistry();
|
||||
await registry.onModuleInit();
|
||||
expect(registry.resolve('backups').backendName).toBe('backups-local');
|
||||
expect(readRow('storage.backends')).toBeUndefined();
|
||||
expect((await readRow('storage.backends'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('SEED-004 unparseable JSON aborts boot with the exact error', async () => {
|
||||
@@ -933,7 +926,7 @@ describe('seed-once storage-config.json import', () => {
|
||||
await registry.onModuleInit();
|
||||
// No key-presence gate: the implicit key covers encryption when
|
||||
// ENCRYPTION_KEY is unset, and the plaintext never persists.
|
||||
const row = readRow('storage.backends')!;
|
||||
const row = (await readRow('storage.backends'))!;
|
||||
expect(row).not.toContain('sk-seed');
|
||||
expect(row).toContain('enc:v1:');
|
||||
});
|
||||
@@ -963,12 +956,12 @@ describe('seed-once storage-config.json import', () => {
|
||||
const root = nasRoot();
|
||||
writeSeed(JSON.stringify({ backends: [{ name: 'nas', type: 'local', options: { root } }], categories: { backups: 'nas' } }));
|
||||
await (await makeUnseededRegistry()).onModuleInit();
|
||||
const firstRow = readRow('storage.backends');
|
||||
const firstRow = (await readRow('storage.backends'));
|
||||
|
||||
writeSeed(JSON.stringify({ backends: [], categories: { backups: 'other' } })); // would fail preview if read
|
||||
const second = await makeUnseededRegistry();
|
||||
await second.onModuleInit(); // must not throw — rows exist, file ignored
|
||||
expect(readRow('storage.backends')).toBe(firstRow);
|
||||
expect((await readRow('storage.backends'))).toBe(firstRow);
|
||||
expect(second.resolve('backups').backendName).toBe('nas');
|
||||
});
|
||||
|
||||
@@ -996,7 +989,7 @@ describe('seed-once storage-config.json import', () => {
|
||||
// enc:v1: values pass through the idempotent encrypt — the ciphertext
|
||||
// must persist byte-for-byte.
|
||||
expect(registry.resolve('backups').driver).toBeInstanceOf(S3Driver);
|
||||
expect(readRow('storage.backends')).toContain(cipher);
|
||||
expect((await readRow('storage.backends'))).toContain(cipher);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1028,7 +1021,7 @@ describe('snapshot()', () => {
|
||||
|
||||
it('reports pure built-in defaults when no settings rows exist', async () => {
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'storage.backends'").run();
|
||||
await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: 'storage.backends' });
|
||||
await registry.onModuleInit();
|
||||
const snap = registry.snapshot();
|
||||
expect(snap.backends.map((b) => [b.name, b.source])).toEqual([
|
||||
@@ -1063,7 +1056,7 @@ describe('snapshot()', () => {
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const { registry } = await makeRegistry();
|
||||
const before = registry.snapshot();
|
||||
setSetting('storage.categories', 'garbage {');
|
||||
await setSetting('storage.categories', 'garbage {');
|
||||
await registry.reload();
|
||||
expect(registry.snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user