mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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' }) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user