From 294ba0a4fe65389e1f096c4da0ef9589fe7b0f30 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 30 Oct 2026 19:17:55 +0100 Subject: [PATCH] place-contrib: repository tests for the log, the outbox and the field origins Every method of the three new repositories, the empty-list shortcuts and the filters included, so the repositories keep their 99 % floor. --- .../PlaceContribLog.repository.test.ts | 94 ++++++++++ .../PlaceContribOutbox.repository.test.ts | 166 ++++++++++++++++++ .../PlaceFieldOrigin.repository.test.ts | 105 +++++++++++ 3 files changed, 365 insertions(+) create mode 100644 server/tests/unit/db/repositories/PlaceContribLog.repository.test.ts create mode 100644 server/tests/unit/db/repositories/PlaceContribOutbox.repository.test.ts create mode 100644 server/tests/unit/db/repositories/PlaceFieldOrigin.repository.test.ts diff --git a/server/tests/unit/db/repositories/PlaceContribLog.repository.test.ts b/server/tests/unit/db/repositories/PlaceContribLog.repository.test.ts new file mode 100644 index 000000000..9ee9e5512 --- /dev/null +++ b/server/tests/unit/db/repositories/PlaceContribLog.repository.test.ts @@ -0,0 +1,94 @@ +/** + * PlaceContribLogRepository: the transmission log of the community enrichment, + * paged and filtered for the admin, counted for the panel's figures and swept + * by the retention job. + */ +import { PlaceContribLog } from '../../../../src/db/entities/PlaceContribLog.entity'; +import type { + PlaceContribLogRepository, + PlaceContribLogWrite, +} from '../../../../src/db/repositories/PlaceContribLog.repository'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { countRows } from '../../../helpers/factories/rows'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let log: PlaceContribLogRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + log = t.repo(PlaceContribLog); +}); +beforeEach(() => { + resetTestDb(testDb); + t.clear(); +}); +afterAll(async () => { + await t.close(); + testDb.close(); +}); + +const line = (over: Partial = {}): PlaceContribLogWrite => ({ + day: '2026-10-29', + contrib_key: 'osm:node/1', + place_name: 'Café Luna', + field: 'website', + value_summary: 'cafe-luna.de', + result: 'accepted', + reason: 'published', + ...over, +}); + +describe('PlaceContribLogRepository', () => { + it('PCLOGREPO-001: no lines writes nothing', async () => { + await log.insertLines([]); + expect(await countRows(t, PlaceContribLog)).toBe(0); + }); + + it('PCLOGREPO-002: pages newest first, filtered by result and field, with the matching total', async () => { + await log.insertLines([ + line({ field: 'website' }), + line({ field: 'phone', result: 'rejected', reason: 'format' }), + line({ field: 'phone' }), + ]); + const all = await log.page({}, 10, 0); + expect(all.map((row) => row.field)).toEqual(['phone', 'phone', 'website']); + expect(await log.total({})).toBe(3); + + const accepted = await log.page({ result: 'accepted' }, 10, 0); + expect(accepted).toHaveLength(2); + expect(await log.total({ result: 'accepted' })).toBe(2); + + const phones = await log.page({ field: 'phone', result: 'rejected' }, 10, 0); + expect(phones).toEqual([expect.objectContaining({ field: 'phone', result: 'rejected', reason: 'format' })]); + expect(await log.total({ field: 'phone' })).toBe(2); + + expect(await log.page({}, 1, 1)).toEqual([expect.objectContaining({ field: 'phone', result: 'rejected' })]); + }); + + it('PCLOGREPO-003: counts per result from a day on, and the last accepted day', async () => { + expect(await log.lastAcceptedDay()).toBeNull(); + await log.insertLines([ + line({ day: '2026-09-01' }), + line({ day: '2026-10-20' }), + line({ day: '2026-10-28', result: 'withheld', reason: 'unchanged' }), + ]); + expect(await log.countsSince('2026-10-01')).toEqual( + expect.arrayContaining([ + { result: 'accepted', count: 1 }, + { result: 'withheld', count: 1 }, + ]), + ); + expect(await log.lastAcceptedDay()).toBe('2026-10-20'); + }); + + it('PCLOGREPO-004: the sweep drops the lines before a day and says how many', async () => { + await log.insertLines([line({ day: '2026-01-01' }), line({ day: '2026-10-01' })]); + expect(await log.purgeBefore('2026-06-01')).toBe(1); + expect(await log.total({})).toBe(1); + }); +}); diff --git a/server/tests/unit/db/repositories/PlaceContribOutbox.repository.test.ts b/server/tests/unit/db/repositories/PlaceContribOutbox.repository.test.ts new file mode 100644 index 000000000..7a362062e --- /dev/null +++ b/server/tests/unit/db/repositories/PlaceContribOutbox.repository.test.ts @@ -0,0 +1,166 @@ +/** + * PlaceContribOutboxRepository: the values waiting to go to the TREK Places + * API, one per place key and field, with the retry bookkeeping the sender uses + * and the join that tells it whether a value is still what the user typed. + */ +import { PlaceContribOutbox } from '../../../../src/db/entities/PlaceContribOutbox.entity'; +import { PlaceFieldOrigin } from '../../../../src/db/entities/PlaceFieldOrigin.entity'; +import type { PlaceContribOutboxRepository } from '../../../../src/db/repositories/PlaceContribOutbox.repository'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { createPlace, createTrip, createUser } from '../../../helpers/factories'; +import { countRows, findRow, insertRow } from '../../../helpers/factories/rows'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let outbox: PlaceContribOutboxRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + outbox = t.repo(PlaceContribOutbox); +}); +beforeEach(() => { + resetTestDb(testDb); + t.clear(); +}); +afterAll(async () => { + await t.close(); + testDb.close(); +}); + +const PAST = '2026-10-29 10:00:00'; +const NOW = '2026-10-30 10:00:00'; +const LATER = '2026-10-31 10:00:00'; + +/** A place named Café Luna, with the shareable columns given (the factory sets only a few). */ +function aPlace(columns: Partial> = {}): number { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const { id } = createPlace(testDb, trip.id, { name: 'Café Luna' }); + for (const [column, value] of Object.entries(columns)) { + testDb.prepare(`UPDATE places SET ${column} = ? WHERE id = ?`).run(value, id); + } + return id; +} + +async function queue(placeId: number, over: Partial[0]> = {}) { + await outbox.upsertEntry({ + contrib_key: 'osm:node/1', + field: 'website', + value: 'https://cafe-luna.de', + place_id: placeId, + place_name: 'Café Luna', + lat: 54.08, + lng: 12.13, + country: 'DE', + cid: 'cid-website-0001', + eligible_at: PAST, + next_attempt_at: PAST, + ...over, + }); +} + +describe('PlaceContribOutboxRepository', () => { + it('PCOUTREPO-001: a newer value for the same key and field replaces the queued one and starts its attempts over', async () => { + const placeId = aPlace(); + await queue(placeId); + await outbox.scheduleRetry( + (await outbox.dueFor('osm:node/1', NOW)).map((row) => row.id), + PAST, + ); + await queue(placeId, { value: 'https://luna.example', cid: 'cid-website-0002' }); + const rows = await outbox.dueFor('osm:node/1', NOW); + expect(rows).toEqual([ + expect.objectContaining({ value: 'https://luna.example', cid: 'cid-website-0002', attempts: 0 }), + ]); + expect(await outbox.size()).toBe(1); + expect(await outbox.hasEntry('osm:node/1', 'website')).toBe(true); + expect(await outbox.hasEntry('osm:node/1', 'phone')).toBe(false); + }); + + it('PCOUTREPO-002: due keys come oldest first, only once due, at most the limit', async () => { + const placeId = aPlace(); + await queue(placeId, { contrib_key: 'osm:node/2', next_attempt_at: NOW }); + await queue(placeId, { contrib_key: 'osm:node/1', next_attempt_at: PAST }); + await queue(placeId, { contrib_key: 'osm:node/3', next_attempt_at: LATER }); + expect(await outbox.dueKeys(NOW, 10)).toEqual(['osm:node/1', 'osm:node/2']); + expect(await outbox.dueKeys(NOW, 1)).toEqual(['osm:node/1']); + }); + + it('PCOUTREPO-003: retries count up and wait, a postponement only waits', async () => { + const placeId = aPlace(); + await queue(placeId); + const [row] = await outbox.dueFor('osm:node/1', NOW); + if (!row) throw new Error('the queued value is missing'); + await outbox.scheduleRetry([row.id], LATER); + expect(await outbox.dueFor('osm:node/1', NOW)).toEqual([]); + await outbox.postpone([row.id], PAST); + const [again] = await outbox.dueFor('osm:node/1', NOW); + expect(again).toEqual(expect.objectContaining({ attempts: 1 })); + await outbox.scheduleRetry([], LATER); + await outbox.postpone([], LATER); + expect((await findRow(t, PlaceContribOutbox, { id: row.id }))?.next_attempt_at).toBe(PAST); + }); + + it('PCOUTREPO-004: values queued before a time come in id order, at most the limit', async () => { + const placeId = aPlace(); + await insertRow(t, PlaceContribOutbox, { + contrib_key: 'osm:node/1', + field: 'phone', + value: '+49 381 1', + place: placeId, + place_name: 'Café Luna', + cid: 'cid-phone-0001', + eligible_at: PAST, + next_attempt_at: PAST, + created_at: '2026-01-01 00:00:00', + }); + await queue(placeId); + expect(await outbox.queuedBefore('2026-06-01 00:00:00', 10)).toEqual([expect.objectContaining({ field: 'phone' })]); + expect(await outbox.queuedBefore(LATER, 1)).toHaveLength(1); + }); + + it('PCOUTREPO-005: deletes by place and field, by field, by id and all, and an empty list deletes nothing', async () => { + const one = aPlace(); + const two = aPlace(); + await queue(one, { contrib_key: 'osm:node/1', field: 'website' }); + await queue(one, { contrib_key: 'osm:node/1', field: 'phone', value: '+49 381 1' }); + await queue(two, { contrib_key: 'osm:node/2', field: 'website' }); + await queue(two, { contrib_key: 'osm:node/2', field: 'email', value: 'hi@luna.de' }); + + expect(await outbox.deleteForPlace(one, [])).toBe(0); + expect(await outbox.deleteFields([])).toBe(0); + expect(await outbox.deleteIds([])).toBe(0); + + expect(await outbox.deleteForPlace(one, ['phone'])).toBe(1); + expect(await outbox.deleteFields(['email'])).toBe(1); + const left = (await outbox.dueFor('osm:node/2', NOW)).map((row) => row.id); + expect(await outbox.deleteIds(left)).toBe(1); + expect(await outbox.deleteAll()).toBe(1); + expect(await countRows(t, PlaceContribOutbox)).toBe(0); + }); + + it("PCOUTREPO-006: the current state joins the place's values and the field's origin, none where it has none", async () => { + const placeId = aPlace({ website: 'https://cafe-luna.de', phone: '+49 381 1' }); + await insertRow(t, PlaceFieldOrigin, { place: placeId, field: 'website', origin: 'user', value_hash: 'h1' }); + await queue(placeId, { field: 'website' }); + await queue(placeId, { field: 'phone', value: '+49 381 1' }); + const ids = (await outbox.dueFor('osm:node/1', NOW)).map((row) => row.id); + expect(await outbox.currentOf([])).toEqual([]); + const current = await outbox.currentOf(ids); + expect(current).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'website', + origin: 'user', + value_hash: 'h1', + website: 'https://cafe-luna.de', + }), + expect.objectContaining({ field: 'phone', origin: null, value_hash: null, phone: '+49 381 1' }), + ]), + ); + }); +}); diff --git a/server/tests/unit/db/repositories/PlaceFieldOrigin.repository.test.ts b/server/tests/unit/db/repositories/PlaceFieldOrigin.repository.test.ts new file mode 100644 index 000000000..2c89e194e --- /dev/null +++ b/server/tests/unit/db/repositories/PlaceFieldOrigin.repository.test.ts @@ -0,0 +1,105 @@ +/** + * PlaceFieldOriginRepository: where each shareable field of a place got its + * value, the place's shareable columns the origin tracking compares, and the + * cached provider answers it checks a typed value against. + */ +import { PlaceDetailsCache } from '../../../../src/db/entities/PlaceDetailsCache.entity'; +import { PlaceFieldOrigin } from '../../../../src/db/entities/PlaceFieldOrigin.entity'; +import type { PlaceFieldOriginRepository } from '../../../../src/db/repositories/PlaceFieldOrigin.repository'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { createPlace, createTrip, createUser } from '../../../helpers/factories'; +import { insertRow } from '../../../helpers/factories/rows'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let origins: PlaceFieldOriginRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + origins = t.repo(PlaceFieldOrigin); +}); +beforeEach(() => { + resetTestDb(testDb); + t.clear(); +}); +afterAll(async () => { + await t.close(); + testDb.close(); +}); + +const AT = '2026-10-30 10:00:00'; + +/** A place named Café Luna, with the shareable columns given (the factory sets only a few). */ +function aPlace(columns: Partial> = {}): number { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const { id } = createPlace(testDb, trip.id, { name: 'Café Luna' }); + for (const [column, value] of Object.entries(columns)) { + testDb.prepare(`UPDATE places SET ${column} = ? WHERE id = ?`).run(value, id); + } + return id; +} + +describe('PlaceFieldOriginRepository', () => { + it("PFOREPO-001: reads a place's shareable values, one or many, and null for a place that is gone", async () => { + const one = aPlace({ website: 'https://cafe-luna.de', osm_id: 'node/1' }); + const two = aPlace({ phone: '+49 381 1' }); + expect(await origins.placeValues(one)).toEqual( + expect.objectContaining({ id: one, name: 'Café Luna', website: 'https://cafe-luna.de', osm_id: 'node/1' }), + ); + expect(await origins.placeValues(999_999)).toBeNull(); + expect(await origins.placeValuesMany([])).toEqual([]); + const many = await origins.placeValuesMany([one, two]); + expect(many.map((row) => row.id).sort()).toEqual([one, two].sort()); + }); + + it('PFOREPO-002: an origin written again replaces the old one; an empty list writes nothing', async () => { + const placeId = aPlace(); + await origins.upsertOrigins([]); + expect(await origins.originsOf(placeId)).toEqual([]); + await origins.upsertOrigins([ + { place_id: placeId, field: 'website', origin: 'google', value_hash: 'h1', updated_at: AT }, + { place_id: placeId, field: 'phone', origin: 'user', value_hash: 'h2', updated_at: AT }, + ]); + await origins.upsertOrigins([ + { place_id: placeId, field: 'website', origin: 'user', value_hash: 'h3', updated_at: AT }, + ]); + expect(await origins.originsOf(placeId)).toEqual( + expect.arrayContaining([ + { field: 'website', origin: 'user', value_hash: 'h3' }, + { field: 'phone', origin: 'user', value_hash: 'h2' }, + ]), + ); + }); + + it('PFOREPO-003: deletes the named fields of one place only', async () => { + const one = aPlace(); + const two = aPlace(); + await origins.upsertOrigins([ + { place_id: one, field: 'website', origin: 'user', value_hash: 'h1', updated_at: AT }, + { place_id: two, field: 'website', origin: 'user', value_hash: 'h1', updated_at: AT }, + ]); + expect(await origins.deleteOrigins(one, [])).toBe(0); + expect(await origins.deleteOrigins(one, ['website'])).toBe(1); + expect(await origins.originsOf(one)).toEqual([]); + expect(await origins.originsOf(two)).toHaveLength(1); + }); + + it('PFOREPO-004: the cached provider answers of the ids asked for, none for an empty list', async () => { + await insertRow(t, PlaceDetailsCache, { + place_id: 'gers-1', + lang: 'en', + expanded: 0, + payload_json: JSON.stringify({ website: 'https://cafe-luna.de' }), + fetched_at: Date.now(), + }); + expect(await origins.cachedProviderPayloads([])).toEqual([]); + expect(await origins.cachedProviderPayloads(['gers-1', 'gers-2'])).toEqual([ + { place_id: 'gers-1', expanded: 0, payload_json: JSON.stringify({ website: 'https://cafe-luna.de' }) }, + ]); + }); +});