mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(server): seed the small specs through the repositories
The suites with one to three raw statements left now seed and read their rows through the ORM factories instead of hand-written SQLite on the better-sqlite3 handle. A legacy statement that serves as a parity oracle, the schema introspection and the bare backup file keep their raw SQL and say why with a test-sql-allow marker. Two database mocks still answered a getPlaceWithTags the module no longer exports; that dead branch goes too, and the budget seam suite stubs the reservations repository directly instead of a fake prepare().
This commit is contained in:
@@ -47,7 +47,11 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi
|
||||
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 } from '../helpers/test-orm';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { makeTrip } from '../helpers/factories/trips';
|
||||
import { upsertRow } from '../helpers/factories/rows';
|
||||
import { Addons } from '../../src/db/entities/Addons.entity';
|
||||
|
||||
/** A one-page PDF whose text layer holds `lines`, in the standard Helvetica. */
|
||||
function pdfWithText(lines: string[]): Buffer {
|
||||
@@ -134,15 +138,23 @@ describe('Booking import e2e (#2477): a schema-bound provider on the upload rout
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
|
||||
).run();
|
||||
tripId = Number(db.prepare("INSERT INTO trips (user_id, title) VALUES (1, 'Albania')").run().lastInsertRowid);
|
||||
const orm = await createTestOrm(db);
|
||||
await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 });
|
||||
tripId = (await makeTrip(orm, 1, { title: 'Albania' })).id;
|
||||
// The instance config the reporter ran: Gemini behind the "openai" provider.
|
||||
db.prepare(
|
||||
`INSERT INTO addons (id, name, type, enabled, config) VALUES ('llm_parsing', 'AI Parsing', 'integration', 1, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET enabled = 1, config = excluded.config`,
|
||||
).run(JSON.stringify({ provider: 'openai', model: 'gemini-3.5-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' }));
|
||||
await upsertRow(
|
||||
orm,
|
||||
Addons,
|
||||
{
|
||||
id: 'llm_parsing',
|
||||
name: 'AI Parsing',
|
||||
type: 'integration',
|
||||
enabled: true,
|
||||
config: { provider: 'openai', model: 'gemini-3.5-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
||||
},
|
||||
['enabled', 'config'],
|
||||
);
|
||||
await orm.close();
|
||||
app = await build();
|
||||
vi.spyOn(app.get(PermissionsService), 'checkPermission').mockResolvedValue(true);
|
||||
server = app.getHttpServer();
|
||||
|
||||
@@ -58,7 +58,8 @@ import { db } from '../../src/db/database';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { AuthModule } from '../../src/nest/auth/auth.module';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
||||
import { createTestMikroOrmModule, createTestOrm } from '../helpers/test-orm';
|
||||
import { setAppSetting } from '../helpers/factories/settings';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
|
||||
@@ -105,10 +106,11 @@ describe('Mailer e2e: the header logo in a mail on the wire (#2507)', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
email = createUser(db as never, { username: 'mail-e2e', email: 'mail-e2e@example.test' }).user.email;
|
||||
const setting = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
|
||||
setting.run('smtp_host', 'mail.internal.example');
|
||||
setting.run('smtp_port', '587');
|
||||
setting.run('smtp_from', 'trek@example.test');
|
||||
const orm = await createTestOrm(db);
|
||||
await setAppSetting(orm, 'smtp_host', 'mail.internal.example');
|
||||
await setAppSetting(orm, 'smtp_port', '587');
|
||||
await setAppSetting(orm, 'smtp_from', 'trek@example.test');
|
||||
await orm.close();
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
|
||||
|
||||
@@ -37,7 +37,11 @@ import { RateLimitService } from '../../src/nest/common/rate-limit.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 { deleteRows } from '../helpers/factories/rows';
|
||||
import { setAppSetting } from '../helpers/factories/settings';
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
import { PlaceDetailsCache } from '../../src/db/entities/PlaceDetailsCache.entity';
|
||||
|
||||
const BODY = { lat: 50.9, lng: 6.96, name: 'Museum Ludwig', placeId: 'way:12345' };
|
||||
|
||||
@@ -45,6 +49,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
let maps: MapsService;
|
||||
let orm: TestOrm;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), PlaceEnrichmentModule] }).compile();
|
||||
@@ -60,6 +65,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
orm = await createTestOrm(db);
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
|
||||
@@ -75,11 +81,12 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
db.prepare('DELETE FROM app_settings').run();
|
||||
db.prepare('DELETE FROM place_details_cache').run();
|
||||
await deleteRows(orm, AppSettings);
|
||||
await deleteRows(orm, PlaceDetailsCache);
|
||||
await app.get(RateLimitService).reset('place_enrichment');
|
||||
// spyOn hands back the same spy on a second call, so the call counts carry
|
||||
// over between tests unless they are cleared explicitly.
|
||||
@@ -159,7 +166,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () =>
|
||||
});
|
||||
|
||||
it('200 with the disabled envelope once an admin switches enrichment off', async () => {
|
||||
db.prepare("INSERT INTO app_settings (key, value) VALUES ('places_enrich_enabled', 'false')").run();
|
||||
await setAppSetting(orm, 'places_enrich_enabled', 'false');
|
||||
|
||||
const res = await request(server).post('/api/maps/enrichment').set('Cookie', sessionCookie(1)).send(BODY);
|
||||
|
||||
|
||||
@@ -42,7 +42,9 @@ vi.mock('../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => plu
|
||||
|
||||
import { db } from '../../src/db/database';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
||||
import { createTestMikroOrmModule, createTestOrm } from '../helpers/test-orm';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { makePlugin } from '../helpers/factories/plugins';
|
||||
|
||||
const trailheads = { id: 'trailheads', label: 'Trailheads', icon: 'Signpost', color: '#2f855a' };
|
||||
const query = { pluginId: 'trail-finder', category: 'trailheads', south: '47', west: '11', north: '47.5', east: '11.5', lang: 'de' };
|
||||
@@ -66,14 +68,16 @@ describe('Plugin POIs e2e (real guard chain + temp SQLite)', () => {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// harness.ts's seedUser() omits password_hash, which the migrated schema
|
||||
// requires NOT NULL (addons.e2e.test.ts does the same) — the same SeededUser
|
||||
// shape sessionCookie(1) needs, with the hash filled in.
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
|
||||
).run();
|
||||
db.prepare("INSERT INTO plugins (id, name, status, capabilities, granted_permissions) VALUES (?, ?, 'active', ?, ?)")
|
||||
.run('trail-finder', 'Trail Finder', JSON.stringify({ poiCategories: [trailheads] }), JSON.stringify(['hook:poi-category-provider']));
|
||||
// The user sessionCookie(1) is signed for, pinned to id 1.
|
||||
const orm = await createTestOrm(db);
|
||||
await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test', role: 'user', password_version: 0 });
|
||||
await makePlugin(orm, 'trail-finder', {
|
||||
name: 'Trail Finder',
|
||||
status: 'active',
|
||||
capabilities: JSON.stringify({ poiCategories: [trailheads] }),
|
||||
granted_permissions: JSON.stringify(['hook:poi-category-provider']),
|
||||
});
|
||||
await orm.close();
|
||||
app = await build();
|
||||
const hooks = app.get(PluginHooks, { strict: false });
|
||||
providersOf = vi.spyOn(hooks, 'providersOf');
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
@@ -103,6 +104,7 @@ import { db as testDb } from '../../src/db/database';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../helpers/factories';
|
||||
import { generateToken } from '../helpers/auth';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
|
||||
describe('R1 async-closure truthiness guards (RB2/RS21/RS23) — driven through the real routes', () => {
|
||||
let app: INestApplication;
|
||||
@@ -111,7 +113,7 @@ describe('R1 async-closure truthiness guards (RB2/RS21/RS23) — driven through
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
httpApp = app.getHttpAdapter().getInstance() as Application;
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'roadtrip'").run();
|
||||
await setAddonEnabled(app.get(MikroORM), 'roadtrip', true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -53,9 +53,15 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createPlace } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { PlaceRegionsRepository } from '../../src/db/repositories/PlaceRegions.repository';
|
||||
import { PlaceRegions } from '../../src/db/entities/PlaceRegions.entity';
|
||||
import { findRow, findRows } from '../helpers/factories/rows';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
/** The app's own ORM, for reading back what the background geocode wrote. */
|
||||
const orm = (): FactoryOrm => nestApp.get(MikroORM);
|
||||
const errors: unknown[] = [];
|
||||
beforeAll(async () => {
|
||||
const orig = PlaceRegionsRepository.prototype.upsertRegionWhileUnmoved;
|
||||
@@ -83,10 +89,10 @@ describe('AT4/AT29 background geocode request context', () => {
|
||||
const p = createPlace(testDb, trip.id, { lat: 48.85, lng: 2.35 });
|
||||
const res = await request(app).get('/api/addons/atlas/stats').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(testDb.prepare('SELECT * FROM place_regions WHERE place_id=?').get(p.id)).toBeUndefined();
|
||||
expect(await findRow(orm(), PlaceRegions, { place: p.id })).toBeNull();
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
expect(errors).toEqual([]);
|
||||
expect(testDb.prepare('SELECT * FROM place_regions WHERE place_id=?').get(p.id)).toMatchObject({ country_code: 'FR', region_code: 'FR-IDF' });
|
||||
expect(await findRow(orm(), PlaceRegions, { place: p.id })).toMatchObject({ country_code: 'FR', region_code: 'FR-IDF' });
|
||||
}, 15_000);
|
||||
|
||||
it('IIFE-CTX-AT29 — /regions IIFE writes two places sequentially (2nd after ~2.4s)', async () => {
|
||||
@@ -99,7 +105,10 @@ describe('AT4/AT29 background geocode request context', () => {
|
||||
expect(res.status).toBe(200);
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
expect(errors).toEqual([]);
|
||||
const rows = testDb.prepare('SELECT place_id, region_code FROM place_regions ORDER BY place_id').all();
|
||||
const rows = (await findRows(orm(), PlaceRegions, {}, { place: 'asc' })).map((r) => ({
|
||||
place_id: r.place_id,
|
||||
region_code: r.region_code,
|
||||
}));
|
||||
expect(rows).toEqual([
|
||||
{ place_id: p1.id, region_code: 'FR-IDF' },
|
||||
{ place_id: p2.id, region_code: 'FR-PAC' },
|
||||
|
||||
@@ -31,12 +31,14 @@
|
||||
* callback it schedules (`() => config.getLink(linkId)`) is what needs the
|
||||
* fork, and it only runs once the timer fires, inside `schedule()`'s own
|
||||
* `withRequestContext` — exactly the wrap under test. The link id comes from
|
||||
* a raw `testDb` read (no ORM, no context needed) rather than through
|
||||
* `nudge()`/`getLinkByToken`, so nothing before the `schedule()` call ever
|
||||
* touches the EntityManager.
|
||||
* the fixture `beforeAll` seeds, whose factory calls each open and close a
|
||||
* request context of their own, rather than through
|
||||
* `nudge()`/`getLinkByToken`, so nothing in the test body before the
|
||||
* `schedule()` call ever touches the EntityManager.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
@@ -56,6 +58,11 @@ vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.
|
||||
import { db as testDb } from '../../src/db/database';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createUser, createTrip } from '../helpers/factories';
|
||||
import { insertRow, updateRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { DocumentConnections } from '../../src/db/entities/DocumentConnections.entity';
|
||||
import { DocumentProviders } from '../../src/db/entities/DocumentProviders.entity';
|
||||
import { TripDocumentLinks } from '../../src/db/entities/TripDocumentLinks.entity';
|
||||
import { WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from '../../src/nest/doc-sync/doc-sync.constants';
|
||||
import { DocSyncWebhookController } from '../../src/nest/doc-sync/doc-sync-webhook.controller';
|
||||
import { DocSyncConfigService } from '../../src/nest/doc-sync/doc-sync-config.service';
|
||||
@@ -74,28 +81,35 @@ describe('DocSyncWebhookController#schedule() runs its detached timer body insid
|
||||
// The addon gate and the provider both have to be genuinely on: schedule()'s
|
||||
// own reload callback (`syncIsOn`) skips the run otherwise, before ever
|
||||
// reaching the assertion below.
|
||||
testDb.exec("UPDATE addons SET enabled = 1 WHERE id = 'documents'");
|
||||
testDb.exec("UPDATE document_providers SET enabled = 1 WHERE id = 'paperless'");
|
||||
const orm = app.get(MikroORM);
|
||||
await setAddonEnabled(orm, 'documents', true);
|
||||
await updateRows(orm, DocumentProviders, { id: 'paperless' }, { enabled: 1 });
|
||||
|
||||
const owner = createUser(testDb, { username: 'docsync-webhook-ctx', email: 'docsync-webhook-ctx@test.local' }).user;
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Japan' });
|
||||
const connInfo = testDb
|
||||
.prepare(
|
||||
`INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings)
|
||||
VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`,
|
||||
)
|
||||
.run(trip.id, owner.id);
|
||||
const linkInfo = testDb
|
||||
.prepare(
|
||||
`INSERT INTO trip_document_links
|
||||
(trip_id, connection_id, provider_id, remote_scope_key, remote_label, direction, delete_policy,
|
||||
conflict_policy, sync_enabled, webhook_token, created_by)
|
||||
VALUES (?, ?, 'paperless', 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, 'webhook-ctx-ratchet-token', ?)`,
|
||||
)
|
||||
.run(trip.id, connInfo.lastInsertRowid, owner.id);
|
||||
// A plain better-sqlite3 read — no ORM, no request context — so nothing
|
||||
// before the `schedule()` call below ever touches the EntityManager.
|
||||
linkId = Number(linkInfo.lastInsertRowid);
|
||||
const connectionId = await insertRow(orm, DocumentConnections, {
|
||||
trip: trip.id,
|
||||
provider: 'paperless',
|
||||
ownerUser: owner.id,
|
||||
base_url: 'https://paperless.example.com',
|
||||
secrets: null,
|
||||
settings: '{}',
|
||||
});
|
||||
// Each factory call above closed its own request context before returning,
|
||||
// so the test body below starts with no EntityManager fork around it.
|
||||
linkId = await insertRow(orm, TripDocumentLinks, {
|
||||
trip: trip.id,
|
||||
connection: connectionId,
|
||||
provider_id: 'paperless',
|
||||
remote_scope_key: 'tag:1',
|
||||
remote_label: 'Japan',
|
||||
direction: 'both',
|
||||
delete_policy: 'unlink',
|
||||
conflict_policy: 'manual',
|
||||
sync_enabled: 1,
|
||||
webhook_token: 'webhook-ctx-ratchet-token',
|
||||
createdByRef: owner.id,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -67,7 +67,9 @@ import { db as testDb } from '../../src/db/database';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { CalendarService } from '../../src/nest/calendar/calendar.service';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
import { createTrip, createUser } from '../helpers/factories';
|
||||
import { updateRows } from '../helpers/factories/rows';
|
||||
|
||||
describe('Anonymous ICS feed routes run inside a request context', () => {
|
||||
let app: INestApplication;
|
||||
@@ -86,7 +88,7 @@ describe('Anonymous ICS feed routes run inside a request context', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Context-Proof Trip' });
|
||||
const token = randomUUID();
|
||||
testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, trip.id);
|
||||
await updateRows(app.get(MikroORM), Trips, { id: trip.id }, { feed_token: token });
|
||||
|
||||
let repoRead: unknown;
|
||||
let caught: unknown;
|
||||
@@ -136,7 +138,7 @@ describe('Anonymous ICS feed routes run inside a request context', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Mutation-Proof Trip' });
|
||||
const token = randomUUID();
|
||||
testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, trip.id);
|
||||
await updateRows(app.get(MikroORM), Trips, { id: trip.id }, { feed_token: token });
|
||||
|
||||
const calendar = app.get(CalendarService);
|
||||
const spy = vi.spyOn(calendar, 'buildTripCalendar').mockImplementation(async () => {
|
||||
@@ -184,7 +186,7 @@ describe('Anonymous ICS feed routes run inside a request context', () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'User-Feed Context-Proof Trip' });
|
||||
const token = randomUUID();
|
||||
testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, user.id);
|
||||
await updateRows(app.get(MikroORM), Users, { id: user.id }, { feed_token: token });
|
||||
|
||||
let repoRead: unknown;
|
||||
let caught: unknown;
|
||||
|
||||
@@ -58,6 +58,8 @@ import { PluginRuntimeService } from '../../src/nest/plugins/plugin-runtime.serv
|
||||
import { PlaceShadowRetentionJob } from '../../src/nest/place-shadow/place-shadow.job';
|
||||
import { StorageService } from '../../src/nest/storage/storage.service';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
import { PlaceShadowPicks } from '../../src/db/entities/PlaceShadowPicks.entity';
|
||||
import { countRows, insertRow } from '../helpers/factories/rows';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { generateToken } from '../helpers/auth';
|
||||
|
||||
@@ -161,10 +163,18 @@ describe('ORM request-context seams populated in production', () => {
|
||||
const isEnabledSpy = vi.spyOn(registrar, 'isEnabled').mockReturnValue(true);
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
testDb.prepare(`
|
||||
INSERT INTO place_shadow_picks (created_at, query, source, live_rank, live_count, picked_name, picked_lat, picked_lng)
|
||||
VALUES (datetime('now', '-999 days'), 'seam-003', 'nominatim', 1, 1, 'expired pick', 0, 0)
|
||||
`).run();
|
||||
// 999 days old, in the `datetime('now')` text form the column defaults to.
|
||||
const expiredAt = new Date(Date.now() - 999 * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
|
||||
await insertRow(orm, PlaceShadowPicks, {
|
||||
created_at: expiredAt,
|
||||
query: 'seam-003',
|
||||
source: 'nominatim',
|
||||
live_rank: 1,
|
||||
live_count: 1,
|
||||
picked_name: 'expired pick',
|
||||
picked_lat: 0,
|
||||
picked_lng: 0,
|
||||
});
|
||||
|
||||
// Real bug found converting `purgeExpired` to a genuinely async
|
||||
// repository call (0b security review F-B4): the `cron` package's
|
||||
@@ -199,8 +209,7 @@ describe('ORM request-context seams populated in production', () => {
|
||||
// `withRequestContext` wrap in `cron-registrar.service.ts`'s `register()`
|
||||
// makes the repository call throw inside `PlaceShadowRetentionJob.tick`'s
|
||||
// own try/catch, and the row survives (verified by hand, reverted).
|
||||
const remaining = testDb.prepare("SELECT COUNT(*) as n FROM place_shadow_picks WHERE query = 'seam-003'").get() as { n: number };
|
||||
expect(remaining.n).toBe(0);
|
||||
expect(await countRows(orm, PlaceShadowPicks, { query: 'seam-003' })).toBe(0);
|
||||
} finally {
|
||||
registrar.unregister('place-shadow-retention');
|
||||
isEnabledSpy.mockRestore();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
@@ -44,15 +45,26 @@ import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { findRows } from '../helpers/factories/rows';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { VacayHolidayCalendars } from '../../src/db/entities/VacayHolidayCalendars.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: FactoryOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
|
||||
/** The id of the holiday calendar created last. */
|
||||
async function latestCalendarId(): Promise<number | undefined> {
|
||||
const [latest] = await findRows(orm, VacayHolidayCalendars, {}, { id: 'desc' });
|
||||
return latest?.id;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
await resetRateLimits(nestApp);
|
||||
@@ -299,7 +311,7 @@ describe('Vacay holiday calendar CRUD', () => {
|
||||
.send({ region: 'US', label: 'US Holidays' });
|
||||
expect(createRes.status).toBe(200);
|
||||
const calId = createRes.body.plan?.holiday_calendars?.at(-1)?.id
|
||||
?? (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
|
||||
?? (await latestCalendarId());
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/addons/vacay/plan/holiday-calendars/${calId}`)
|
||||
@@ -317,7 +329,7 @@ describe('Vacay holiday calendar CRUD', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ region: 'FR', label: 'French Holidays' });
|
||||
expect(createRes.status).toBe(200);
|
||||
const calId = (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
|
||||
const calId = await latestCalendarId();
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/addons/vacay/plan/holiday-calendars/${calId}`)
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
createTrip,
|
||||
createUser,
|
||||
} from '../../helpers/factories';
|
||||
import { readTripDays } from '../../helpers/factories/trips';
|
||||
import { countRows } from '../../helpers/factories/rows';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { Trips } from '../../../src/db/entities/Trips.entity';
|
||||
|
||||
// #2518: a container with a read-only root filesystem and no tmpfs on /tmp
|
||||
// gives SQLite nowhere to put a temp file, so deleting a trip failed with
|
||||
@@ -124,7 +128,8 @@ async function deleteTripAndWatchTempFiles(db: Database.Database): Promise<{
|
||||
start_date: '2026-06-01',
|
||||
end_date: '2026-06-14',
|
||||
});
|
||||
const days = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
|
||||
const t = await createTestOrm(db);
|
||||
const days = await readTripDays(t, trip.id);
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const place = createPlace(db, trip.id, { name: `Ort ${i}`, description: 'Grachten und Tulpen. '.repeat(10) });
|
||||
createDayAssignment(db, days[i % days.length].id, place.id);
|
||||
@@ -141,19 +146,17 @@ async function deleteTripAndWatchTempFiles(db: Database.Database): Promise<{
|
||||
db.exec('CREATE TRIGGER trek_watch_trip_delete AFTER DELETE ON trips BEGIN SELECT trek_watch_temp_files(); END');
|
||||
|
||||
const none = undefined as never;
|
||||
const t = await createTestOrm(db);
|
||||
try {
|
||||
const trips = new TripsService(none, none, none, none, none, none, none, none, new UnitOfWork(t.em), t.em, none);
|
||||
await trips.remove(trip.id, user.id, 'user');
|
||||
return {
|
||||
tempFiles: [...seen],
|
||||
tripLeft: await countRows(t, Trips, { id: trip.id }),
|
||||
placesLeft: await countRows(t, Places, { trip: trip.id }),
|
||||
};
|
||||
} finally {
|
||||
await t.close();
|
||||
}
|
||||
|
||||
return {
|
||||
tempFiles: [...seen],
|
||||
tripLeft: (db.prepare('SELECT COUNT(*) AS n FROM trips WHERE id = ?').get(trip.id) as { n: number }).n,
|
||||
placesLeft: (db.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id) as { n: number }).n,
|
||||
};
|
||||
}
|
||||
|
||||
describe.runIf(canSeeTempFiles)('deleting a trip with content (#2518)', () => {
|
||||
|
||||
@@ -14,32 +14,42 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTrip, createUser } from '../../../helpers/factories';
|
||||
import { createTestCollabMessagesRepo } from '../../../helpers/collab-repos';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { makeCollabMessage } from '../../../helpers/factories/collab';
|
||||
import { updateRows } from '../../../helpers/factories/rows';
|
||||
import { Users } from '../../../../src/db/entities/Users.entity';
|
||||
import type { CollabMessagesRepository } from '../../../../src/db/repositories/CollabMessages.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let collabMessagesRepo: CollabMessagesRepository;
|
||||
let t: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
collabMessagesRepo = await createTestCollabMessagesRepo(testDb);
|
||||
t = await createTestOrm(testDb);
|
||||
});
|
||||
beforeEach(() => resetTestDb(testDb));
|
||||
afterAll(() => testDb.close());
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function insertMessage(
|
||||
async function insertMessage(
|
||||
tripId: number,
|
||||
userId: number,
|
||||
overrides: Partial<{ text: string; reply_to: number | null; deleted: number; created_at: string }> = {},
|
||||
): number {
|
||||
const result = testDb.prepare(
|
||||
'INSERT INTO collab_messages (trip_id, user_id, text, reply_to, deleted, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
).run(
|
||||
tripId, userId, overrides.text ?? 'hi', overrides.reply_to ?? null, overrides.deleted ?? 0,
|
||||
overrides.created_at ?? '2026-09-01T00:00:00.000Z',
|
||||
);
|
||||
return Number(result.lastInsertRowid);
|
||||
): Promise<number> {
|
||||
const message = await makeCollabMessage(t, tripId, userId, {
|
||||
text: overrides.text ?? 'hi',
|
||||
replyToRef: overrides.reply_to ?? null,
|
||||
deleted: overrides.deleted ?? 0,
|
||||
created_at: overrides.created_at ?? '2026-09-01T00:00:00.000Z',
|
||||
});
|
||||
return message.id;
|
||||
}
|
||||
|
||||
function legacyPublicForShare(tripId: number): unknown {
|
||||
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
||||
return testDb.prepare(
|
||||
'SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY m.created_at ASC',
|
||||
).all(tripId);
|
||||
@@ -48,15 +58,15 @@ function legacyPublicForShare(tripId: number): unknown {
|
||||
describe('CollabMessagesRepository — share.service.ts SH16 read', () => {
|
||||
it('listPublicForShare — matches the legacy statement, deleted excluded, every nullable column both null and set', async () => {
|
||||
const { user: author } = createUser(testDb, { username: 'alice' });
|
||||
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('alice.png', author.id);
|
||||
await updateRows(t, Users, { id: author.id }, { avatar: 'alice.png' });
|
||||
const { user: replier } = createUser(testDb, { username: 'bob' });
|
||||
const trip = createTrip(testDb, author.id);
|
||||
const other = createTrip(testDb, author.id);
|
||||
|
||||
const rootId = insertMessage(trip.id, author.id, { text: 'root', reply_to: null, created_at: '2026-09-01T10:00:00.000Z' });
|
||||
const replyId = insertMessage(trip.id, replier.id, { text: 'reply', reply_to: rootId, created_at: '2026-09-01T11:00:00.000Z' });
|
||||
insertMessage(trip.id, author.id, { text: 'gone', deleted: 1, created_at: '2026-09-01T12:00:00.000Z' });
|
||||
insertMessage(other.id, author.id, { text: 'foreign' });
|
||||
const rootId = await insertMessage(trip.id, author.id, { text: 'root', reply_to: null, created_at: '2026-09-01T10:00:00.000Z' });
|
||||
const replyId = await insertMessage(trip.id, replier.id, { text: 'reply', reply_to: rootId, created_at: '2026-09-01T11:00:00.000Z' });
|
||||
await insertMessage(trip.id, author.id, { text: 'gone', deleted: 1, created_at: '2026-09-01T12:00:00.000Z' });
|
||||
await insertMessage(other.id, author.id, { text: 'foreign' });
|
||||
|
||||
const legacy = legacyPublicForShare(trip.id);
|
||||
const typed = await collabMessagesRepo.listPublicForShare(trip.id);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { PhotoProviderFields } from '../../../../src/db/entities/PhotoProviderFields.entity';
|
||||
import { PhotoProviders } from '../../../../src/db/entities/PhotoProviders.entity';
|
||||
import { deleteRows, insertRow } from '../../../helpers/factories/rows';
|
||||
import type { PhotoProviderFieldsRepository } from '../../../../src/db/repositories/PhotoProviderFields.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
@@ -13,10 +15,10 @@ beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
fields = t.repo(PhotoProviderFields);
|
||||
});
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
testDb.exec('DELETE FROM photo_provider_fields');
|
||||
testDb.exec('DELETE FROM photo_providers');
|
||||
await deleteRows(t, PhotoProviderFields);
|
||||
await deleteRows(t, PhotoProviders);
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
@@ -24,11 +26,11 @@ afterAll(async () => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function insertProvider(id: string): void {
|
||||
testDb.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES (?, ?, 'Image', 1, 0)").run(id, id);
|
||||
async function insertProvider(id: string): Promise<void> {
|
||||
await insertRow(t, PhotoProviders, { id, name: id, icon: 'Image', enabled: 1, sort_order: 0 });
|
||||
}
|
||||
|
||||
function insertField(row: {
|
||||
async function insertField(row: {
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
@@ -40,35 +42,29 @@ function insertField(row: {
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order?: number;
|
||||
}): void {
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT INTO photo_provider_fields
|
||||
(provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
row.provider_id,
|
||||
row.field_key,
|
||||
row.label,
|
||||
row.input_type ?? 'text',
|
||||
row.placeholder ?? null,
|
||||
row.hint ?? null,
|
||||
row.required ?? 0,
|
||||
row.secret ?? 0,
|
||||
row.settings_key ?? null,
|
||||
row.payload_key ?? null,
|
||||
row.sort_order ?? 0,
|
||||
);
|
||||
}): Promise<void> {
|
||||
await insertRow(t, PhotoProviderFields, {
|
||||
provider: row.provider_id,
|
||||
field_key: row.field_key,
|
||||
label: row.label,
|
||||
input_type: row.input_type ?? 'text',
|
||||
placeholder: row.placeholder ?? null,
|
||||
hint: row.hint ?? null,
|
||||
required: row.required ?? 0,
|
||||
secret: row.secret ?? 0,
|
||||
settings_key: row.settings_key ?? null,
|
||||
payload_key: row.payload_key ?? null,
|
||||
sort_order: row.sort_order ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
describe('PhotoProviderFieldsRepository.listAllOrdered', () => {
|
||||
it('ADDONSPPFREPO-001: returns every field, ordered by sort_order then id, across all providers', async () => {
|
||||
insertProvider('immich');
|
||||
insertProvider('synology');
|
||||
insertField({ provider_id: 'synology', field_key: 'url', label: 'URL', sort_order: 1 });
|
||||
insertField({ provider_id: 'immich', field_key: 'url', label: 'URL', sort_order: 0 });
|
||||
insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', sort_order: 0 });
|
||||
await insertProvider('immich');
|
||||
await insertProvider('synology');
|
||||
await insertField({ provider_id: 'synology', field_key: 'url', label: 'URL', sort_order: 1 });
|
||||
await insertField({ provider_id: 'immich', field_key: 'url', label: 'URL', sort_order: 0 });
|
||||
await insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', sort_order: 0 });
|
||||
|
||||
const rows = await fields.listAllOrdered();
|
||||
// Two rows share sort_order 0 — insertion order (ascending id) breaks the tie.
|
||||
@@ -80,15 +76,15 @@ describe('PhotoProviderFieldsRepository.listAllOrdered', () => {
|
||||
});
|
||||
|
||||
it('ADDONSPPFREPO-002: the provider_id twin reads back from find(), same as trip_id on Days', async () => {
|
||||
insertProvider('immich');
|
||||
insertField({ provider_id: 'immich', field_key: 'url', label: 'URL' });
|
||||
await insertProvider('immich');
|
||||
await insertField({ provider_id: 'immich', field_key: 'url', label: 'URL' });
|
||||
const [row] = await fields.listAllOrdered();
|
||||
expect(row.provider_id).toBe('immich');
|
||||
});
|
||||
|
||||
it('ADDONSPPFREPO-003: required/secret come back as the raw stored integer, matching the entity type (not a coerced boolean)', async () => {
|
||||
insertProvider('immich');
|
||||
insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', required: 1, secret: 1 });
|
||||
await insertProvider('immich');
|
||||
await insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', required: 1, secret: 1 });
|
||||
const [row] = await fields.listAllOrdered();
|
||||
expect(row.required).toBe(1);
|
||||
expect(row.secret).toBe(1);
|
||||
@@ -96,8 +92,8 @@ describe('PhotoProviderFieldsRepository.listAllOrdered', () => {
|
||||
});
|
||||
|
||||
it('ADDONSPPFREPO-004: nullable columns (placeholder, hint, settings_key, payload_key) come back null when unset', async () => {
|
||||
insertProvider('immich');
|
||||
insertField({ provider_id: 'immich', field_key: 'k', label: 'L' });
|
||||
await insertProvider('immich');
|
||||
await insertField({ provider_id: 'immich', field_key: 'k', label: 'L' });
|
||||
const [row] = await fields.listAllOrdered();
|
||||
expect(row.placeholder).toBeNull();
|
||||
expect(row.hint).toBeNull();
|
||||
@@ -124,11 +120,11 @@ describe('PhotoProviderFieldsRepository.listAllOrderedForAdminShelf', () => {
|
||||
// just `provider_id`, so a future narrowed-projection regression on any
|
||||
// other column fails here too.
|
||||
it('AD28: returns the full legacy row shape, ordered by sort_order then id, across all providers', async () => {
|
||||
insertProvider('immich');
|
||||
insertProvider('synology');
|
||||
insertField({ provider_id: 'synology', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://photos.example.com', hint: 'ignored: not in the AD28 shape', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'url', sort_order: 1 });
|
||||
insertField({ provider_id: 'immich', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'url', sort_order: 0 });
|
||||
insertField({ provider_id: 'immich', field_key: 'api_key', label: 'API Key', input_type: 'password', required: 1, secret: 1, settings_key: 'immich_api_key', payload_key: 'apiKey', sort_order: 1 });
|
||||
await insertProvider('immich');
|
||||
await insertProvider('synology');
|
||||
await insertField({ provider_id: 'synology', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://photos.example.com', hint: 'ignored: not in the AD28 shape', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'url', sort_order: 1 });
|
||||
await insertField({ provider_id: 'immich', field_key: 'url', label: 'Server URL', input_type: 'text', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'url', sort_order: 0 });
|
||||
await insertField({ provider_id: 'immich', field_key: 'api_key', label: 'API Key', input_type: 'password', required: 1, secret: 1, settings_key: 'immich_api_key', payload_key: 'apiKey', sort_order: 1 });
|
||||
|
||||
const rows = await fields.listAllOrderedForAdminShelf();
|
||||
// sort_order 0 first; the sort_order-1 tie breaks by id (insertion order:
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { PluginOauthTokens } from '../../../../src/db/entities/PluginOauthTokens.entity';
|
||||
import type { PluginOauthTokensRepository } from '../../../../src/db/repositories/PluginOauthTokens.repository';
|
||||
import { currentTimestampKysely } from '../../../../src/db/dialect/kysely-functions';
|
||||
import { findRow } from '../../../helpers/factories/rows';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
@@ -30,9 +31,7 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function storedRow(pluginId: string, userId: number) {
|
||||
return testDb
|
||||
.prepare('SELECT plugin_id, user_id, access_token, refresh_token, expires_at, scope, updated_at FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?')
|
||||
.get(pluginId, userId) as { plugin_id: string; user_id: number; access_token: string | null; refresh_token: string | null; expires_at: number | null; scope: string | null; updated_at: string } | undefined;
|
||||
return findRow(t, PluginOauthTokens, { plugin_id: pluginId, user_id: userId });
|
||||
}
|
||||
|
||||
describe('PluginOauthTokensRepository', () => {
|
||||
@@ -75,12 +74,12 @@ describe('PluginOauthTokensRepository', () => {
|
||||
// Seed an existing token row with a non-null refresh_token — the state a
|
||||
// previously-connected user is in when a later refresh comes back without one.
|
||||
await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-1', refresh_token: 'RT-original', expires_at: 1000, scope: 'read' });
|
||||
expect(storedRow('p', 42)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-original' });
|
||||
expect(await storedRow('p', 42)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-original' });
|
||||
|
||||
// A refresh response that omits refresh_token — the repository call the
|
||||
// service makes when it has NOT pre-filled the field itself.
|
||||
await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-2', refresh_token: null, expires_at: 2000, scope: 'read write' });
|
||||
const row = storedRow('p', 42)!;
|
||||
const row = (await storedRow('p', 42))!;
|
||||
// access_token/expires_at/scope always take the new value (plain excluded.* reassignment)...
|
||||
expect(row.access_token).toBe('AT-2');
|
||||
expect(row.expires_at).toBe(2000);
|
||||
@@ -93,14 +92,14 @@ describe('PluginOauthTokensRepository', () => {
|
||||
it('PO11REPO-003 (R-oauth-upsert, the other COALESCE direction): a refresh response that DOES supply a new refresh_token overwrites the stored one', async () => {
|
||||
await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-1', refresh_token: 'RT-original', expires_at: 1000, scope: 'read' });
|
||||
await repo.storeToken({ plugin_id: 'p', user_id: 42, access_token: 'AT-2', refresh_token: 'RT-rotated', expires_at: 2000, scope: 'read' });
|
||||
const row = storedRow('p', 42)!;
|
||||
const row = (await storedRow('p', 42))!;
|
||||
expect(row.refresh_token).toBe('RT-rotated');
|
||||
});
|
||||
|
||||
it('PO11REPO-004: a brand-new row (no prior state) inserts plainly — no COALESCE fallback target to speak of yet', async () => {
|
||||
expect(storedRow('fresh', 7)).toBeUndefined();
|
||||
expect(await storedRow('fresh', 7)).toBeNull();
|
||||
await repo.storeToken({ plugin_id: 'fresh', user_id: 7, access_token: 'AT', refresh_token: null, expires_at: null, scope: null });
|
||||
const row = storedRow('fresh', 7)!;
|
||||
const row = (await storedRow('fresh', 7))!;
|
||||
expect(row.access_token).toBe('AT');
|
||||
expect(row.refresh_token).toBeNull();
|
||||
});
|
||||
@@ -108,8 +107,8 @@ describe('PluginOauthTokensRepository', () => {
|
||||
it('PO11REPO-005: the composite key isolates rows — a second user of the same plugin gets its own row, the first is untouched', async () => {
|
||||
await repo.storeToken({ plugin_id: 'p', user_id: 1, access_token: 'AT-1', refresh_token: 'RT-1', expires_at: null, scope: null });
|
||||
await repo.storeToken({ plugin_id: 'p', user_id: 2, access_token: 'AT-2', refresh_token: 'RT-2', expires_at: null, scope: null });
|
||||
expect(storedRow('p', 1)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-1' });
|
||||
expect(storedRow('p', 2)).toMatchObject({ access_token: 'AT-2', refresh_token: 'RT-2' });
|
||||
expect(await storedRow('p', 1)).toMatchObject({ access_token: 'AT-1', refresh_token: 'RT-1' });
|
||||
expect(await storedRow('p', 2)).toMatchObject({ access_token: 'AT-2', refresh_token: 'RT-2' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { createUser } from '../../../helpers/factories';
|
||||
import { VacayPlans } from '../../../../src/db/entities/VacayPlans.entity';
|
||||
import { makeVacayPlan } from '../../../helpers/factories/vacay';
|
||||
import type { VacayPlansRepository } from '../../../../src/db/repositories/VacayPlans.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
@@ -23,20 +24,16 @@ beforeAll(async () => {
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function insertPlan(ownerId: number, overrides: Partial<{ block_weekends: number; holidays_enabled: number; holidays_region: string; school_holidays_enabled: number; company_holidays_enabled: number; carry_over_enabled: number; weekend_days: string; week_start: number }> = {}): number {
|
||||
const cols = Object.keys(overrides);
|
||||
const placeholders = cols.map(() => '?').join(', ');
|
||||
const extra = cols.length > 0 ? `, ${cols.join(', ')}` : '';
|
||||
const result = testDb.prepare(`INSERT INTO vacay_plans (owner_id${extra}) VALUES (?${cols.length > 0 ? ', ' + placeholders : ''})`)
|
||||
.run(ownerId, ...cols.map((c) => (overrides as Record<string, unknown>)[c]));
|
||||
return Number(result.lastInsertRowid);
|
||||
async function insertPlan(ownerId: number, overrides: Partial<{ block_weekends: number; holidays_enabled: number; holidays_region: string; school_holidays_enabled: number; company_holidays_enabled: number; carry_over_enabled: number; weekend_days: string; week_start: number }> = {}): Promise<number> {
|
||||
return (await makeVacayPlan(t, ownerId, overrides)).id;
|
||||
}
|
||||
|
||||
describe('VacayPlansRepository.findByOwner (VC7/9/78)', () => {
|
||||
it('VACAYPLANREPO-001: matches SELECT * FROM vacay_plans WHERE owner_id = ? run raw, every nullable column set', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertPlan(user.id, { holidays_region: 'DE', weekend_days: '["sat","sun"]', week_start: 1 });
|
||||
const id = await insertPlan(user.id, { holidays_region: 'DE', weekend_days: '["sat","sun"]', week_start: 1 });
|
||||
|
||||
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
||||
const legacy = testDb.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(user.id);
|
||||
expect(await repo.findByOwner(user.id)).toEqual(legacy);
|
||||
expect((await repo.findByOwner(user.id))!.id).toBe(id);
|
||||
@@ -51,8 +48,9 @@ describe('VacayPlansRepository.findByOwner (VC7/9/78)', () => {
|
||||
describe('VacayPlansRepository.findById (VC14, the hottest statement in this file)', () => {
|
||||
it('VACAYPLANREPO-003: matches SELECT * FROM vacay_plans WHERE id = ? run raw, defaulted columns included', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertPlan(user.id);
|
||||
const id = await insertPlan(user.id);
|
||||
|
||||
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
||||
const legacy = testDb.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(id);
|
||||
expect(await repo.findById(id)).toEqual(legacy);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createSnapshotTestDb } from '../../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../../helpers/test-orm';
|
||||
import { createReservation, createTrip, createUser } from '../../../../helpers/factories';
|
||||
import { addReservationTraveler } from '../../../../helpers/factories/reservations';
|
||||
import { Reservations } from '../../../../../src/db/entities/Reservations.entity';
|
||||
import {
|
||||
travelerOwnsCondition,
|
||||
@@ -32,12 +33,13 @@ beforeAll(async () => { t = await createTestOrm(testDb); });
|
||||
beforeEach(() => { resetTestDb(testDb); t.clear(); });
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
function addTraveler(reservationId: number, userId: number): void {
|
||||
testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(reservationId, userId);
|
||||
async function addTraveler(reservationId: number, userId: number): Promise<void> {
|
||||
await addReservationTraveler(t, reservationId, userId);
|
||||
}
|
||||
|
||||
async function legacyOwnedIds(tripId: number, userId: number): Promise<number[]> {
|
||||
const rows = testDb
|
||||
// test-sql-allow: the legacy TRAVELER_OWNS fragment is the oracle, so it has to run as the raw SQL it was.
|
||||
.prepare(`SELECT id FROM reservations r WHERE r.trip_id = ? AND ${TRAVELER_OWNS} ORDER BY id ASC`)
|
||||
.all(tripId, userId) as { id: number }[];
|
||||
return rows.map((row) => row.id);
|
||||
@@ -91,7 +93,7 @@ describe('reservation-travelers-owns parity (TRAVELER_OWNS: the two cases that m
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, traveler.id);
|
||||
const assigned = createReservation(testDb, trip.id, { title: 'Flight with named travelers' });
|
||||
addTraveler(assigned.id, traveler.id);
|
||||
await addTraveler(assigned.id, traveler.id);
|
||||
|
||||
const legacyForTraveler = await legacyOwnedIds(trip.id, traveler.id);
|
||||
const typedQbForTraveler = await typedQbOwnedIds(trip.id, traveler.id);
|
||||
@@ -114,8 +116,8 @@ describe('reservation-travelers-owns parity (TRAVELER_OWNS: the two cases that m
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, travelerA.id);
|
||||
const shared = createReservation(testDb, trip.id, { title: 'Family flight' });
|
||||
addTraveler(shared.id, travelerA.id);
|
||||
addTraveler(shared.id, travelerB.id);
|
||||
await addTraveler(shared.id, travelerA.id);
|
||||
await addTraveler(shared.id, travelerB.id);
|
||||
|
||||
for (const userId of [travelerA.id, travelerB.id]) {
|
||||
const legacy = await legacyOwnedIds(trip.id, userId);
|
||||
@@ -141,7 +143,7 @@ describe('reservation-travelers-owns parity (TRAVELER_OWNS: the two cases that m
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const unassigned = createReservation(testDb, trip.id, { title: 'Unassigned' });
|
||||
const assignedToOther = createReservation(testDb, trip.id, { title: 'Assigned to someone else' });
|
||||
addTraveler(assignedToOther.id, travelerB.id);
|
||||
await addTraveler(assignedToOther.id, travelerB.id);
|
||||
|
||||
const legacy = await legacyOwnedIds(trip.id, viewer.id);
|
||||
const typedQb = await typedQbOwnedIds(trip.id, viewer.id);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { Addons } from '../../../../src/db/entities/Addons.entity';
|
||||
import { deleteRows, insertRow } from '../../../helpers/factories/rows';
|
||||
import type { AddonsRepository } from '../../../../src/db/repositories/Addons.repository';
|
||||
import { prepareLlmAddonConfigForWrite } from '../../../../src/nest/llm-parse/llm-config';
|
||||
import { decrypt_api_key } from '../../../../src/nest/common/crypto/apiKeyCrypto';
|
||||
@@ -24,12 +25,18 @@ beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
addons = t.repo(Addons);
|
||||
});
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
testDb.exec('DELETE FROM addons');
|
||||
testDb
|
||||
.prepare(`INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('llm_parsing', 'AI Parsing', NULL, 'global', 'Sparkles', 0, 0)`)
|
||||
.run();
|
||||
await deleteRows(t, Addons);
|
||||
await insertRow(t, Addons, {
|
||||
id: 'llm_parsing',
|
||||
name: 'AI Parsing',
|
||||
description: null,
|
||||
type: 'global',
|
||||
icon: 'Sparkles',
|
||||
enabled: false,
|
||||
sort_order: 0,
|
||||
});
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* counterparts of the GET /api/plugins feed and GET /api/plugin-pois. Both go through
|
||||
* PluginPoisService, so the gate, the window and the normalization are the REST ones.
|
||||
*/
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true) }));
|
||||
|
||||
@@ -25,6 +25,10 @@ import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { deleteRows } from '../../helpers/factories/rows';
|
||||
import { makePlugin } from '../../helpers/factories/plugins';
|
||||
|
||||
const providersOfMock = vi.spyOn(PluginHooks.prototype, 'providersOf');
|
||||
const categoryPoisMock = vi.spyOn(PluginHooks.prototype, 'categoryPois');
|
||||
@@ -32,18 +36,28 @@ const categoryPoisMock = vi.spyOn(PluginHooks.prototype, 'categoryPois');
|
||||
const trailheads = { id: 'trailheads', label: 'Trailheads', labels: { de: 'Wanderparkplätze' }, icon: 'Signpost', color: '#2f855a' };
|
||||
const bbox = { south: 47, west: 11, north: 47.5, east: 11.5 };
|
||||
|
||||
beforeEach(() => {
|
||||
let orm: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(testDb);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
testDb.prepare('DELETE FROM plugins').run();
|
||||
testDb
|
||||
.prepare("INSERT INTO plugins (id, name, status, capabilities, granted_permissions) VALUES (?, ?, 'active', ?, ?)")
|
||||
.run('trail-finder', 'Trail Finder', JSON.stringify({ poiCategories: [trailheads] }), JSON.stringify(['hook:poi-category-provider']));
|
||||
await deleteRows(orm, Plugins);
|
||||
await makePlugin(orm, 'trail-finder', {
|
||||
name: 'Trail Finder',
|
||||
status: 'active',
|
||||
capabilities: JSON.stringify({ poiCategories: [trailheads] }),
|
||||
granted_permissions: JSON.stringify(['hook:poi-category-provider']),
|
||||
});
|
||||
pluginsEnabled.mockReturnValue(true);
|
||||
providersOfMock.mockReset().mockReturnValue(['trail-finder']);
|
||||
categoryPoisMock.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('checkBackupDatabase and the release that made the backup', () => {
|
||||
const db = new Database(path.join(dir, 'travel.db'));
|
||||
for (const table of ['users', 'trips', 'trip_members', 'places', 'days']) db.exec(`CREATE TABLE ${table} (id INTEGER)`);
|
||||
db.exec('CREATE TABLE mikro_orm_migrations (id INTEGER PRIMARY KEY, name TEXT, executed_at TEXT)');
|
||||
// test-sql-allow: a bare backup file before any boot, which no ORM is bound to.
|
||||
const insert = db.prepare('INSERT INTO mikro_orm_migrations (name) VALUES (?)');
|
||||
for (const name of recorded) insert.run(name);
|
||||
db.close();
|
||||
|
||||
@@ -4,18 +4,14 @@
|
||||
* freeze the FX rate before the raw write — incl. the #1445 stored-currency
|
||||
* thread-through) and the wrapper-only syncReservationPrice SQL. The SQL-heavy
|
||||
* paths themselves are covered by budget.service.db.test.ts (real :memory: DB)
|
||||
* and budget.service.calc.test.ts (settlement math over a prepare-stub mock).
|
||||
* and budget.service.calc.test.ts (settlement math over a stubbed database).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the data + side-effect dependencies the service reaches into directly.
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: dbMock,
|
||||
db: {},
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
canAccessTrip,
|
||||
@@ -49,9 +45,9 @@ import type { TripsRepository } from '../../../src/db/repositories/Trips.reposit
|
||||
import type { TripMembersRepository } from '../../../src/db/repositories/TripMembers.repository';
|
||||
|
||||
/**
|
||||
* There is no database behind this suite — every statement is served by the
|
||||
* prepare stub above — so the UnitOfWork is a pass-through: it runs the callback
|
||||
* as MikroORM would, with no transaction to open on.
|
||||
* There is no database behind this suite (every repository is a stub), so the
|
||||
* UnitOfWork is a pass-through: it runs the callback as MikroORM would, with no
|
||||
* transaction to open on.
|
||||
*/
|
||||
const uowStub = { transactional: <T>(fn: () => Promise<T>) => fn() } as unknown as UnitOfWork;
|
||||
|
||||
@@ -61,16 +57,19 @@ const uowStub = { transactional: <T>(fn: () => Promise<T>) => fn() } as unknown
|
||||
* and update and delete wrappers, setItemPayers, insertSettlement,
|
||||
* getSettlement, applySettlementUpdate — the composite-wrapper tests only
|
||||
* assert the freeze-then-write ORDER, never the SQL itself) or, for
|
||||
* `syncReservationPrice`, still needs the `dbMock`/`prepare` stub above to
|
||||
* answer — so this repo proxies to it instead of a real
|
||||
* `ReservationsRepository`, keeping that describe block's assertions
|
||||
* (incl. "prepare throws" leading to a swallowed error) unchanged.
|
||||
* `syncReservationPrice`, answered by these three repository stubs: the
|
||||
* lookup, the metadata write and the reload the service makes, in that order.
|
||||
* A lookup that rejects stands for the database being gone.
|
||||
*/
|
||||
const reservationsRepoStub = {
|
||||
getIdAndMetadata: async (id: number, tripId: unknown) => dbMock.prepare().get(id, tripId),
|
||||
setMetadata: async (id: number, metadata: string) => { dbMock.prepare().run(metadata, id); },
|
||||
getFull: async (id: number) => dbMock.prepare().get(id),
|
||||
} as unknown as ReservationsRepository;
|
||||
const reservationsRepo = {
|
||||
getIdAndMetadata: vi.fn(),
|
||||
setMetadata: vi.fn<(id: number, metadata: string) => Promise<void>>(async () => {}),
|
||||
getFull: vi.fn(),
|
||||
};
|
||||
const reservationsRepoStub = reservationsRepo as unknown as ReservationsRepository;
|
||||
|
||||
/** The metadata JSON the service wrote on its first setMetadata call. */
|
||||
const writtenMetadata = (): unknown => JSON.parse(reservationsRepo.setMetadata.mock.calls[0][1]);
|
||||
|
||||
function svc(
|
||||
tripMembersRepo: TripMembersRepository = {} as unknown as TripMembersRepository,
|
||||
@@ -101,7 +100,7 @@ describe('BudgetService', () => {
|
||||
// Plan 4 Task 2: `verifyTripAccess` no longer rides `DatabaseService
|
||||
// .canAccessTrip` — it reads through the injected `TripsRepository`
|
||||
// directly, consistent with this suite's "no database behind it"
|
||||
// design (everything else here is a `prepare` stub).
|
||||
// design (everything else here is a stub).
|
||||
const findAccessible = vi.fn(async () => ({ id: 5, user_id: 2 }));
|
||||
const fakeTripsRepo = { findAccessible } as unknown as TripsRepository;
|
||||
expect(await svc(undefined, fakeTripsRepo).verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
|
||||
@@ -261,52 +260,54 @@ describe('BudgetService', () => {
|
||||
|
||||
describe('syncReservationPrice', () => {
|
||||
it('returns early when the reservation is not found', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce(undefined);
|
||||
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce(undefined);
|
||||
await svc().syncReservationPrice('5', 42, 250, 'sock');
|
||||
expect(dbMock._stmt.run).not.toHaveBeenCalled();
|
||||
expect(reservationsRepo.setMetadata).not.toHaveBeenCalled();
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges into existing metadata and broadcasts reservation:updated', async () => {
|
||||
dbMock._stmt.get
|
||||
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }) // lookup
|
||||
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload
|
||||
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }); // lookup
|
||||
reservationsRepo.getFull.mockResolvedValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload
|
||||
await svc().syncReservationPrice('5', 42, 250, 'sock');
|
||||
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
|
||||
expect(writtenMeta).toEqual({ vendor: 'ACME', price: '250' });
|
||||
expect(reservationsRepo.setMetadata.mock.calls[0][0]).toBe(42);
|
||||
expect(writtenMetadata()).toEqual({ vendor: 'ACME', price: '250' });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:updated', { reservation: { id: 42, metadata: '{"vendor":"ACME","price":"250"}' } }, 'sock');
|
||||
});
|
||||
|
||||
it('starts from an empty object when the reservation has no metadata', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: null }).mockReturnValueOnce({ id: 42 });
|
||||
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: null });
|
||||
reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 });
|
||||
await svc().syncReservationPrice('5', 42, 99, undefined);
|
||||
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
|
||||
expect(writtenMeta).toEqual({ price: '99' });
|
||||
expect(writtenMetadata()).toEqual({ price: '99' });
|
||||
});
|
||||
|
||||
it('swallows errors so a sync failure never breaks the budget update', async () => {
|
||||
dbMock.prepare.mockImplementationOnce(() => { throw new Error('db gone'); });
|
||||
reservationsRepo.getIdAndMetadata.mockRejectedValueOnce(new Error('db gone'));
|
||||
await expect(svc().syncReservationPrice('5', 42, 250, 'sock')).resolves.toBeUndefined();
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #2084: the card names the currency beside the mirrored figure.
|
||||
it('writes the currency beside the price, upper-cased', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"seat":"1A"}' }).mockReturnValueOnce({ id: 42 });
|
||||
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"seat":"1A"}' });
|
||||
reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 });
|
||||
await svc().syncReservationPrice('5', 42, 99.5, 'sock', 'usd');
|
||||
expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ seat: '1A', price: '99.5', priceCurrency: 'USD' });
|
||||
expect(writtenMetadata()).toEqual({ seat: '1A', price: '99.5', priceCurrency: 'USD' });
|
||||
});
|
||||
|
||||
it('drops a stored currency when the expense is in the trip currency (null)', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' }).mockReturnValueOnce({ id: 42 });
|
||||
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' });
|
||||
reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 });
|
||||
await svc().syncReservationPrice('5', 42, 12, undefined, null);
|
||||
expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ price: '12' });
|
||||
expect(writtenMetadata()).toEqual({ price: '12' });
|
||||
});
|
||||
|
||||
it('leaves a stored currency alone when none is passed at all', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' }).mockReturnValueOnce({ id: 42 });
|
||||
reservationsRepo.getIdAndMetadata.mockResolvedValueOnce({ id: 42, metadata: '{"price":"10","priceCurrency":"CNY"}' });
|
||||
reservationsRepo.getFull.mockResolvedValueOnce({ id: 42 });
|
||||
await svc().syncReservationPrice('5', 42, 12, undefined);
|
||||
expect(JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string)).toEqual({ price: '12', priceCurrency: 'CNY' });
|
||||
expect(writtenMetadata()).toEqual({ price: '12', priceCurrency: 'CNY' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
import { DAWARICH_KEY_MASK, type DawarichCapabilities } from '@trek/shared';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { DawarichConnections } from '../../../src/db/entities/DawarichConnections.entity';
|
||||
import { findRow, upsertRow } from '../../helpers/factories/rows';
|
||||
import type { DawarichConnectionsRepository } from '../../../src/db/repositories/DawarichConnections.repository';
|
||||
import { DawarichService } from '../../../src/nest/integrations/dawarich.service';
|
||||
import type { TestOrm } from '../../helpers/test-orm';
|
||||
@@ -121,7 +123,7 @@ interface ConnFixture {
|
||||
capabilities: string | null;
|
||||
}
|
||||
|
||||
function connect(userId: number, over: Partial<ConnFixture> = {}): void {
|
||||
async function connect(userId: number, over: Partial<ConnFixture> = {}): Promise<void> {
|
||||
const row: ConnFixture = {
|
||||
url: HOST,
|
||||
apiKey: 'stored-key',
|
||||
@@ -133,41 +135,21 @@ function connect(userId: number, over: Partial<ConnFixture> = {}): void {
|
||||
capabilities: null,
|
||||
...over,
|
||||
};
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO dawarich_connections
|
||||
(user_id, url, api_key, allow_insecure_tls, sync_enabled,
|
||||
last_sync_at, last_sync_state, last_sync_error, capabilities)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
row.url,
|
||||
row.apiKey,
|
||||
row.allowInsecureTls,
|
||||
row.syncEnabled,
|
||||
row.lastSyncAt,
|
||||
row.lastSyncState,
|
||||
row.lastSyncError,
|
||||
row.capabilities,
|
||||
);
|
||||
await upsertRow(t, DawarichConnections, {
|
||||
user: userId,
|
||||
url: row.url,
|
||||
api_key: row.apiKey,
|
||||
allow_insecure_tls: row.allowInsecureTls,
|
||||
sync_enabled: row.syncEnabled,
|
||||
last_sync_at: row.lastSyncAt,
|
||||
last_sync_state: row.lastSyncState,
|
||||
last_sync_error: row.lastSyncError,
|
||||
capabilities: row.capabilities,
|
||||
});
|
||||
}
|
||||
|
||||
interface StoredRow {
|
||||
url: string | null;
|
||||
api_key: string | null;
|
||||
allow_insecure_tls: number;
|
||||
sync_enabled: number;
|
||||
last_sync_at: string | null;
|
||||
last_sync_state: string;
|
||||
last_sync_error: string | null;
|
||||
capabilities: string | null;
|
||||
}
|
||||
|
||||
function row(userId = USER): StoredRow | undefined {
|
||||
return testDb
|
||||
.prepare('SELECT * FROM dawarich_connections WHERE user_id = ?')
|
||||
.get(userId) as StoredRow | undefined;
|
||||
function row(userId = USER) {
|
||||
return findRow(t, DawarichConnections, { user: userId });
|
||||
}
|
||||
|
||||
const FULL_CAPS: DawarichCapabilities = {
|
||||
@@ -271,24 +253,24 @@ describe('DawarichService getCredentials', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-002: a row without an address is not a connection', async () => {
|
||||
connect(USER, { url: null });
|
||||
await connect(USER, { url: null });
|
||||
expect(await svc.getCredentials(USER)).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-003: a row without a key is not a connection', async () => {
|
||||
connect(USER, { apiKey: null });
|
||||
await connect(USER, { apiKey: null });
|
||||
expect(await svc.getCredentials(USER)).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-004: a key that cannot be decrypted reads as "not connected" rather than travelling upstream as garbage', async () => {
|
||||
// What a rotated ENCRYPTION_KEY or a half-restored backup leaves behind:
|
||||
// the envelope is intact, the ciphertext is not ours.
|
||||
connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
|
||||
await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
|
||||
expect(await svc.getCredentials(USER)).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-005: a legacy plaintext key still works, with the TLS flag off', async () => {
|
||||
connect(USER, { apiKey: 'plain-legacy-key', allowInsecureTls: 0 });
|
||||
await connect(USER, { apiKey: 'plain-legacy-key', allowInsecureTls: 0 });
|
||||
expect(await svc.getCredentials(USER)).toEqual({
|
||||
baseUrl: HOST,
|
||||
apiKey: 'plain-legacy-key',
|
||||
@@ -312,12 +294,12 @@ describe('DawarichService getCredentials', () => {
|
||||
|
||||
describe('DawarichService isConnected', () => {
|
||||
it('DAWARICH-SVC-010: both halves present means connected', async () => {
|
||||
connect(USER);
|
||||
await connect(USER);
|
||||
expect(await svc.isConnected(USER)).toBe(true);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-011: an address without a key is not connected', async () => {
|
||||
connect(USER, { apiKey: null });
|
||||
await connect(USER, { apiKey: null });
|
||||
expect(await svc.isConnected(USER)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -329,7 +311,7 @@ describe('DawarichService isConnected', () => {
|
||||
// Deliberate difference from getCredentials: the settings card has to keep
|
||||
// showing a connection the user can repair, and only the outbound path
|
||||
// cares that the ciphertext is unusable.
|
||||
connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
|
||||
await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
|
||||
expect(await svc.isConnected(USER)).toBe(true);
|
||||
expect(await svc.getCredentials(USER)).toBeNull();
|
||||
});
|
||||
@@ -343,14 +325,14 @@ describe('DawarichService listSyncableUserIds', () => {
|
||||
const blankUrl = createUser(testDb).user.id;
|
||||
const noKey = createUser(testDb).user.id;
|
||||
|
||||
connect(syncable);
|
||||
connect(pollOff, { syncEnabled: 0 });
|
||||
connect(noUrl, { url: null });
|
||||
await connect(syncable);
|
||||
await connect(pollOff, { syncEnabled: 0 });
|
||||
await connect(noUrl, { url: null });
|
||||
// An empty string is a distinct case from NULL: the save path stores NULL,
|
||||
// but an older row or a hand-edited one can hold '' and it must not be
|
||||
// polled, because the client would then request /api/v1 on no host at all.
|
||||
connect(blankUrl, { url: '' });
|
||||
connect(noKey, { apiKey: null });
|
||||
await connect(blankUrl, { url: '' });
|
||||
await connect(noKey, { apiKey: null });
|
||||
|
||||
expect(await svc.listSyncableUserIds()).toEqual([syncable]);
|
||||
});
|
||||
@@ -365,9 +347,9 @@ describe('DawarichService listSyncableUserIds', () => {
|
||||
// settings card would still say the poll was on for all of them.
|
||||
const second = createUser(testDb).user.id;
|
||||
const pollOff = createUser(testDb).user.id;
|
||||
connect(USER);
|
||||
connect(second);
|
||||
connect(pollOff, { syncEnabled: 0 });
|
||||
await connect(USER);
|
||||
await connect(second);
|
||||
await connect(pollOff, { syncEnabled: 0 });
|
||||
|
||||
const ids = await svc.listSyncableUserIds();
|
||||
|
||||
@@ -397,7 +379,7 @@ describe('DawarichService getConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-031: a stored connection is reported with a mask where the key is, never the key', async () => {
|
||||
connect(USER, {
|
||||
await connect(USER, {
|
||||
apiKey: 'stored-key',
|
||||
allowInsecureTls: 1,
|
||||
syncEnabled: 0,
|
||||
@@ -423,24 +405,24 @@ describe('DawarichService getConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-032: an address without a key reports the address and no mask', async () => {
|
||||
connect(USER, { apiKey: null });
|
||||
await connect(USER, { apiKey: null });
|
||||
expect(await svc.getConnection(USER)).toMatchObject({ url: HOST, apiKeyMasked: '', connected: false });
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-033: a null address reads as an empty string, so the form binds to a value', async () => {
|
||||
connect(USER, { url: null, apiKey: null });
|
||||
await connect(USER, { url: null, apiKey: null });
|
||||
expect(await svc.getConnection(USER)).toMatchObject({ url: '', connected: false, syncEnabled: true });
|
||||
});
|
||||
|
||||
it.each(['ok', 'partial', 'failed'])('DAWARICH-SVC-034: the known sync state %s passes through', async (state) => {
|
||||
connect(USER, { lastSyncState: state });
|
||||
await connect(USER, { lastSyncState: state });
|
||||
expect((await svc.getConnection(USER)).lastSyncState).toBe(state);
|
||||
});
|
||||
|
||||
it.each(['syncing', 'OK', 'success', ''])(
|
||||
'DAWARICH-SVC-035: the unrecognised sync state %j reads as "never" rather than reaching a client that has no branch for it',
|
||||
async (state) => {
|
||||
connect(USER, { lastSyncState: state });
|
||||
await connect(USER, { lastSyncState: state });
|
||||
expect((await svc.getConnection(USER)).lastSyncState).toBe('never');
|
||||
},
|
||||
);
|
||||
@@ -448,7 +430,7 @@ describe('DawarichService getConnection', () => {
|
||||
it.each(['{not json', '', 'undefined'])(
|
||||
'DAWARICH-SVC-036: the malformed capabilities blob %j reads as "not probed yet", never as a crash on the settings page',
|
||||
async (raw) => {
|
||||
connect(USER, { capabilities: raw });
|
||||
await connect(USER, { capabilities: raw });
|
||||
expect((await svc.getConnection(USER)).capabilities).toBeNull();
|
||||
},
|
||||
);
|
||||
@@ -456,7 +438,7 @@ describe('DawarichService getConnection', () => {
|
||||
it.each(['null', '42', '"visits"', 'true'])(
|
||||
'DAWARICH-SVC-037: the JSON scalar %s is not a capabilities object and reads as not probed',
|
||||
async (raw) => {
|
||||
connect(USER, { capabilities: raw });
|
||||
await connect(USER, { capabilities: raw });
|
||||
expect((await svc.getConnection(USER)).capabilities).toBeNull();
|
||||
},
|
||||
);
|
||||
@@ -465,7 +447,7 @@ describe('DawarichService getConnection', () => {
|
||||
// The column survives an upgrade, so the shape is whatever was current when
|
||||
// the last probe ran. Missing flags are off and a missing probe timestamp
|
||||
// is the epoch, which reads as "long ago" everywhere it is compared.
|
||||
connect(USER, { capabilities: JSON.stringify({ visits: true, serverVersion: 17, extra: 'ignored' }) });
|
||||
await connect(USER, { capabilities: JSON.stringify({ visits: true, serverVersion: 17, extra: 'ignored' }) });
|
||||
|
||||
expect((await svc.getConnection(USER)).capabilities).toEqual({
|
||||
visits: true,
|
||||
@@ -481,7 +463,7 @@ describe('DawarichService getConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-039: a complete blob round-trips unchanged', async () => {
|
||||
connect(USER, { capabilities: JSON.stringify(FULL_CAPS) });
|
||||
await connect(USER, { capabilities: JSON.stringify(FULL_CAPS) });
|
||||
expect((await svc.getConnection(USER)).capabilities).toEqual(FULL_CAPS);
|
||||
});
|
||||
});
|
||||
@@ -492,12 +474,12 @@ describe('DawarichService getCapabilities', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-041: null when the connection has never been probed', async () => {
|
||||
connect(USER);
|
||||
await connect(USER);
|
||||
expect(await svc.getCapabilities(USER)).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-042: the stored probe otherwise', async () => {
|
||||
connect(USER, { capabilities: JSON.stringify(FULL_CAPS) });
|
||||
await connect(USER, { capabilities: JSON.stringify(FULL_CAPS) });
|
||||
expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS);
|
||||
});
|
||||
});
|
||||
@@ -511,7 +493,7 @@ describe('DawarichService saveSettings', () => {
|
||||
const out = await svc.saveSettings(USER, ` ${HOST} `, 'super-secret', true, true, IP);
|
||||
|
||||
expect(out).toEqual({ success: true });
|
||||
const stored = row();
|
||||
const stored = await row();
|
||||
expect(stored?.url).toBe(HOST);
|
||||
expect(stored?.allow_insecure_tls).toBe(1);
|
||||
expect(stored?.sync_enabled).toBe(1);
|
||||
@@ -524,31 +506,31 @@ describe('DawarichService saveSettings', () => {
|
||||
it('DAWARICH-SVC-051: turning the background poll off is persisted, and the card reports it', async () => {
|
||||
await svc.saveSettings(USER, HOST, 'k', false, false, IP);
|
||||
|
||||
expect(row()?.sync_enabled).toBe(0);
|
||||
expect(row()?.allow_insecure_tls).toBe(0);
|
||||
expect((await row())?.sync_enabled).toBe(0);
|
||||
expect((await row())?.allow_insecure_tls).toBe(0);
|
||||
expect((await svc.getConnection(USER)).syncEnabled).toBe(false);
|
||||
expect(await svc.listSyncableUserIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-052: a blank key field keeps the stored key, because the field is never prefilled', async () => {
|
||||
await svc.saveSettings(USER, HOST, 'first-key', false, true, IP);
|
||||
const encrypted = row()?.api_key;
|
||||
const encrypted = (await row())?.api_key;
|
||||
|
||||
await svc.saveSettings(USER, `${HOST}`, undefined, true, true, IP);
|
||||
|
||||
expect(row()?.api_key).toBe(encrypted);
|
||||
expect(row()?.allow_insecure_tls).toBe(1);
|
||||
expect((await row())?.api_key).toBe(encrypted);
|
||||
expect((await row())?.allow_insecure_tls).toBe(1);
|
||||
});
|
||||
|
||||
it.each([DAWARICH_KEY_MASK, ' ', ''])(
|
||||
'DAWARICH-SVC-053: the key field %j means "keep what is stored"',
|
||||
async (typed) => {
|
||||
await svc.saveSettings(USER, HOST, 'first-key', false, true, IP);
|
||||
const encrypted = row()?.api_key;
|
||||
const encrypted = (await row())?.api_key;
|
||||
|
||||
await svc.saveSettings(USER, HOST, typed, false, true, IP);
|
||||
|
||||
expect(row()?.api_key).toBe(encrypted);
|
||||
expect((await row())?.api_key).toBe(encrypted);
|
||||
expect((await svc.getCredentials(USER))?.apiKey).toBe('first-key');
|
||||
},
|
||||
);
|
||||
@@ -561,7 +543,7 @@ describe('DawarichService saveSettings', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-055: moving to a different host without retyping the key drops the key and every artefact of the old instance', async () => {
|
||||
connect(USER, {
|
||||
await connect(USER, {
|
||||
url: 'https://old.example',
|
||||
apiKey: 'stored-key',
|
||||
lastSyncAt: '2026-09-12T04:15:00.000Z',
|
||||
@@ -572,7 +554,7 @@ describe('DawarichService saveSettings', () => {
|
||||
|
||||
await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP);
|
||||
|
||||
const stored = row();
|
||||
const stored = await row();
|
||||
expect(stored?.url).toBe('https://new.example');
|
||||
expect(stored?.api_key).toBeNull();
|
||||
expect(stored?.capabilities).toBeNull();
|
||||
@@ -582,9 +564,9 @@ describe('DawarichService saveSettings', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-056: a different port is a different instance, so the key goes with it', async () => {
|
||||
connect(USER, { url: 'https://d.example' });
|
||||
await connect(USER, { url: 'https://d.example' });
|
||||
await svc.saveSettings(USER, 'https://d.example:8443', undefined, false, true, IP);
|
||||
expect(row()?.api_key).toBeNull();
|
||||
expect((await row())?.api_key).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -594,15 +576,15 @@ describe('DawarichService saveSettings', () => {
|
||||
])(
|
||||
'DAWARICH-SVC-057: %s to %s is the same instance and keeps the key',
|
||||
async (before, after) => {
|
||||
connect(USER, { url: before, apiKey: 'stored-key' });
|
||||
await connect(USER, { url: before, apiKey: 'stored-key' });
|
||||
await svc.saveSettings(USER, after, undefined, false, true, IP);
|
||||
expect(row()?.api_key).toBe('stored-key');
|
||||
expect(row()?.url).toBe(after);
|
||||
expect((await row())?.api_key).toBe('stored-key');
|
||||
expect((await row())?.url).toBe(after);
|
||||
},
|
||||
);
|
||||
|
||||
it('DAWARICH-SVC-058: a key typed at the same time as the host change survives, because it was minted for the new host', async () => {
|
||||
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
|
||||
await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP);
|
||||
|
||||
@@ -617,32 +599,32 @@ describe('DawarichService saveSettings', () => {
|
||||
// No scheme, so the URL constructor throws and the comparison falls back to
|
||||
// string equality. Erring towards keeping the key here is safe precisely
|
||||
// because the text did not change.
|
||||
connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' });
|
||||
await connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' });
|
||||
|
||||
await svc.saveSettings(USER, 'dawarich.example', undefined, false, true, IP);
|
||||
|
||||
expect(row()?.api_key).toBe('stored-key');
|
||||
expect((await row())?.api_key).toBe('stored-key');
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-060: an unparseable stored address against a different one drops the key', async () => {
|
||||
connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' });
|
||||
await connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' });
|
||||
|
||||
await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP);
|
||||
|
||||
expect(row()?.api_key).toBeNull();
|
||||
expect((await row())?.api_key).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-061: a row whose address column is null counts as no previous host, so nothing is dropped', async () => {
|
||||
connect(USER, { url: null, apiKey: 'stored-key' });
|
||||
await connect(USER, { url: null, apiKey: 'stored-key' });
|
||||
|
||||
await svc.saveSettings(USER, HOST, undefined, false, true, IP);
|
||||
|
||||
expect(row()?.api_key).toBe('stored-key');
|
||||
expect(row()?.url).toBe(HOST);
|
||||
expect((await row())?.api_key).toBe('stored-key');
|
||||
expect((await row())?.url).toBe(HOST);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-062: clearing the address wipes the key, because a live credential for a server nobody named is just a stored secret', async () => {
|
||||
connect(USER, {
|
||||
await connect(USER, {
|
||||
apiKey: 'stored-key',
|
||||
lastSyncAt: '2026-09-12T04:15:00.000Z',
|
||||
lastSyncState: 'ok',
|
||||
@@ -653,7 +635,7 @@ describe('DawarichService saveSettings', () => {
|
||||
const out = await svc.saveSettings(USER, ' ', undefined, false, true, IP);
|
||||
|
||||
expect(out).toEqual({ success: true });
|
||||
const stored = row();
|
||||
const stored = await row();
|
||||
expect(stored?.url).toBeNull();
|
||||
expect(stored?.api_key).toBeNull();
|
||||
expect(stored?.capabilities).toBeNull();
|
||||
@@ -663,9 +645,9 @@ describe('DawarichService saveSettings', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-063: a key typed while the address is cleared is discarded with it', async () => {
|
||||
connect(USER, { apiKey: 'stored-key' });
|
||||
await connect(USER, { apiKey: 'stored-key' });
|
||||
await svc.saveSettings(USER, '', 'pointless-key', false, true, IP);
|
||||
expect(row()?.api_key).toBeNull();
|
||||
expect((await row())?.api_key).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-064: an empty address is never resolved, so clearing the connection cannot fail on DNS', async () => {
|
||||
@@ -683,7 +665,7 @@ describe('DawarichService saveSettings', () => {
|
||||
code: 'invalid_url',
|
||||
error: 'Only HTTP and HTTPS URLs are allowed',
|
||||
});
|
||||
expect(row()).toBeUndefined();
|
||||
expect(await row()).toBeNull();
|
||||
expect(audit.writeAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -708,7 +690,7 @@ describe('DawarichService saveSettings', () => {
|
||||
warningCode: 'private_ip',
|
||||
warningIp: '192.168.0.5',
|
||||
});
|
||||
expect(row()?.url).toBe('http://dawarich.lan:3000');
|
||||
expect((await row())?.url).toBe('http://dawarich.lan:3000');
|
||||
expect(audit.writeAudit).toHaveBeenCalledWith({
|
||||
userId: USER,
|
||||
action: 'dawarich.private_ip_configured',
|
||||
@@ -730,7 +712,7 @@ describe('DawarichService saveSettings', () => {
|
||||
const out = await svc.saveSettings(USER, 'http://10.0.0.9:3000', 'k', false, true, IP);
|
||||
|
||||
expect(out).toMatchObject({ success: true, warningCode: 'private_ip', warningIp: '10.0.0.9' });
|
||||
expect(row()?.url).toBe('http://10.0.0.9:3000');
|
||||
expect((await row())?.url).toBe('http://10.0.0.9:3000');
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-069: a private result the resolver could not name still warns, with no IP in the warning payload', async () => {
|
||||
@@ -744,7 +726,7 @@ describe('DawarichService saveSettings', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-070: the address write and the key write are one transaction, so a failed key write cannot leave the old key pointed at the new host', async () => {
|
||||
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
// Rollback proof (R7's transaction, one of the plan's eight): spy the
|
||||
// SECOND write (the key) to throw and confirm the FIRST write (the
|
||||
// address, already committed to the transactional connection) is rolled
|
||||
@@ -760,7 +742,7 @@ describe('DawarichService saveSettings', () => {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
const stored = row();
|
||||
const stored = await row();
|
||||
expect(stored?.url).toBe('https://old.example');
|
||||
expect(stored?.api_key).toBe('stored-key');
|
||||
});
|
||||
@@ -770,8 +752,8 @@ describe('DawarichService saveSettings', () => {
|
||||
// file and then logs the whole install out of Dawarich the first time one
|
||||
// person edits their own URL. Two victims, one bystander, both statements.
|
||||
const bystander = createUser(testDb).user.id;
|
||||
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
connect(bystander, {
|
||||
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
await connect(bystander, {
|
||||
apiKey: 'bystander-key',
|
||||
capabilities: JSON.stringify(FULL_CAPS),
|
||||
lastSyncState: 'ok',
|
||||
@@ -780,8 +762,8 @@ describe('DawarichService saveSettings', () => {
|
||||
await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP);
|
||||
await svc.saveSettings(USER, '', undefined, false, true, IP);
|
||||
|
||||
expect(row()?.api_key).toBeNull();
|
||||
expect(row(bystander)).toMatchObject({
|
||||
expect((await row())?.api_key).toBeNull();
|
||||
expect(await row(bystander)).toMatchObject({
|
||||
url: HOST,
|
||||
api_key: 'bystander-key',
|
||||
last_sync_state: 'ok',
|
||||
@@ -794,7 +776,7 @@ describe('DawarichService saveSettings', () => {
|
||||
// point at. Carrying them over would show the old instance's version and
|
||||
// "last sync ok" on a connection that has never been asked anything, and
|
||||
// the tracks layer would skip an endpoint the new instance does have.
|
||||
connect(USER, {
|
||||
await connect(USER, {
|
||||
url: 'https://old.example',
|
||||
apiKey: 'stored-key',
|
||||
lastSyncAt: '2026-09-12T04:15:00.000Z',
|
||||
@@ -806,7 +788,7 @@ describe('DawarichService saveSettings', () => {
|
||||
await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP);
|
||||
|
||||
expect((await svc.getCredentials(USER))?.apiKey).toBe('minted-for-new');
|
||||
const stored = row();
|
||||
const stored = await row();
|
||||
expect(stored?.url).toBe('https://new.example');
|
||||
expect(stored?.capabilities).toBeNull();
|
||||
expect(stored?.last_sync_state).toBe('never');
|
||||
@@ -815,7 +797,7 @@ describe('DawarichService saveSettings', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-073: a new key on the same host keeps the probe, because it describes the host and not the key', async () => {
|
||||
connect(USER, {
|
||||
await connect(USER, {
|
||||
url: 'https://d.example/api',
|
||||
apiKey: 'stored-key',
|
||||
lastSyncState: 'ok',
|
||||
@@ -825,8 +807,8 @@ describe('DawarichService saveSettings', () => {
|
||||
await svc.saveSettings(USER, 'https://d.example/', 'rotated-key', false, true, IP);
|
||||
|
||||
expect((await svc.getCredentials(USER))?.apiKey).toBe('rotated-key');
|
||||
expect(row()?.capabilities).toBe(JSON.stringify(FULL_CAPS));
|
||||
expect(row()?.last_sync_state).toBe('ok');
|
||||
expect((await row())?.capabilities).toBe(JSON.stringify(FULL_CAPS));
|
||||
expect((await row())?.last_sync_state).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -836,11 +818,11 @@ describe('DawarichService saveSettings', () => {
|
||||
|
||||
describe('DawarichService disconnect', () => {
|
||||
it('DAWARICH-SVC-080: forgets the credential and says so in the audit log', async () => {
|
||||
connect(USER);
|
||||
await connect(USER);
|
||||
|
||||
await svc.disconnect(USER, '198.51.100.7');
|
||||
|
||||
expect(row()).toBeUndefined();
|
||||
expect(await row()).toBeNull();
|
||||
expect((await svc.getConnection(USER)).connected).toBe(false);
|
||||
expect(audit.writeAudit).toHaveBeenCalledWith({
|
||||
userId: USER,
|
||||
@@ -853,18 +835,18 @@ describe('DawarichService disconnect', () => {
|
||||
it('DAWARICH-SVC-081: disconnecting something that was never connected is a no-op that still leaves a trail', async () => {
|
||||
await svc.disconnect(USER, null);
|
||||
|
||||
expect(row()).toBeUndefined();
|
||||
expect(await row()).toBeNull();
|
||||
expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ ip: null }));
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-082: only the calling user loses their connection', async () => {
|
||||
const other = createUser(testDb).user.id;
|
||||
connect(USER);
|
||||
connect(other);
|
||||
await connect(USER);
|
||||
await connect(other);
|
||||
|
||||
await svc.disconnect(USER, null);
|
||||
|
||||
expect(row(other)).toBeDefined();
|
||||
expect(await row(other)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -890,7 +872,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-092: a key typed with no address falls back to the stored address', async () => {
|
||||
connect(USER, { apiKey: 'stored-key' });
|
||||
await connect(USER, { apiKey: 'stored-key' });
|
||||
|
||||
await svc.testConnection(USER, '', 'typed-key', false);
|
||||
|
||||
@@ -898,7 +880,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-093: the mask means "use the stored key", which is what a form that only changed the TLS flag posts back', async () => {
|
||||
connect(USER, { apiKey: 'stored-key' });
|
||||
await connect(USER, { apiKey: 'stored-key' });
|
||||
|
||||
await svc.testConnection(USER, HOST, DAWARICH_KEY_MASK, true);
|
||||
|
||||
@@ -906,7 +888,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-094: a stored connection tested as it stands persists the freshly probed capabilities', async () => {
|
||||
connect(USER, { apiKey: 'stored-key' });
|
||||
await connect(USER, { apiKey: 'stored-key' });
|
||||
|
||||
const out = await svc.testConnection(USER, HOST, undefined, false);
|
||||
|
||||
@@ -915,7 +897,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-095: a test run against a different address than the stored one must not overwrite what is on file', async () => {
|
||||
connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) });
|
||||
await connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) });
|
||||
|
||||
await svc.testConnection(USER, 'https://someone-elses.example', 'their-key', false);
|
||||
|
||||
@@ -926,11 +908,11 @@ describe('DawarichService testConnection', () => {
|
||||
const out = await svc.testConnection(USER, HOST, 'typed-key', false);
|
||||
|
||||
expect(out.connected).toBe(true);
|
||||
expect(row()).toBeUndefined();
|
||||
expect(await row()).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-097: the visit count is the last 30 days, and it is only asked for when the visits endpoint exists', async () => {
|
||||
connect(USER, { apiKey: 'stored-key' });
|
||||
await connect(USER, { apiKey: 'stored-key' });
|
||||
allEndpointsAnswer([visit({ id: 1 }), visit({ id: 2 }), visit({ id: 3 })]);
|
||||
|
||||
const out = await svc.testConnection(USER, HOST, undefined, false);
|
||||
@@ -942,7 +924,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-098: an instance without a visits endpoint reports zero rather than asking a route that is not there', async () => {
|
||||
connect(USER, { apiKey: 'stored-key' });
|
||||
await connect(USER, { apiKey: 'stored-key' });
|
||||
client.listVisits.mockRejectedValue(notFound());
|
||||
|
||||
const out = await svc.testConnection(USER, HOST, undefined, false);
|
||||
@@ -953,7 +935,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-099: a count that fails after the connection is already proven is not a failed connection', async () => {
|
||||
connect(USER, { apiKey: 'stored-key' });
|
||||
await connect(USER, { apiKey: 'stored-key' });
|
||||
client.listVisits
|
||||
.mockResolvedValueOnce({ visits: [visit()], truncated: false, version: '1.14.4' })
|
||||
.mockRejectedValueOnce(new DawarichError('rate_limited', 'HTTP 429', 429));
|
||||
@@ -964,7 +946,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-100: a rejected key is a 200 the form can render, carrying the code the client translates', async () => {
|
||||
connect(USER, { apiKey: 'stored-key' });
|
||||
await connect(USER, { apiKey: 'stored-key' });
|
||||
client.probe.mockRejectedValue(new DawarichError('unauthorized', 'Dawarich answered HTTP 401', 401));
|
||||
|
||||
const out = await svc.testConnection(USER, HOST, undefined, false);
|
||||
@@ -974,7 +956,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-101: the free-text detail rides along when there is one, for the person debugging their own reverse proxy', async () => {
|
||||
connect(USER, { apiKey: 'stored-key' });
|
||||
await connect(USER, { apiKey: 'stored-key' });
|
||||
client.probe.mockRejectedValue(
|
||||
new DawarichError('invalid_response', 'Not JSON', 200, '<html>login</html>'),
|
||||
);
|
||||
@@ -987,7 +969,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-102: anything that is not a DawarichError reads as unreachable rather than leaking the message', async () => {
|
||||
connect(USER, { apiKey: 'stored-key' });
|
||||
await connect(USER, { apiKey: 'stored-key' });
|
||||
client.probe.mockRejectedValue(new Error('ECONNRESET on https://dawarich.example?api_key=secret'));
|
||||
|
||||
expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({
|
||||
@@ -997,7 +979,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-103: a failed probe leaves the stored capabilities untouched', async () => {
|
||||
connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) });
|
||||
await connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) });
|
||||
client.probe.mockRejectedValue(new DawarichError('unauthorized', 'HTTP 401', 401));
|
||||
|
||||
await svc.testConnection(USER, HOST, undefined, false);
|
||||
@@ -1009,7 +991,7 @@ describe('DawarichService testConnection', () => {
|
||||
// The rotated-ENCRYPTION_KEY case reached from the outbound side rather than
|
||||
// through getCredentials: the row still reads as connected on the card, and
|
||||
// the test button has to say what is wrong without sending the blob anywhere.
|
||||
connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
|
||||
await connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' });
|
||||
|
||||
expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({
|
||||
connected: false,
|
||||
@@ -1023,7 +1005,7 @@ describe('DawarichService testConnection', () => {
|
||||
// press Test" is the ordinary way to move an instance. Falling back to the
|
||||
// stored key here would carry it to whatever host was just typed, which is
|
||||
// exactly what saveSettings refuses to persist.
|
||||
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
|
||||
const out = await svc.testConnection(USER, 'https://someone-elses.example', undefined, false);
|
||||
|
||||
@@ -1033,7 +1015,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-106: the mask against a different host is a blank field, not a key', async () => {
|
||||
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
|
||||
const out = await svc.testConnection(USER, 'https://old.example:8443', DAWARICH_KEY_MASK, false);
|
||||
|
||||
@@ -1045,7 +1027,7 @@ describe('DawarichService testConnection', () => {
|
||||
// The mirror of 105: the same instance under a corrected path is where the
|
||||
// key was issued, and refusing it would make every trailing-slash edit
|
||||
// demand the key again.
|
||||
connect(USER, { url: 'https://d.example/api', apiKey: 'stored-key' });
|
||||
await connect(USER, { url: 'https://d.example/api', apiKey: 'stored-key' });
|
||||
|
||||
const out = await svc.testConnection(USER, 'https://d.example/', undefined, false);
|
||||
|
||||
@@ -1054,7 +1036,7 @@ describe('DawarichService testConnection', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-108: a key typed for the new host is used as typed, so moving an instance and testing it first still works', async () => {
|
||||
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
await connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
|
||||
|
||||
const out = await svc.testConnection(USER, 'https://new.example', 'minted-for-new', false);
|
||||
|
||||
@@ -1176,33 +1158,33 @@ describe('DawarichService probeCapabilities', () => {
|
||||
|
||||
describe('DawarichService storeCapabilities', () => {
|
||||
it('DAWARICH-SVC-130: writes the probe where getCapabilities reads it', async () => {
|
||||
connect(USER);
|
||||
await connect(USER);
|
||||
|
||||
await svc.storeCapabilities(USER, FULL_CAPS);
|
||||
|
||||
expect(await svc.getCapabilities(USER)).toEqual(FULL_CAPS);
|
||||
expect(JSON.parse(row()?.capabilities ?? 'null')).toEqual(FULL_CAPS);
|
||||
expect(JSON.parse((await row())?.capabilities ?? 'null')).toEqual(FULL_CAPS);
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-131: storing against a user with no connection changes nothing instead of creating a row without a credential', async () => {
|
||||
await svc.storeCapabilities(USER, FULL_CAPS);
|
||||
expect(row()).toBeUndefined();
|
||||
expect(await row()).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-132: only the named user is touched', async () => {
|
||||
const other = createUser(testDb).user.id;
|
||||
connect(USER);
|
||||
connect(other);
|
||||
await connect(USER);
|
||||
await connect(other);
|
||||
|
||||
await svc.storeCapabilities(USER, FULL_CAPS);
|
||||
|
||||
expect(row(other)?.capabilities).toBeNull();
|
||||
expect((await row(other))?.capabilities).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DawarichService recordSyncResult', () => {
|
||||
it('DAWARICH-SVC-140: a failure is stored with its reason and a timestamp the card can show', async () => {
|
||||
connect(USER);
|
||||
await connect(USER);
|
||||
const before = Date.now();
|
||||
|
||||
await svc.recordSyncResult(USER, 'failed', 'unreachable');
|
||||
@@ -1214,7 +1196,7 @@ describe('DawarichService recordSyncResult', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-141: a later success clears the error rather than leaving the old one on screen', async () => {
|
||||
connect(USER, { lastSyncState: 'failed', lastSyncError: 'unreachable' });
|
||||
await connect(USER, { lastSyncState: 'failed', lastSyncError: 'unreachable' });
|
||||
|
||||
await svc.recordSyncResult(USER, 'ok', null);
|
||||
|
||||
@@ -1222,13 +1204,13 @@ describe('DawarichService recordSyncResult', () => {
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-142: a partial run is its own state, distinct from both ok and failed', async () => {
|
||||
connect(USER);
|
||||
await connect(USER);
|
||||
await svc.recordSyncResult(USER, 'partial', 'too_large');
|
||||
expect((await svc.getConnection(USER)).lastSyncState).toBe('partial');
|
||||
});
|
||||
|
||||
it('DAWARICH-SVC-143: recording against a user with no connection changes nothing', async () => {
|
||||
await svc.recordSyncResult(USER, 'ok', null);
|
||||
expect(row()).toBeUndefined();
|
||||
expect(await row()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { DayBoundariesController } from '../../../src/nest/roadtrip/day-boundari
|
||||
import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp';
|
||||
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
||||
import { RoadtripDayBoundaries } from '../../../src/db/entities/RoadtripDayBoundaries.entity';
|
||||
import { deleteRows } from '../../helpers/factories/rows';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
@@ -71,7 +72,7 @@ it('replaces one day only, persists place snapping and cascades deleted visits',
|
||||
expect(await service.list(tripB.id)).toEqual([]);
|
||||
await service.remove(tripB.id, 1);
|
||||
expect(await service.list(tripA.id)).toHaveLength(2);
|
||||
testDb.prepare('DELETE FROM day_assignments WHERE id = ?').run(toId);
|
||||
await deleteRows(t, DayAssignments, { id: toId });
|
||||
expect(await service.list(tripA.id)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,18 +12,8 @@ vi.mock('../../../src/db/database', async () => {
|
||||
|
||||
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
||||
const db = createSnapshotTestDb();
|
||||
// DatabaseService delegates the joined read straight through to the module, so the
|
||||
// mock has to answer it: without this the insert half of every import throws.
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: any) => {
|
||||
const place: any = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId);
|
||||
return place ? { ...place, category: null, tags: [] } : null;
|
||||
},
|
||||
};
|
||||
return mock;
|
||||
// The joined place read goes through PlacesRepository now; the module only hands out the handle.
|
||||
return { db, closeDb: () => {}, reinitialize: () => {} };
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { createDay, createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
import { updateRows } from '../../helpers/factories/rows';
|
||||
import { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service';
|
||||
import { Days } from '../../../src/db/entities/Days.entity';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
@@ -33,7 +34,7 @@ async function makeService() {
|
||||
);
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
async function fixture() {
|
||||
const { user } = createUser(testDb);
|
||||
const tripA = createTrip(testDb, user.id);
|
||||
const tripB = createTrip(testDb, user.id);
|
||||
@@ -43,17 +44,17 @@ function fixture() {
|
||||
const track = createPlace(testDb, tripA.id, { name: 'Atlantic Road' });
|
||||
const hotel = createPlace(testDb, tripA.id, { name: 'A hotel' });
|
||||
const otherTrack = createPlace(testDb, tripB.id, { name: 'Somebody else’s track' });
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[52,13],[52,14]]', track.id);
|
||||
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[40,2],[40,3]]', otherTrack.id);
|
||||
await updateRows(t, Places, { id: track.id }, { route_geometry: '[[52,13],[52,14]]' });
|
||||
await updateRows(t, Places, { id: otherTrack.id }, { route_geometry: '[[40,2],[40,3]]' });
|
||||
return { tripA, tripB, dayA1, dayA2, dayB1, track, hotel, otherTrack };
|
||||
}
|
||||
|
||||
describe('RoadtripService', () => {
|
||||
let service: RoadtripService;
|
||||
let f: ReturnType<typeof fixture>;
|
||||
let f: Awaited<ReturnType<typeof fixture>>;
|
||||
beforeEach(async () => {
|
||||
service = await makeService();
|
||||
f = fixture();
|
||||
f = await fixture();
|
||||
});
|
||||
|
||||
it('ROADTRIP-SVC-001: a day of another trip is not this trip’s day', async () => {
|
||||
|
||||
@@ -24,6 +24,9 @@ import { StorageRegistryService, BACKENDS_KEY, CATEGORIES_KEY } from '../../../.
|
||||
import { StorageService } from '../../../../src/nest/storage/storage.service';
|
||||
import { StorageConflictError } from '../../../../src/nest/storage/storage.types';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../../helpers/test-uow';
|
||||
import { readAppSetting, setAppSetting } from '../../../helpers/factories/settings';
|
||||
import { deleteRows } from '../../../helpers/factories/rows';
|
||||
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
function makeTmpDir(): string {
|
||||
@@ -31,12 +34,11 @@ function makeTmpDir(): string {
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
function setSetting(key: string, value: string): void {
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
|
||||
async function setSetting(key: string, value: string): Promise<void> {
|
||||
await setAppSetting(await sharedTestOrm(testDb), key, value);
|
||||
}
|
||||
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;
|
||||
async function readRow(key: string): Promise<string | undefined> {
|
||||
return (await readAppSetting(await sharedTestOrm(testDb), key)) ?? undefined;
|
||||
}
|
||||
|
||||
const S3_OPTIONS = {
|
||||
@@ -53,7 +55,7 @@ const S3_OPTIONS = {
|
||||
/** Real registry + real service over the in-memory DB. */
|
||||
async function makeService(opts: { uploadsRoot?: string } = {}) {
|
||||
const uploadsRoot = opts.uploadsRoot ?? makeTmpDir();
|
||||
setSetting(BACKENDS_KEY, JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }]));
|
||||
await setSetting(BACKENDS_KEY, JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }]));
|
||||
const env = { placePhotoDir: undefined };
|
||||
const uow = await createTestUnitOfWork(testDb);
|
||||
const appSettings = await createTestAppSettingsRepo(testDb);
|
||||
@@ -92,8 +94,8 @@ async function put(service: StorageAdminService, config: StorageConfig): Promise
|
||||
await service.applyConfig({ ...config, version: (await (await service.state())).version });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'storage.%'").run();
|
||||
beforeEach(async () => {
|
||||
await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -138,7 +140,7 @@ describe('StorageAdminService.state', () => {
|
||||
const { service, registry } = await makeService();
|
||||
expect((await service.state()).configError).toBeNull();
|
||||
|
||||
setSetting(CATEGORIES_KEY, 'garbage {');
|
||||
await setSetting(CATEGORIES_KEY, 'garbage {');
|
||||
await registry.reload();
|
||||
expect((await service.state()).configError).toBe(registry.lastLoadError());
|
||||
expect((await service.state()).configError).not.toBeNull();
|
||||
@@ -163,7 +165,7 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
categories: { backups: 'nas-backups' },
|
||||
}));
|
||||
expect(registry.resolve('backups').backendName).toBe('nas-backups'); // reload() ran
|
||||
expect(JSON.parse(readRow(CATEGORIES_KEY)!)).toEqual({ backups: 'nas-backups' });
|
||||
expect(JSON.parse((await readRow(CATEGORIES_KEY))!)).toEqual({ backups: 'nas-backups' });
|
||||
});
|
||||
|
||||
it('STORADM-011 encrypts plaintext secrets at rest', async () => {
|
||||
@@ -172,7 +174,7 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
|
||||
categories: { backups: 'off-box' },
|
||||
}));
|
||||
const stored = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record<string, unknown> }>;
|
||||
const stored = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record<string, unknown> }>;
|
||||
const secret = String(stored.find((b) => b.name === 'off-box')!.options.secretAccessKey);
|
||||
expect(secret.startsWith('enc:v1:')).toBe(true);
|
||||
});
|
||||
@@ -183,27 +185,27 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
|
||||
categories: { backups: 'off-box' },
|
||||
}));
|
||||
const before = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record<string, unknown> }>;
|
||||
const before = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record<string, unknown> }>;
|
||||
const cipherBefore = before.find((b) => b.name === 'off-box')!.options.secretAccessKey;
|
||||
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } }],
|
||||
categories: { backups: 'off-box' },
|
||||
}));
|
||||
const after = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record<string, unknown> }>;
|
||||
const after = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record<string, unknown> }>;
|
||||
expect(after.find((b) => b.name === 'off-box')!.options.secretAccessKey).toBe(cipherBefore);
|
||||
});
|
||||
|
||||
it('STORADM-013 a mask on a renamed/new backend throws the re-enter error, persists nothing', async () => {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
const before = readRow(BACKENDS_KEY);
|
||||
const before = await readRow(BACKENDS_KEY);
|
||||
await expect(
|
||||
put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'brand-new', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } }],
|
||||
categories: {},
|
||||
})),
|
||||
).rejects.toThrow("re-enter the secret 'secretAccessKey' for 'brand-new'");
|
||||
expect(readRow(BACKENDS_KEY)).toBe(before);
|
||||
expect(await readRow(BACKENDS_KEY)).toBe(before);
|
||||
});
|
||||
|
||||
it('STORADM-014 a plaintext secret saves without an explicit ENCRYPTION_KEY and is still encrypted at rest', async () => {
|
||||
@@ -213,7 +215,7 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
|
||||
categories: {},
|
||||
}));
|
||||
const stored = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record<string, string> }>;
|
||||
const stored = JSON.parse((await readRow(BACKENDS_KEY))!) as Array<{ name: string; options: Record<string, string> }>;
|
||||
const offBox = stored.find((b) => b.name === 'off-box')!;
|
||||
expect(offBox.options.secretAccessKey.startsWith('enc:v1:')).toBe(true);
|
||||
});
|
||||
@@ -225,29 +227,29 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: encrypt_api_key('sk') } }],
|
||||
categories: { backups: 'off-box' },
|
||||
}));
|
||||
expect(readRow(BACKENDS_KEY)).toBeDefined();
|
||||
expect(await readRow(BACKENDS_KEY)).toBeDefined();
|
||||
});
|
||||
|
||||
it('STORADM-016 preview() refusals surface verbatim and persist nothing', async () => {
|
||||
const { service, uploadsRoot, registry } = await makeService();
|
||||
const before = readRow(CATEGORIES_KEY);
|
||||
const before = await readRow(CATEGORIES_KEY);
|
||||
await expect(
|
||||
put(service, configWith(uploadsRoot, { categories: { backups: 'nope' } })),
|
||||
).rejects.toThrow("category 'backups' maps to unknown backend 'nope'");
|
||||
expect(readRow(CATEGORIES_KEY)).toBe(before);
|
||||
expect(await readRow(CATEGORIES_KEY)).toBe(before);
|
||||
expect(registry.resolve('backups').backendName).toBe('backups-local'); // live state untouched
|
||||
});
|
||||
|
||||
it('STORADM-018 a mask sentinel in a NON-secret field throws and persists nothing', async () => {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
const before = readRow(BACKENDS_KEY);
|
||||
const before = await readRow(BACKENDS_KEY);
|
||||
await expect(
|
||||
put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, accessKeyId: MASKED_SETTING_VALUE } }],
|
||||
categories: {},
|
||||
})),
|
||||
).rejects.toThrow("backend 'off-box' field 'accessKeyId' is the mask sentinel — a mask can never become a stored value");
|
||||
expect(readRow(BACKENDS_KEY)).toBe(before);
|
||||
expect(await readRow(BACKENDS_KEY)).toBe(before);
|
||||
});
|
||||
|
||||
it('STORADM-017 persists both rows in ONE transaction and reloads once', async () => {
|
||||
@@ -284,8 +286,8 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
it('STORADM-031 a stale submitted version throws StorageConflictError and persists nothing (→ 409, audit #7)', async () => {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
await put(service, configWith(uploadsRoot)); // version is now 1
|
||||
const beforeBackends = readRow(BACKENDS_KEY);
|
||||
const beforeCategories = readRow(CATEGORIES_KEY);
|
||||
const beforeBackends = await readRow(BACKENDS_KEY);
|
||||
const beforeCategories = await readRow(CATEGORIES_KEY);
|
||||
await expect(
|
||||
service.applyConfig({ ...configWith(uploadsRoot), version: 0 }), // stale — current is 1
|
||||
).rejects.toThrow(StorageConflictError);
|
||||
@@ -297,8 +299,8 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
expect((err as StorageConflictError).currentVersion).toBe(1);
|
||||
expect((err as StorageConflictError).submittedVersion).toBe(0);
|
||||
}
|
||||
expect(readRow(BACKENDS_KEY)).toBe(beforeBackends);
|
||||
expect(readRow(CATEGORIES_KEY)).toBe(beforeCategories);
|
||||
expect(await readRow(BACKENDS_KEY)).toBe(beforeBackends);
|
||||
expect(await readRow(CATEGORIES_KEY)).toBe(beforeCategories);
|
||||
expect((await service.state()).version).toBe(1); // unchanged — the conflicting save never wrote
|
||||
});
|
||||
|
||||
@@ -452,7 +454,7 @@ describe('StorageAdminService.testBackend', () => {
|
||||
// applyConfig encrypted the secret at rest — the snapshot the mirror
|
||||
// expansion reads holds ciphertext, so this probe exercises the full
|
||||
// snapshot → decryptBackendSecrets → ephemeral S3Driver chain.
|
||||
expect(readRow(BACKENDS_KEY)).toContain('enc:v1:');
|
||||
expect(await readRow(BACKENDS_KEY)).toContain('enc:v1:');
|
||||
const result = await service.testBackend({
|
||||
name: 'cand-mirror',
|
||||
type: 'mirror',
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
||||
import { deleteRows, insertRow } from '../../helpers/factories/rows';
|
||||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||||
|
||||
// Category-addressed since slice 4: originals + thumbs are ('journey', <name>)
|
||||
@@ -136,13 +137,13 @@ describe('sweepOrphanThumbs (spec fix #2)', () => {
|
||||
return fp;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
thumbsDb.prepare('DELETE FROM trek_photos').run();
|
||||
beforeEach(async () => {
|
||||
await deleteRows(t, TrekPhotos);
|
||||
fs.rmSync(thumbsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('THUMB-SWEEP-001: deletes strays, spares thumbs derivable from live journey rows', async () => {
|
||||
thumbsDb.prepare("INSERT INTO trek_photos (provider, file_path) VALUES ('local', ?)").run('journey/live.jpg');
|
||||
await insertRow(t, TrekPhotos, { provider: 'local', file_path: 'journey/live.jpg' });
|
||||
const liveName = journeyThumbName('journey/live.jpg'); // 'thumbs/<hash>.jpg'
|
||||
const livePath = writeThumb(path.basename(liveName));
|
||||
const strayPath = writeThumb('deadbeefdeadbeef.jpg');
|
||||
@@ -155,8 +156,11 @@ describe('sweepOrphanThumbs (spec fix #2)', () => {
|
||||
});
|
||||
|
||||
it('THUMB-SWEEP-002: spares a recorded thumbnail_path even without a matching file_path hash', async () => {
|
||||
thumbsDb.prepare("INSERT INTO trek_photos (provider, file_path, thumbnail_path) VALUES ('local', ?, ?)")
|
||||
.run('elsewhere/x.jpg', 'journey/thumbs/recorded00000000.jpg');
|
||||
await insertRow(t, TrekPhotos, {
|
||||
provider: 'local',
|
||||
file_path: 'elsewhere/x.jpg',
|
||||
thumbnail_path: 'journey/thumbs/recorded00000000.jpg',
|
||||
});
|
||||
const recorded = writeThumb('recorded00000000.jpg');
|
||||
|
||||
const removed = await svc.sweepOrphanThumbs();
|
||||
|
||||
@@ -20,6 +20,7 @@ import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { makePlugin } from '../../helpers/factories/plugins';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
@@ -51,13 +52,17 @@ afterAll(async () => { await t.close(); testDb.close(); });
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/** installedDepRows() reads the `plugins` table directly — no discovery needed. */
|
||||
function installEnabledPlugin(id: string): void {
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT INTO plugins (id, name, status, enabled, version, api_version, permissions, granted_permissions, capabilities, config, dependencies)
|
||||
VALUES (?, ?, 'inactive', 1, '1.0.0', 1, '[]', '[]', '{}', '{}', '{}')`,
|
||||
)
|
||||
.run(id, id);
|
||||
async function installEnabledPlugin(id: string): Promise<void> {
|
||||
await makePlugin(t, id, {
|
||||
status: 'inactive',
|
||||
enabled: 1,
|
||||
api_version: 1,
|
||||
permissions: '[]',
|
||||
granted_permissions: '[]',
|
||||
capabilities: '{}',
|
||||
config: '{}',
|
||||
dependencies: '{}',
|
||||
});
|
||||
}
|
||||
|
||||
async function buildRuntime(withOrm: boolean): Promise<PluginRuntimeService> {
|
||||
@@ -94,7 +99,7 @@ async function buildRuntime(withOrm: boolean): Promise<PluginRuntimeService> {
|
||||
|
||||
describe('PluginRuntimeService.onApplicationBootstrap — absent-ORM boot branch (task-6-rereview2.md I-A)', () => {
|
||||
it('RT-BOOT-NOORM-001: without an ORM, boot logs the skip line and never calls activate', async () => {
|
||||
installEnabledPlugin('rtboot-noorm');
|
||||
await installEnabledPlugin('rtboot-noorm');
|
||||
const rt = await buildRuntime(false);
|
||||
const activateSpy = vi.spyOn(rt, 'activate');
|
||||
|
||||
@@ -113,7 +118,7 @@ describe('PluginRuntimeService.onApplicationBootstrap — absent-ORM boot branch
|
||||
});
|
||||
|
||||
it('RT-BOOT-NOORM-002: WITH an ORM, the same arrangement activates the plugin — proves case 001 is not vacuous', async () => {
|
||||
installEnabledPlugin('rtboot-withorm');
|
||||
await installEnabledPlugin('rtboot-withorm');
|
||||
const rt = await buildRuntime(true);
|
||||
const activateSpy = vi.spyOn(rt, 'activate').mockResolvedValue(undefined);
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ const tripsRegistry = () => {
|
||||
} as unknown as TripsRepository;
|
||||
const guards = new PluginGuards(db, {} as never, {} as never, {} as never);
|
||||
// Plan 3c Task 7: trips.getById (RP1) now reads through
|
||||
// `EntityManager.getRepository(Trips).findRaw(...)`, not `db.prepare(...)`.
|
||||
// `EntityManager.getRepository(Trips).findRaw(...)`, not a raw statement on the handle.
|
||||
const em = {
|
||||
getRepository: () => ({ findRaw: async () => ({ id: 1, title: 'Japan', feed_token: null }) }),
|
||||
} as unknown as EntityManager;
|
||||
|
||||
@@ -8,16 +8,10 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
|
||||
vi.mock('../../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
||||
const db = createSnapshotTestDb();
|
||||
function getPlaceWithTags(placeId: number | string) {
|
||||
const p = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId);
|
||||
if (!p) return null;
|
||||
return { ...(p as object), category: null, tags: [] };
|
||||
}
|
||||
return {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags,
|
||||
canAccessTrip: async () => null,
|
||||
isOwner: async () => false,
|
||||
};
|
||||
@@ -30,6 +24,8 @@ vi.mock('../../../src/config', () => ({
|
||||
|
||||
import { db as testDb } from '../../../src/db/database';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { findRow } from '../../helpers/factories/rows';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { accommodationsOver } from '../../helpers/accommodations-service';
|
||||
import { isUpdateConflict } from '../../../src/nest/common/conflictResult';
|
||||
@@ -190,8 +186,8 @@ describe('PlacesService.update — optimistic concurrency', () => {
|
||||
expect((result.server as { name: string }).name).toBe('Original');
|
||||
}
|
||||
// The row must NOT have been overwritten.
|
||||
const row = testDb.prepare('SELECT name FROM places WHERE id = ?').get(place.id) as { name: string };
|
||||
expect(row.name).toBe('Original');
|
||||
const row = await findRow(await sharedTestOrm(testDb), Places, { id: place.id });
|
||||
expect(row?.name).toBe('Original');
|
||||
});
|
||||
|
||||
it('returns null for a place that does not exist', async () => {
|
||||
@@ -203,6 +199,7 @@ describe('PlacesService.update — optimistic concurrency', () => {
|
||||
|
||||
describe('updateItem (packing) — optimistic concurrency', () => {
|
||||
it('migration added updated_at and createItem stamps it', async () => {
|
||||
// test-sql-allow: schema introspection, which no entity or repository maps.
|
||||
const cols = testDb.prepare("PRAGMA table_info('packing_items')").all() as { name: string }[];
|
||||
expect(cols.map(c => c.name)).toContain('updated_at');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user