diff --git a/server/tests/e2e/dawarich.e2e.test.ts b/server/tests/e2e/dawarich.e2e.test.ts index a96410db7..20912a8e7 100644 --- a/server/tests/e2e/dawarich.e2e.test.ts +++ b/server/tests/e2e/dawarich.e2e.test.ts @@ -32,12 +32,6 @@ vi.mock('../../src/db/database', async () => { closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, - canAccessTrip: (tripId: number | string, userId: number) => - db - .prepare( - 'SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)', - ) - .get(userId, tripId, userId), isOwner: () => false, }; }); @@ -53,7 +47,12 @@ import { AddonsService } from '../../src/nest/addons/addons.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { countRows, deleteRows, findRow, insertRow } from '../helpers/factories/rows'; +import { DawarichConnections } from '../../src/db/entities/DawarichConnections.entity'; +import { DawarichVisitSuggestions } from '../../src/db/entities/DawarichVisitSuggestions.entity'; + +let orm: TestOrm; /** * Every outbound call an instance would ever receive, stubbed to throw. A route @@ -75,15 +74,12 @@ function makeClientStub() { } /** A suggestion row with only the NOT NULL columns filled in. */ -function seedSuggestion(userId: number, sourceVisitId: string, name: string): number { - const info = db - .prepare( - `INSERT INTO dawarich_visit_suggestions - (user_id, source_visit_id, name, lat, lng, started_at, ended_at, duration_minutes, local_date, source_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run(userId, sourceVisitId, name, 48.8584, 2.2945, '2026-05-01T10:00:00+02:00', '2026-05-01T12:30:00+02:00', 150, '2026-05-01', 'hash-' + sourceVisitId); - return Number(info.lastInsertRowid); +function seedSuggestion(userId: number, sourceVisitId: string, name: string): Promise { + return insertRow(orm, DawarichVisitSuggestions, { + user: userId, source_visit_id: sourceVisitId, name, lat: 48.8584, lng: 2.2945, + started_at: '2026-05-01T10:00:00+02:00', ended_at: '2026-05-01T12:30:00+02:00', duration_minutes: 150, + local_date: '2026-05-01', source_hash: 'hash-' + sourceVisitId, + }); } describe('Dawarich e2e (real addon gate + real auth guard + real services + temp SQLite)', () => { @@ -111,21 +107,23 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp } beforeAll(async () => { + orm = await createTestOrm(db); ownerId = createUser(db as never, { username: 'dawarich-owner', email: 'dawarich-owner@test.example' }).user.id; strangerId = createUser(db as never, { username: 'dawarich-stranger', email: 'dawarich-stranger@test.example' }).user.id; - strangerSuggestionId = seedSuggestion(strangerId, 'visit-stranger-1', 'Eiffel Tower'); + strangerSuggestionId = await seedSuggestion(strangerId, 'visit-stranger-1', 'Eiffel Tower'); app = await build(); server = app.getHttpServer(); }); - beforeEach(() => { + beforeEach(async () => { isAddonEnabled.mockReturnValue(true); - db.prepare('DELETE FROM dawarich_connections').run(); - db.prepare('DELETE FROM dawarich_visit_suggestions WHERE user_id = ?').run(ownerId); + await deleteRows(orm, DawarichConnections); + await deleteRows(orm, DawarichVisitSuggestions, { user: ownerId }); }); afterAll(async () => { await app.close(); + await orm.close(); }); // ── Auth + addon gate ──────────────────────────────────────────────────── @@ -158,7 +156,7 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp .set('Cookie', sessionCookie(ownerId)) .send({ url: 'https://dawarich.example', apiKey: 'secret' }); expect(res.status).toBe(404); - expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 }); + expect(await countRows(orm, DawarichConnections)).toBe(0); }); it('DAWARICH-E2E-005: the addon flag is re-read per request — switching it back on reopens the route without a restart', async () => { @@ -198,7 +196,7 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'Invalid URL' }); // Crucially: the key is not stored on a rejected address either. - expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 }); + expect(await countRows(orm, DawarichConnections)).toBe(0); }); it('DAWARICH-E2E-012: PUT settings with a non-HTTP scheme is a 400 — file:// and friends never reach the fetch layer', async () => { @@ -209,7 +207,7 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'Only HTTP and HTTPS URLs are allowed' }); - expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 }); + expect(await countRows(orm, DawarichConnections)).toBe(0); }); it('DAWARICH-E2E-013: PUT settings rejects a body the shared contract does not accept, before any of this runs', async () => { @@ -230,8 +228,8 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp expect(put.status).toBe(200); expect(put.body).toEqual({ success: true }); - const row = db.prepare('SELECT url, api_key, sync_enabled FROM dawarich_connections WHERE user_id = ?').get(ownerId); - expect(row).toEqual({ url: null, api_key: null, sync_enabled: 0 }); + const row = await findRow(orm, DawarichConnections, { user: ownerId }); + expect(row).toMatchObject({ url: null, api_key: null, sync_enabled: 0 }); const res = await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId)); expect(res.body.connected).toBe(false); @@ -250,11 +248,7 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp // the form just typed. The client stub throws on every call, so a probe // that did go out would surface as `unreachable` here rather than as the // refusal the form is meant to render. - db.prepare('INSERT INTO dawarich_connections (user_id, url, api_key) VALUES (?, ?, ?)').run( - ownerId, - 'https://dawarich.old.example', - 'plain-legacy-key', - ); + await insertRow(orm, DawarichConnections, { user: ownerId, url: 'https://dawarich.old.example', api_key: 'plain-legacy-key' }); const res = await request(server) .post('/api/integrations/dawarich/test') @@ -265,7 +259,7 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp expect(res.body).toMatchObject({ connected: false, error: 'not_connected' }); expect(res.body.errorDetail).toContain('https://dawarich.old.example'); // And the stored connection is untouched by a test, as it always was. - expect(db.prepare('SELECT url, api_key FROM dawarich_connections WHERE user_id = ?').get(ownerId)).toEqual({ + expect(await findRow(orm, DawarichConnections, { user: ownerId })).toMatchObject({ url: 'https://dawarich.old.example', api_key: 'plain-legacy-key', }); @@ -304,7 +298,7 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp // ── Cross-user isolation ───────────────────────────────────────────────── it("DAWARICH-E2E-030: another user's suggestion never appears in your own list", async () => { - const mine = seedSuggestion(ownerId, 'visit-mine-1', 'Gare du Nord'); + const mine = await seedSuggestion(ownerId, 'visit-mine-1', 'Gare du Nord'); const res = await request(server).get('/api/integrations/dawarich/suggestions').set('Cookie', sessionCookie(ownerId)); expect(res.status).toBe(200); @@ -313,8 +307,7 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp expect(ids).not.toContain(strangerSuggestionId); // And the row really is there for the other user, so this is isolation and // not an empty table. - expect(db.prepare('SELECT user_id FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId)) - .toEqual({ user_id: strangerId }); + expect((await findRow(orm, DawarichVisitSuggestions, { id: strangerSuggestionId }))?.user_id).toBe(strangerId); }); it("DAWARICH-E2E-031: PUT state on another user's suggestion is a 404 — the same answer a missing id gets, so nothing is enumerable", async () => { @@ -325,8 +318,7 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Suggestion not found' }); - expect(db.prepare('SELECT state FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId)) - .toEqual({ state: 'new' }); + expect((await findRow(orm, DawarichVisitSuggestions, { id: strangerSuggestionId }))?.state).toBe('new'); const missing = await request(server) .put('/api/integrations/dawarich/suggestions/999999/state') @@ -344,12 +336,11 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Suggestion not found', code: 'not_found' }); - expect(db.prepare('SELECT state, target FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId)) - .toEqual({ state: 'new', target: null }); + expect((await findRow(orm, DawarichVisitSuggestions, { id: strangerSuggestionId }))).toMatchObject({ state: 'new', target: null }); }); it('DAWARICH-E2E-033: your own suggestion is reachable through the same route, which is what makes the two 404s above meaningful', async () => { - const mine = seedSuggestion(ownerId, 'visit-mine-2', 'Musée d’Orsay'); + const mine = await seedSuggestion(ownerId, 'visit-mine-2', 'Musée d’Orsay'); const res = await request(server) .put(`/api/integrations/dawarich/suggestions/${mine}/state`) @@ -359,6 +350,6 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp expect(res.status).toBe(200); expect(res.body.id).toBe(mine); expect(res.body.state).toBe('dismissed'); - expect(db.prepare('SELECT state FROM dawarich_visit_suggestions WHERE id = ?').get(mine)).toEqual({ state: 'dismissed' }); + expect((await findRow(orm, DawarichVisitSuggestions, { id: mine }))?.state).toBe('dismissed'); }); }); diff --git a/server/tests/unit/nest/place-photos/place-photo-cache.service.test.ts b/server/tests/unit/nest/place-photos/place-photo-cache.service.test.ts index a31642f5b..557bc2f1d 100644 --- a/server/tests/unit/nest/place-photos/place-photo-cache.service.test.ts +++ b/server/tests/unit/nest/place-photos/place-photo-cache.service.test.ts @@ -16,10 +16,10 @@ * `collection_places` half, closing out the Plan 3h carve-out): the legacy * version cast a hand-rolled 3-table SQLite fixture to `DatabaseService`; * that cannot survive the service now calling three repositories for its - * owned reads/writes. The same minimal 3-table fixture stays (every column - * these repositories touch is already in it — the R8 `createSnapshotTestDb()` - * migration only applies once a converted read needs a column the hand-rolled - * DDL omits, which is not the case here), wired through `createTestOrm()`. + * owned reads/writes. The suite runs on the migrated snapshot + * (`createSnapshotTestDb()`), wired through `createTestOrm()`, and seeds the + * referencing places and saved places through the ORM factories, which need + * the full rows a hand-rolled table could not give them. * Plan 4 Task 4 dropped the now-unused `DatabaseService` injection entirely. */ import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; @@ -28,34 +28,12 @@ import fs from 'node:fs'; import crypto from 'node:crypto'; import { Jimp, JimpMime } from 'jimp'; -const { testDb } = vi.hoisted(() => { - const Db = require('better-sqlite3'); - return { testDb: new Db(':memory:') }; +vi.mock('../../../../src/db/database', async () => { + const { createSnapshotTestDb, buildDbMock } = await import('../../../helpers/db-mock'); + return buildDbMock(createSnapshotTestDb()); }); -// Minimal real DB with just the tables the cache touches. isReferenced -// UNIONs collection_places (#1081 photo-cache fix), so the bare fixture must -// declare it too or the reference check would throw "no such table". -testDb.exec(` - CREATE TABLE places ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - google_place_id TEXT, - image_url TEXT - ); - CREATE TABLE collection_places ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - google_place_id TEXT, - image_url TEXT - ); - CREATE TABLE google_place_photo_meta ( - place_id TEXT PRIMARY KEY, - attribution TEXT, - fetched_at INTEGER NOT NULL, - error_at INTEGER - ); -`); - -vi.mock('../../../../src/db/database', () => ({ db: testDb })); +import { db as testDb } from '../../../../src/db/database'; import { PlacePhotoCacheService } from '../../../../src/nest/place-photos/place-photo-cache.service'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; @@ -66,6 +44,11 @@ import type { PlacesRepository } from '../../../../src/db/repositories/Places.re import { CollectionPlaces } from '../../../../src/db/entities/CollectionPlaces.entity'; import type { CollectionPlacesRepository } from '../../../../src/db/repositories/CollectionPlaces.repository'; import { makeStorageFixture, type StorageFixture } from '../../../helpers/storage-fixture'; +import { findRow, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { makeUser } from '../../../helpers/factories/users'; +import { makeTrip } from '../../../helpers/factories/trips'; +import { makePlace } from '../../../helpers/factories/places'; +import { makeCollection, makeCollectionPlace } from '../../../helpers/factories/collections'; async function makeJpeg(width: number, height: number): Promise { const img = new Jimp({ width, height, color: 0xff0000ff }); @@ -80,6 +63,16 @@ let t: TestOrm; let metaRepo: GooglePlacePhotoMetaRepository; let placesRepo: PlacesRepository; let collectionPlacesRepo: CollectionPlacesRepository; +/** The trip and the collection the referencing rows hang off; the per-case reset leaves both alone. */ +let ownerId: number; +let tripId: number; +let collectionId: number; + +const referenceByPlace = (overrides: { google_place_id?: string; image_url?: string }) => makePlace(t, tripId, overrides); +const referenceByCollectionPlace = (googlePlaceId: string) => + makeCollectionPlace(t, collectionId, ownerId, { google_place_id: googlePlaceId }); +const insertMeta = (placeId: string, attribution: string | null, fetchedAt: number) => + insertRow(t, GooglePlacePhotoMeta, { place_id: placeId, attribution, fetched_at: fetchedAt }); // Task 1 fix review L3: this is the one suite in this commit that opts out of // the request-context ratchet (`allowGlobalContext: true`, disabling @@ -104,8 +97,11 @@ beforeAll(async () => { metaRepo = t.repo(GooglePlacePhotoMeta); placesRepo = t.repo(Places); collectionPlacesRepo = t.repo(CollectionPlaces); + ownerId = (await makeUser(t)).user.id; + tripId = (await makeTrip(t, ownerId)).id; + collectionId = (await makeCollection(t, ownerId)).id; }); -afterAll(async () => { await t.close(); }); +afterAll(async () => { await t.close(); testDb.close(); }); describe.each([ ['mode A (photos/google/ prefix)', 'photos/google/'], @@ -180,12 +176,10 @@ describe.each([ describe('get()', () => { it('PPC-013: purges the meta row and returns null when the object vanished', async () => { - testDb - .prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at) VALUES (?, ?, ?)') - .run('gone-place', 'Bob', Date.now()); + await insertMeta('gone-place', 'Bob', Date.now()); expect(await cache.get('gone-place')).toBeNull(); - expect(testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('gone-place')).toBeUndefined(); + expect(await findRow(t, GooglePlacePhotoMeta, { place_id: 'gone-place' })).toBeNull(); }); it('PPC-014: returns the proxy URL + attribution for a cached photo', async () => { @@ -205,9 +199,7 @@ describe.each([ // outcome on a genuine race: two concurrent `get()` calls for a // never-checked placeId whose storage object is missing. it('PPC-017 (§18.4 concurrency): two concurrent gets on a row whose storage object is missing both resolve null; the loser\'s delete is a harmless no-op', async () => { - testDb - .prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at) VALUES (?, ?, ?)') - .run('race-place', 'Dana', Date.now()); + await insertMeta('race-place', 'Dana', Date.now()); const deleteSpy = vi.spyOn(metaRepo, 'deleteByPlaceId'); try { @@ -222,7 +214,7 @@ describe.each([ expect(deleteSpy).toHaveBeenCalledTimes(2); expect(deleteSpy).toHaveBeenCalledWith('race-place'); // Exactly one row existed to begin with — gone either way, not double-deleted into an error. - expect(testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('race-place')).toBeUndefined(); + expect(await findRow(t, GooglePlacePhotoMeta, { place_id: 'race-place' })).toBeNull(); } finally { deleteSpy.mockRestore(); } @@ -237,12 +229,12 @@ describe.each([ await cache.removeIfUnreferenced('orphan'); expect(fs.existsSync(filePathFor('orphan'))).toBe(false); - expect(testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('orphan')).toBeUndefined(); + expect(await findRow(t, GooglePlacePhotoMeta, { place_id: 'orphan' })).toBeNull(); }); it('PPC-005: keeps an entry still referenced by google_place_id', async () => { await cache.put('gid-1', await makeJpeg(50, 50), null); - testDb.prepare('INSERT INTO places (google_place_id) VALUES (?)').run('gid-1'); + await referenceByPlace({ google_place_id: 'gid-1' }); await cache.removeIfUnreferenced('gid-1'); @@ -253,7 +245,7 @@ describe.each([ const id = 'coords:48.8:2.3'; await cache.put(id, await makeJpeg(50, 50), null); const proxy = `/api/maps/place-photo/${encodeURIComponent(id)}/bytes`; - testDb.prepare('INSERT INTO places (image_url) VALUES (?)').run(proxy); + await referenceByPlace({ image_url: proxy }); await cache.removeIfUnreferenced(id); @@ -267,7 +259,7 @@ describe.each([ it('PPC-016: keeps an entry referenced only through collection_places, proving the repository-backed fallback still fires', async () => { const id = 'coll-only'; await cache.put(id, await makeJpeg(50, 50), null); - testDb.prepare('INSERT INTO collection_places (google_place_id) VALUES (?)').run(id); + await referenceByCollectionPlace(id); await cache.removeIfUnreferenced(id); @@ -279,7 +271,7 @@ describe.each([ it('PPC-007: removes orphaned meta rows + files, keeps referenced ones, deletes stray files', async () => { await cache.put('keep-gid', await makeJpeg(50, 50), null); await cache.put('drop-me', await makeJpeg(50, 50), null); - testDb.prepare('INSERT INTO places (google_place_id) VALUES (?)').run('keep-gid'); + await referenceByPlace({ google_place_id: 'keep-gid' }); // A stray .jpg on disk with no meta row (e.g. a crash between write and upsert). const strayPath = path.join(prefixDir(), 'deadbeef'.padEnd(40, '0') + '.jpg'); @@ -290,14 +282,14 @@ describe.each([ expect(fs.existsSync(filePathFor('keep-gid'))).toBe(true); expect(fs.existsSync(filePathFor('drop-me'))).toBe(false); expect(fs.existsSync(strayPath)).toBe(false); - expect(testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('drop-me')).toBeUndefined(); - expect(testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('keep-gid')).toBeDefined(); + expect(await findRow(t, GooglePlacePhotoMeta, { place_id: 'drop-me' })).toBeNull(); + expect(await findRow(t, GooglePlacePhotoMeta, { place_id: 'keep-gid' })).not.toBeNull(); expect(removed).toBe(2); // drop-me (orphan meta+file) + stray file }); it('PPC-008: returns 0 when every entry is referenced', async () => { await cache.put('ref-a', await makeJpeg(50, 50), null); - testDb.prepare('INSERT INTO places (google_place_id) VALUES (?)').run('ref-a'); + await referenceByPlace({ google_place_id: 'ref-a' }); expect(await cache.sweepOrphans()).toBe(0); expect(fs.existsSync(filePathFor('ref-a'))).toBe(true); @@ -320,25 +312,23 @@ describe.each([ }); describe('negative cache', () => { - function ageError(placeId: string, ms: number): void { - testDb - .prepare('UPDATE google_place_photo_meta SET error_at = ? WHERE place_id = ?') - .run(Date.now() - ms, placeId); + async function ageError(placeId: string, ms: number): Promise { + await updateRows(t, GooglePlacePhotoMeta, { place_id: placeId }, { error_at: Date.now() - ms }); } it('PPC-009: a place with no photo stays remembered well past the old five-minute window', async () => { await cache.markError('photo-less'); - ageError('photo-less', 30 * 60 * 1000); + await ageError('photo-less', 30 * 60 * 1000); expect(await cache.getErrored('photo-less')).toBe(true); - ageError('photo-less', 5 * 60 * 60 * 1000); + await ageError('photo-less', 5 * 60 * 60 * 1000); expect(await cache.getErrored('photo-less')).toBe(true); }); it('PPC-010: the miss expires once it is a day old', async () => { await cache.markError('stale-miss'); - ageError('stale-miss', 24 * 60 * 60 * 1000); + await ageError('stale-miss', 24 * 60 * 60 * 1000); expect(await cache.getErrored('stale-miss')).toBe(false); }); @@ -351,14 +341,14 @@ describe.each([ await cache.markError('flaky', 'provider-error'); expect(await cache.getErrored('flaky')).toBe(true); // Nothing on disk — a restart retries instead of inheriting someone's outage. - expect(testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('flaky')).toBeUndefined(); + expect(await findRow(t, GooglePlacePhotoMeta, { place_id: 'flaky' })).toBeNull(); vi.advanceTimersByTime(5 * 60 * 1000); expect(await cache.getErrored('flaky')).toBe(false); // The long window belongs to the other case: same age, still remembered. await cache.markError('photo-less-too'); - ageError('photo-less-too', 5 * 60 * 1000); + await ageError('photo-less-too', 5 * 60 * 1000); expect(await cache.getErrored('photo-less-too')).toBe(true); } finally { vi.useRealTimers(); diff --git a/server/tests/unit/nest/trek-photo-cache.service.test.ts b/server/tests/unit/nest/trek-photo-cache.service.test.ts index ebcc68def..742d3ff55 100644 --- a/server/tests/unit/nest/trek-photo-cache.service.test.ts +++ b/server/tests/unit/nest/trek-photo-cache.service.test.ts @@ -21,6 +21,7 @@ import { TrekPhotoCacheMeta } from '../../../src/db/entities/TrekPhotoCacheMeta. import { TrekPhotoCacheService, CACHE_TTL } from '../../../src/nest/memories/trek-photo-cache.service'; import { StorageNotFoundError } from '../../../src/nest/storage/storage.types'; import { makeStorageFixture } from '../../helpers/storage-fixture'; +import { deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; const fx = makeStorageFixture('photos/trek/'); const testDb = createSnapshotTestDb(); @@ -40,8 +41,8 @@ beforeAll(async () => { svc = new TrekPhotoCacheService(t.repo(TrekPhotoCacheMeta), fx.storage); }); -beforeEach(() => { - testDb.prepare('DELETE FROM trek_photo_cache_meta').run(); +beforeEach(async () => { + await deleteRows(t, TrekPhotoCacheMeta); }); afterAll(async () => { @@ -78,20 +79,18 @@ describe('put / getFresh', () => { it('CACHE-004: an entry past its TTL is a miss and its metadata row is dropped', async () => { const key = freshKey('stale'); await svc.put(key, Buffer.from('old'), 'image/jpeg'); - testDb.prepare('UPDATE trek_photo_cache_meta SET fetched_at = ? WHERE cache_key = ?') - .run(Date.now() - CACHE_TTL - 1000, key); + await updateRows(t, TrekPhotoCacheMeta, { cache_key: key }, { fetched_at: Date.now() - CACHE_TTL - 1000 }); expect(await svc.getFresh(key)).toBeNull(); - expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(key)).toBeUndefined(); + expect(await findRow(t, TrekPhotoCacheMeta, { cache_key: key })).toBeNull(); }); it('CACHE-005: metadata without its object is a miss and the row is dropped', async () => { const key = 'orphan-meta-row'; - testDb.prepare('INSERT INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)') - .run(key, 'image/jpeg', Date.now()); + await insertRow(t, TrekPhotoCacheMeta, { cache_key: key, content_type: 'image/jpeg', fetched_at: Date.now() }); expect(await svc.getFresh(key)).toBeNull(); - expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(key)).toBeUndefined(); + expect(await findRow(t, TrekPhotoCacheMeta, { cache_key: key })).toBeNull(); }); it('CACHE-006: writing the same key twice replaces the bytes rather than duplicating the row', async () => { @@ -99,7 +98,7 @@ describe('put / getFresh', () => { await svc.put(key, Buffer.from('first'), 'image/jpeg'); await svc.put(key, Buffer.from('second'), 'image/png'); - const rows = testDb.prepare('SELECT content_type FROM trek_photo_cache_meta WHERE cache_key = ?').all(key); + const rows = await findRows(t, TrekPhotoCacheMeta, { cache_key: key }); expect(rows).toHaveLength(1); expect((await svc.getFresh(key))!.contentType).toBe('image/png'); expect(fs.readFileSync(binPath(key)).toString()).toBe('second'); @@ -174,23 +173,21 @@ describe('sweepExpired', () => { const freshKeyId = freshKey('sweep-fresh'); await svc.put(staleKey, Buffer.from('old'), 'image/jpeg'); await svc.put(freshKeyId, Buffer.from('new'), 'image/jpeg'); - testDb.prepare('UPDATE trek_photo_cache_meta SET fetched_at = ? WHERE cache_key = ?') - .run(Date.now() - CACHE_TTL * 2 - 1000, staleKey); + await updateRows(t, TrekPhotoCacheMeta, { cache_key: staleKey }, { fetched_at: Date.now() - CACHE_TTL * 2 - 1000 }); await svc.sweepExpired(); - expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(staleKey)).toBeUndefined(); + expect(await findRow(t, TrekPhotoCacheMeta, { cache_key: staleKey })).toBeNull(); expect(fs.existsSync(binPath(staleKey))).toBe(false); - expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(freshKeyId)).toBeDefined(); + expect(await findRow(t, TrekPhotoCacheMeta, { cache_key: freshKeyId })).not.toBeNull(); }); it('CACHE-012: survives a metadata row whose object is already gone', async () => { const key = 'sweep-orphan'; - testDb.prepare('INSERT INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)') - .run(key, 'image/jpeg', Date.now() - CACHE_TTL * 3); + await insertRow(t, TrekPhotoCacheMeta, { cache_key: key, content_type: 'image/jpeg', fetched_at: Date.now() - CACHE_TTL * 3 }); await expect(svc.sweepExpired()).resolves.toBeUndefined(); - expect(testDb.prepare('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?').get(key)).toBeUndefined(); + expect(await findRow(t, TrekPhotoCacheMeta, { cache_key: key })).toBeNull(); }); it('CACHE-014: reclaims a row-less .bin object past the cutoff (fix #4, spec rev 3.2)', async () => { @@ -198,7 +195,7 @@ describe('sweepExpired', () => { // list-driven pass 2 is what reclaims those leaks. const key = freshKey('rowless'); await svc.put(key, Buffer.from('leaked'), 'image/jpeg'); - testDb.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key); + await deleteRows(t, TrekPhotoCacheMeta, { cache_key: key }); const old = (Date.now() - CACHE_TTL * 3) / 1000; fs.utimesSync(binPath(key), old, old); @@ -209,7 +206,7 @@ describe('sweepExpired', () => { it('CACHE-015: a row-less .bin younger than the cutoff survives (in-flight put guard)', async () => { const key = freshKey('rowless-fresh'); await svc.put(key, Buffer.from('in-flight'), 'image/jpeg'); - testDb.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key); + await deleteRows(t, TrekPhotoCacheMeta, { cache_key: key }); await svc.sweepExpired(); expect(fs.existsSync(binPath(key))).toBe(true);