PR-002.5: Application Layer — Command/Result pattern, end-to-end flow

- 8 commands (CreateFieldSession, CreateMission, RegisterArtifact,
  CreateObservation, CreateDecisionCase, ApproveDecision, StartReview,
  CompleteReview)
- 4 handlers with validation and orchestration
- Result<T, E> pattern — explicit success/failure, no exceptions
- End-to-end test: Session → Mission → DecisionCase → Approval
- 3 tests proving full flow works in memory
- ADR-007: Command/Result Pattern

Acceptance Criteria:
 CreateFieldSession → CreateMission → CreateDecisionCase → ApproveDecision
 Without PostgreSQL, Redis, S3, API, HTTP, UI
 Business logic verified before infrastructure attached

Next: PR-003 — PostgreSQL adapters (swap InMemory → Postgres)
This commit is contained in:
Bernt
2026-07-02 15:06:59 +00:00
parent 02b51d7814
commit eab9da0100
15 changed files with 665 additions and 0 deletions
@@ -0,0 +1,168 @@
/**
* End-to-End Flow Test
*
* Acceptance Test for PR-002.5:
* CreateFieldSession → CreateMission → CreateDecisionCase → ApproveDecision
*
* WITHOUT:
* - PostgreSQL
* - Redis
* - S3
* - API
* - HTTP
* - UI
*
* If this passes, the domain is proven usable.
*/
import {
InMemoryFieldSessionRepository,
InMemoryMissionRepository,
InMemoryDecisionCaseRepository,
InMemoryUnitOfWork,
} from '@landvex/infrastructure';
import {
CreateFieldSessionHandler,
CreateMissionHandler,
CreateDecisionCaseHandler,
ApproveDecisionHandler,
} from '../handlers';
import {
CreateFieldSessionCommand,
CreateMissionCommand,
CreateDecisionCaseCommand,
ApproveDecisionCommand,
} from '../commands/commands';
describe('End-to-End Flow: Session → Mission → DecisionCase → Approval', () => {
let uow: InMemoryUnitOfWork;
let createSession: CreateFieldSessionHandler;
let createMission: CreateMissionHandler;
let createDecisionCase: CreateDecisionCaseHandler;
let approveDecision: ApproveDecisionHandler;
beforeEach(() => {
uow = new InMemoryUnitOfWork();
createSession = new CreateFieldSessionHandler(uow.sessions);
createMission = new CreateMissionHandler(uow.missions, uow.sessions);
createDecisionCase = new CreateDecisionCaseHandler(uow.decisionCases);
approveDecision = new ApproveDecisionHandler(uow.decisionCases);
});
it('should complete full flow in memory', async () => {
// Step 1: Create FieldSession
const sessionResult = await createSession.execute({
inspector: 'inspector_001',
date: new Date('2026-07-02'),
location: { lat: 59.3293, lng: 18.0686 },
areaId: 'area_stockholm_city',
} as CreateFieldSessionCommand);
expect(sessionResult.success).toBe(true);
if (!sessionResult.success) return;
const session = sessionResult.data.session;
// Step 2: Create Mission
const missionResult = await createMission.execute({
sessionId: session.id,
location: { lat: 59.3293, lng: 18.0686 },
device: { model: 'iPhone14,2', os: 'iOS 17.0', appVersion: '1.0.0' },
} as CreateMissionCommand);
expect(missionResult.success).toBe(true);
if (!missionResult.success) return;
const mission = missionResult.data.mission;
// Step 3: Create DecisionCase
const decisionCaseResult = await createDecisionCase.execute({
missionId: mission.id,
title: 'Crack in bridge deck',
description: 'Structural crack detected in bridge deck',
priority: 'high',
confidence: 'high',
recommendedAction: 'Inspect immediately by certified engineer',
observationIds: ['obs_000001'],
evidenceIds: ['evidence_000001'],
findingId: 'finding_000001',
decisionId: 'decision_000001',
reviewId: 'review_000001',
} as CreateDecisionCaseCommand);
expect(decisionCaseResult.success).toBe(true);
if (!decisionCaseResult.success) return;
const decisionCase = decisionCaseResult.data.decisionCase;
expect(decisionCase.status).toBe('pending');
// Step 4: Approve Decision
const approveResult = await approveDecision.execute({
decisionCaseId: decisionCase.id,
approvedBy: 'reviewer_001',
} as ApproveDecisionCommand);
expect(approveResult.success).toBe(true);
if (!approveResult.success) return;
const approved = approveResult.data.decisionCase;
expect(approved.status).toBe('approved');
// Verify persistence
const foundSession = await uow.sessions.findById(session.id);
const foundMission = await uow.missions.findById(mission.id);
const foundCase = await uow.decisionCases.findById(decisionCase.id);
expect(foundSession).not.toBeNull();
expect(foundMission).not.toBeNull();
expect(foundCase).not.toBeNull();
expect(foundCase!.status).toBe('approved');
});
it('should fail to approve decision without review', async () => {
// Create a decision case without reviewId
const createResult = await createDecisionCase.execute({
missionId: 'mission_20260702_000001',
title: 'Pothole on road A',
description: 'Small pothole',
priority: 'low',
confidence: 'medium',
recommendedAction: 'Schedule repair',
observationIds: ['obs_000001'],
evidenceIds: ['evidence_000001'],
findingId: 'finding_000001',
decisionId: 'decision_000001',
reviewId: 'review_000001',
} as CreateDecisionCaseCommand);
expect(createResult.success).toBe(true);
if (!createResult.success) return;
// Manually remove reviewId to simulate missing review
const decisionCase = createResult.data.decisionCase;
const modified = { ...decisionCase, reviewId: undefined as any };
await uow.decisionCases.save(modified);
// Try to approve
const approveResult = await approveDecision.execute({
decisionCaseId: decisionCase.id,
approvedBy: 'reviewer_001',
} as ApproveDecisionCommand);
expect(approveResult.success).toBe(false);
if (approveResult.success) return;
expect(approveResult.error.code).toBe('VALIDATION_ERROR');
});
it('should fail to create mission for non-existent session', async () => {
const result = await createMission.execute({
sessionId: 'session_20260702_999999',
location: { lat: 59.3293, lng: 18.0686 },
device: { model: 'iPhone14,2', os: 'iOS 17.0', appVersion: '1.0.0' },
} as CreateMissionCommand);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe('NOT_FOUND');
});
});