feat(S2-a): ingest durable app MQTT points securely

This commit is contained in:
Brent Perteet
2026-08-20 15:22:40 -05:00
parent daa3407b9d
commit 8bcc12ec96
14 changed files with 979 additions and 59 deletions

View File

@@ -0,0 +1,315 @@
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<string, { pointId: string; jobId: string; createdAt: Date }>;
const point = (
pointId: string,
createdAt: string,
extra: Record<string, unknown> = {},
) => ({
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',
},
]);
});
});