import { Test } from '@nestjs/testing'; import { readFileSync } from 'fs'; import { resolve } from 'path'; import { PrismaService } from '../prisma/prisma.service'; import { RealtimeService } from '../realtime/realtime.service'; import { AppLogAck } from './dto/app-messages.dto'; import { AppLogIngestService } from './app-log-ingest.service'; import { MqttClientService } from './mqtt-client.service'; // Unit coverage for the durable app-log ingest path (S2-a, SRS-SYN-2/7, §3.4.2). // Prisma is an in-memory fake keyed on pointId so idempotency is exercised for real // (a replayed UUID must be stored exactly once), without a database. The broker is a // spy so we can assert the application-level ack shape and topic. describe('AppLogIngestService', () => { const ORG = 'org_alpha'; const CLIENT = 'app_client_1'; const JOB = { id: 'job_1', orgId: ORG, ticketNumber: 'T-1' }; let service: AppLogIngestService; let published: { topic: string; payload: AppLogAck }[]; let store: Map; const point = ( pointId: string, createdAt: string, extra: Record = {}, ) => ({ pointId, createdAt, origin: 'APP', uploadPath: 'APP_MQTT', lat: 40, lng: -80, ts: createdAt, qualityFlag: 'IN_SPEC', ...extra, }); const msg = ( points: object[], job: { jobId?: string; ticket?: string } = { jobId: JOB.id }, ) => JSON.stringify({ schemaVersion: '1', ...job, points }); const lastAck = () => published[published.length - 1].payload; beforeEach(async () => { published = []; store = new Map(); const prisma = { organization: { findUnique: jest.fn(({ where }: { where: { id: string } }) => Promise.resolve(where.id === ORG ? { id: ORG } : null), ), }, job: { findFirst: jest.fn( ({ where }: { where: { id: string; orgId: string } }) => Promise.resolve( where.id === JOB.id && where.orgId === ORG ? JOB : null, ), ), findUnique: jest.fn(() => Promise.resolve(null)), create: jest.fn(() => Promise.resolve(JOB)), }, locatePoint: { findMany: jest.fn( ({ where }: { where: { pointId: { in: string[] } } }) => Promise.resolve( where.pointId.in .filter((id) => store.has(id)) .map((pointId) => ({ pointId })), ), ), createManyAndReturn: jest.fn( ({ data, }: { data: { pointId: string; jobId: string; createdAt: Date }[]; }) => { const inserted: unknown[] = []; for (const row of data) { if (store.has(row.pointId)) { continue; // skipDuplicates } store.set(row.pointId, row); inserted.push({ ...row, id: BigInt(store.size), lat: 40, lng: -80, altitude: null, utilityType: 'UNKNOWN', sequence: null, fixType: 'NONE', hAccuracy: null, vAccuracy: null, satellites: null, hdop: null, depth: null, frequencyHz: null, currentMa: null, signalDb: null, gainDb: null, locateMode: null, phaseDeg: null, compassDeg: null, distortionPct: null, recordedAt: row.createdAt, receivedAt: row.createdAt, origin: 'APP', uploadPath: 'APP_MQTT', originClientId: CLIENT, deviceId: null, }); } return Promise.resolve(inserted); }, ), }, }; const mqtt = { publish: jest.fn((topic: string, payload: AppLogAck) => published.push({ topic, payload }), ), }; const moduleRef = await Test.createTestingModule({ providers: [ AppLogIngestService, { provide: PrismaService, useValue: prisma }, { provide: RealtimeService, useValue: { publish: jest.fn() } }, { provide: MqttClientService, useValue: mqtt }, ], }).compile(); service = moduleRef.get(AppLogIngestService); }); it('ingests a fresh point once and acks it accepted on the client namespace', async () => { await service.handle( ORG, CLIENT, msg([ point('018f1a00-0000-7000-8000-000000000001', '2026-08-21T10:00:00Z'), ]), ); expect(store.size).toBe(1); expect(published[0].topic).toBe(`ul/${ORG}/app/${CLIENT}/ack`); const ack = lastAck(); expect(ack.results).toEqual([ { pointId: '018f1a00-0000-7000-8000-000000000001', outcome: 'ACCEPTED' }, ]); }); it('consumes the shared app fixture and emits the shared ack fixture', async () => { const fixtureRoot = resolve( __dirname, '../../../../meta/contracts/fixtures', ); const publishFixture = readFileSync( resolve(fixtureRoot, 'app-log-points-v1.json'), 'utf8', ); const expectedAck = JSON.parse( readFileSync(resolve(fixtureRoot, 'app-log-ack-v1.json'), 'utf8'), ); await service.handle(ORG, CLIENT, publishFixture); expect(store.size).toBe(1); expect(lastAck()).toEqual(expectedAck); }); it('ingests a replayed UUID exactly once (idempotency) and acks it as duplicate', async () => { const p = point( '018f1a00-0000-7000-8000-000000000002', '2026-08-21T10:00:00Z', ); await service.handle(ORG, CLIENT, msg([p])); await service.handle(ORG, CLIENT, msg([p])); // replay expect(store.size).toBe(1); // stored exactly once const ack = lastAck(); expect(ack.results).toEqual([ { pointId: '018f1a00-0000-7000-8000-000000000002', outcome: 'DUPLICATE' }, ]); }); it('orders a batch by event time, not array order', async () => { const later = point( '018f1a00-0000-7000-8000-00000000000a', '2026-08-21T10:05:00Z', ); const earlier = point( '018f1a00-0000-7000-8000-00000000000b', '2026-08-21T10:01:00Z', ); await service.handle(ORG, CLIENT, msg([later, earlier])); const rows = [...store.values()]; expect(rows.map((r) => r.pointId)).toEqual([ earlier.pointId, later.pointId, ]); }); it('rejects a point whose jobId belongs to another org (cross-namespace backstop)', async () => { await service.handle( ORG, CLIENT, msg( [point('018f1a00-0000-7000-8000-00000000000c', '2026-08-21T10:00:00Z')], { jobId: 'job_of_org_beta' }, ), ); expect(store.size).toBe(0); const ack = lastAck(); expect(ack.results).toEqual([ { pointId: '018f1a00-0000-7000-8000-00000000000c', outcome: 'REJECTED', reasonCode: 'UNKNOWN_JOB_OR_WRONG_ORG', }, ]); }); it('drops (no ack) a message on an unknown org namespace', async () => { await service.handle( 'org_ghost', CLIENT, msg([ point('018f1a00-0000-7000-8000-00000000000d', '2026-08-21T10:00:00Z'), ]), ); expect(published).toHaveLength(0); expect(store.size).toBe(0); }); it('does not emit an ambiguous ack when malformed JSON has no usable pointId', async () => { await service.handle(ORG, CLIENT, '{ not json'); expect(published).toHaveLength(0); }); it('rejects a schema-invalid point but names its UUID in the ack', async () => { // Missing required createdAt + bad lat → validation fails; pointId still surfaced. await service.handle( ORG, CLIENT, msg([ { pointId: '018f1a00-0000-7000-8000-00000000000e', lat: 999, lng: 0, ts: '2026-08-21T10:00:00Z', }, ]), ); const ack = lastAck(); expect(store.size).toBe(0); expect(ack.results).toEqual([ { pointId: '018f1a00-0000-7000-8000-00000000000e', outcome: 'REJECTED', reasonCode: 'VALIDATION_ERROR', }, ]); }); it('accepts a valid point while rejecting a malformed neighbor in the same batch', async () => { const bad = point( '018f1a00-0000-7000-8000-00000000000f', '2026-08-21T10:00:00Z', { lat: 999 }, ); const good = point( '018f1a00-0000-7000-8000-000000000010', '2026-08-21T10:00:01Z', ); await service.handle(ORG, CLIENT, msg([bad, good])); expect(store.size).toBe(1); expect(lastAck().results).toEqual( expect.arrayContaining([ { pointId: bad.pointId, outcome: 'REJECTED', reasonCode: 'VALIDATION_ERROR', }, { pointId: good.pointId, outcome: 'ACCEPTED' }, ]), ); }); it('rejects a non-v7 UUID even when it is otherwise a valid UUID', async () => { const uuidV4 = '550e8400-e29b-41d4-a716-446655440000'; await service.handle( ORG, CLIENT, msg([point(uuidV4, '2026-08-21T10:00:00Z')]), ); expect(store.size).toBe(0); expect(lastAck().results).toEqual([ { pointId: uuidV4, outcome: 'REJECTED', reasonCode: 'POINT_ID_NOT_UUIDV7', }, ]); }); });