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.
This commit is contained in:
Maurice
2026-10-30 19:17:55 +01:00
parent 36c1599a09
commit 294ba0a4fe
3 changed files with 365 additions and 0 deletions
@@ -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> = {}): 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);
});
});
@@ -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<Record<'website' | 'phone' | 'osm_id', string>> = {}): 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<Parameters<PlaceContribOutboxRepository['upsertEntry']>[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' }),
]),
);
});
});
@@ -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<Record<'website' | 'phone' | 'osm_id', string>> = {}): 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' }) },
]);
});
});