316 lines
9.2 KiB
TypeScript
316 lines
9.2 KiB
TypeScript
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',
|
|
},
|
|
]);
|
|
});
|
|
});
|