Files
TREK/server/tests/e2e/place-contrib.e2e.test.ts
T
Maurice 36c1599a09 place-contrib: settle the second CI round
- The plugin RPC fallback tests build PlacesRpc with its origin bookkeeping.
- The e2e guard and contract cases send their requests one after another; the
  test server reset connections opened all at once.
- The job's two cron registrations are tagged @txIndependent: neither writes.
- The panel draws its fold head itself instead of importing it from pages/.
2026-10-31 03:25:55 +00:00

249 lines
9.7 KiB
TypeScript

/**
* /api/admin/places-contribute e2e: the real JwtAuthGuard, AdminGuard, Zod pipe
* and response contracts over PlaceContribModule on a migrated temp database,
* with the installation key from TREK_SERVICES_KEY and fetch standing in for
* the TREK Places API.
*
* NODE_ENV=test makes the instance ineligible, which is what the state says;
* the decisions, the log and the withdrawal work regardless.
*/
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { PlaceContribLog } from '../../src/db/entities/PlaceContribLog.entity';
import { servicesConfig } from '../../src/nest/app-config/tokens';
import { inOrder } from '../../src/nest/common/in-order';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { PlaceContribModule } from '../../src/nest/place-contrib/place-contrib.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { countRows, deleteRows, insertRows } from '../helpers/factories/rows';
import { makeAdmin, makeUser } from '../helpers/factories/users';
import { serviceKeyTokenFor } from '../helpers/service-keys';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import { PLACE_CONTRIB_TERMS_VERSION } from '@trek/shared';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
const USER = 1;
const ADMIN = 2;
const KEY = serviceKeyTokenFor({ i: 'contrib-e2e', iat: 1_793_000_000, exp: 4_102_444_800 });
const FIELDS = { website: true, phone: true, email: false, opening_hours: true, description: true, image: false };
const BASE = '/api/admin/places-contribute';
const fetchMock = vi.fn();
let orm: TestOrm;
let app: Awaited<ReturnType<typeof build>>;
let server: Server;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), PlaceContribModule],
})
.overrideProvider(servicesConfig.KEY)
.useValue({
mode: 'auto',
key: KEY,
hosts: ['places.liketrek.com'],
photonUrl: 'https://photon.liketrek.com',
valhallaUrl: 'https://valhalla.liketrek.com',
})
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
beforeAll(async () => {
vi.stubGlobal('fetch', fetchMock);
orm = await createTestOrm(db);
await makeUser(orm, { id: USER, username: 'traveler', email: 'traveler@example.test' });
await makeAdmin(orm, { id: ADMIN, username: 'admin', email: 'admin@example.test' });
app = await build();
server = app.getHttpServer();
});
beforeEach(async () => {
fetchMock.mockReset();
await deleteRows(orm, PlaceContribLog);
});
afterAll(async () => {
await app.close();
await orm.close();
vi.unstubAllGlobals();
});
describe('the guards', () => {
it('PCE2E-001: 401 without a session and 403 for a user who is no admin, on every route', async () => {
const routes = [
() => request(server).get(BASE),
() => request(server).put(`${BASE}/decision`).send({ action: 'snooze' }),
() => request(server).get(`${BASE}/log`),
() => request(server).post(`${BASE}/withdraw`).send({ confirm: true }),
];
// One after another: the test server resets connections opened all at once.
await inOrder(routes, async (route) => {
expect((await route()).status).toBe(401);
expect((await route().set(as(USER))).status).toBe(403);
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('the state and the decisions', () => {
it('PCE2E-010: a test instance is not eligible, says why, and shows no prompt', async () => {
const res = await request(server).get(BASE).set(as(ADMIN));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
termsVersion: PLACE_CONTRIB_TERMS_VERSION,
ceiling: 'admin',
eligible: false,
ineligibleReason: 'not_production',
showPrompt: false,
active: false,
queue: { size: 0, cap: 5000, full: false },
counts: { accepted: 0, duplicate: 0, rejected: 0, withheld: 0, retracted: 0 },
});
});
it('PCE2E-011: an agreement is stored, answered with the new state and audited', async () => {
const res = await request(server)
.put(`${BASE}/decision`)
.set(as(ADMIN))
.send({ action: 'agree', termsVersion: PLACE_CONTRIB_TERMS_VERSION, fields: FIELDS });
expect(res.status).toBe(200);
expect(res.body.consent).toMatchObject({
enabled: true,
fields: FIELDS,
termsVersion: PLACE_CONTRIB_TERMS_VERSION,
decision: 'agreed',
decidedBy: ADMIN,
});
expect(await countRows(orm, AuditLog, { action: 'admin.places_contribute_decision' })).toBeGreaterThanOrEqual(1);
});
it('PCE2E-012: a dialog built for other terms gets 409 with the terms in force', async () => {
const res = await request(server)
.put(`${BASE}/decision`)
.set(as(ADMIN))
.send({ action: 'agree', termsVersion: PLACE_CONTRIB_TERMS_VERSION + 1, fields: FIELDS });
expect(res.status).toBe(409);
expect(res.body).toEqual({
error: 'The contribution terms changed',
reason: 'terms_outdated',
termsVersion: PLACE_CONTRIB_TERMS_VERSION,
});
});
it('PCE2E-013: the pipe refuses a body outside the contract', async () => {
const bodies = [
{ action: 'maybe' },
{ action: 'agree', fields: FIELDS },
{ action: 'agree', termsVersion: 1, fields: { website: true } },
{ action: 'fields', fields: FIELDS, withdraw: 'yes' },
];
await inOrder(bodies, async (body) => {
const res = await request(server).put(`${BASE}/decision`).set(as(ADMIN)).send(body);
expect(res.status, JSON.stringify(body)).toBe(400);
});
});
it('PCE2E-014: "Not now" answers when the prompt comes back for this admin', async () => {
const res = await request(server).put(`${BASE}/decision`).set(as(ADMIN)).send({ action: 'snooze' });
expect(res.status).toBe(200);
expect(Date.parse(res.body.snoozedUntil) - Date.now()).toBeGreaterThan(13 * 24 * 3_600_000);
});
});
describe('the log', () => {
it('PCE2E-020: pages newest first and filters by result and field', async () => {
await insertRows(orm, PlaceContribLog, [
{
day: '2026-10-29',
contrib_key: 'osm:node/1',
place_name: 'A',
field: 'website',
result: 'accepted',
reason: 'published',
},
{
day: '2026-10-30',
contrib_key: 'osm:node/2',
place_name: 'B',
field: 'phone',
result: 'withheld',
reason: 'unchanged',
},
{
day: '2026-10-30',
contrib_key: 'osm:node/3',
place_name: 'C',
field: 'website',
result: 'rejected',
reason: 'key',
},
]);
const page = await request(server).get(`${BASE}/log?page=1&perPage=2`).set(as(ADMIN));
expect(page.status).toBe(200);
expect(page.body).toMatchObject({ total: 3, page: 1, perPage: 2 });
expect(page.body.entries.map((entry: { placeName: string }) => entry.placeName)).toEqual(['C', 'B']);
const filtered = await request(server).get(`${BASE}/log?field=website&result=accepted`).set(as(ADMIN));
expect(filtered.body.entries).toEqual([
{
id: expect.any(Number),
day: '2026-10-29',
key: 'osm:node/1',
placeName: 'A',
field: 'website',
valueSummary: null,
result: 'accepted',
reason: 'published',
},
]);
expect((await request(server).get(`${BASE}/log?result=sent`).set(as(ADMIN))).status).toBe(400);
});
});
describe('withdrawing everything', () => {
it('PCE2E-030: needs the confirmation', async () => {
expect((await request(server).post(`${BASE}/withdraw`).set(as(ADMIN)).send({})).status).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});
it('PCE2E-031: asks the service with the installation key and answers what it took back', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ ok: true, withdrawn: { website: 2, image: 0 } }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const res = await request(server).post(`${BASE}/withdraw`).set(as(ADMIN)).send({ confirm: true });
expect(res.status).toBe(200);
expect(res.body).toEqual({ withdrawn: { website: 2, image: 0 }, cleared: 0 });
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit & { headers: Record<string, string> }];
expect(String(url)).toBe('https://places.liketrek.com/v1/contrib/install');
expect(init.method).toBe('DELETE');
expect(init.headers.Authorization).toBe(`Bearer ${KEY}`);
expect(await countRows(orm, PlaceContribLog, { result: 'retracted' })).toBe(1);
expect(await countRows(orm, AuditLog, { action: 'admin.places_contribute_withdraw' })).toBeGreaterThanOrEqual(1);
});
it('PCE2E-032: a withdrawal the service does not confirm is a 502', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: 'unavailable' }), { status: 500 }));
const res = await request(server).post(`${BASE}/withdraw`).set(as(ADMIN)).send({ confirm: true });
expect(res.status).toBe(502);
expect(res.body).toEqual({ error: 'The TREK Places API did not confirm the withdrawal', reason: 'status_500' });
});
});