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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user