mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(server): seed the photo cache and dawarich specs through the factories
Converts the raw prepare() fixtures and reads in the trek photo cache and google place photo cache services and the dawarich e2e to the ORM factories and readers. The place photo cache suite moves from its hand-rolled three-table fixture to the migrated snapshot: the references it checks are now real places on a trip and real saved places in a collection, seeded through makePlace and makeCollectionPlace, which a table without names or owners could not hold. The dawarich e2e drops the raw canAccessTrip on its database mock, which nothing reads any more.
This commit is contained in:
@@ -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<number> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Buffer> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user