feat(S2-a): ingest durable app MQTT points securely
This commit is contained in:
315
backend/src/ingest/app-log-ingest.service.spec.ts
Normal file
315
backend/src/ingest/app-log-ingest.service.spec.ts
Normal 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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user