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,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
@@ -0,0 +1,60 @@
|
||||
# @landvex/application
|
||||
|
||||
Application layer — orchestrates domain operations.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
UI → Application Layer (commands/handlers) → Domain → Repository Interfaces → Adapters
|
||||
```
|
||||
|
||||
**No business logic here. Only orchestration.**
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `CreateFieldSessionCommand` | Start a new field session |
|
||||
| `CreateMissionCommand` | Create a mission within a session |
|
||||
| `RegisterArtifactCommand` | Register an artifact |
|
||||
| `CreateObservationCommand` | Create an observation |
|
||||
| `CreateDecisionCaseCommand` | Create a decision case |
|
||||
| `ApproveDecisionCommand` | Approve a decision |
|
||||
| `StartReviewCommand` | Start a review |
|
||||
| `CompleteReviewCommand` | Complete a review |
|
||||
|
||||
## Handlers
|
||||
|
||||
| Handler | Command | Result |
|
||||
|---------|---------|--------|
|
||||
| `CreateFieldSessionHandler` | `CreateFieldSessionCommand` | `Result<{ session }>` |
|
||||
| `CreateMissionHandler` | `CreateMissionCommand` | `Result<{ mission }>` |
|
||||
| `CreateDecisionCaseHandler` | `CreateDecisionCaseCommand` | `Result<{ decisionCase }>` |
|
||||
| `ApproveDecisionHandler` | `ApproveDecisionCommand` | `Result<{ decisionCase }>` |
|
||||
|
||||
## Result Pattern
|
||||
|
||||
```typescript
|
||||
const result = await handler.execute(command);
|
||||
|
||||
if (result.success) {
|
||||
// Use result.data
|
||||
} else {
|
||||
// Handle result.error
|
||||
}
|
||||
```
|
||||
|
||||
## End-to-End Test
|
||||
|
||||
`use-cases/end-to-end-flow.test.ts` proves the full flow works in memory:
|
||||
|
||||
```
|
||||
CreateFieldSession → CreateMission → CreateDecisionCase → ApproveDecision
|
||||
```
|
||||
|
||||
Without PostgreSQL, Redis, S3, API, HTTP, or UI.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@landvex/domain` — domain objects and factories
|
||||
- `@landvex/infrastructure` — repository interfaces and in-memory adapters
|
||||
@@ -0,0 +1,38 @@
|
||||
# ADR-007: Command/Result Pattern
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
We need a clear way to express intent to change state, execute business operations, and handle failures without exceptions.
|
||||
|
||||
## Decision
|
||||
Use Command/Result pattern:
|
||||
|
||||
- **Command**: Plain object (DTO) containing all data needed to execute
|
||||
- **Handler**: Contains orchestration logic, calls domain factories
|
||||
- **Result**: Explicit success/failure, no exceptions for business errors
|
||||
|
||||
```
|
||||
CreateMissionCommand
|
||||
↓
|
||||
CreateMissionHandler
|
||||
↓
|
||||
Result<MissionCreatedResult>
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Clear intent: commands are named after use cases
|
||||
- Testable: handlers are pure functions with injected repositories
|
||||
- No exceptions for business logic
|
||||
- Audit trail: commands can be logged
|
||||
- Async-friendly
|
||||
|
||||
### Negative
|
||||
- More boilerplate than direct service calls
|
||||
- Need to handle Result type at every call site
|
||||
|
||||
## Related
|
||||
- ADR-006: In-Memory Adapters for Testing
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@landvex/application",
|
||||
"version": "0.1.0",
|
||||
"description": "Application layer and use cases for LandveX domain",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@landvex/domain": "file:../domain",
|
||||
"@landvex/infrastructure": "file:../infrastructure"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Commands — intent to change state
|
||||
*
|
||||
* A command is a request to do something.
|
||||
* It contains all data needed to execute.
|
||||
* It does NOT contain business logic.
|
||||
*
|
||||
* ADR-007: Command/Result pattern
|
||||
* - Commands are plain objects (DTOs)
|
||||
* - Handlers contain orchestration logic
|
||||
* - Results are explicit (success/failure)
|
||||
*/
|
||||
|
||||
import { GeoLocation, DeviceInfo } from '@landvex/domain';
|
||||
|
||||
// Session commands
|
||||
export interface CreateFieldSessionCommand {
|
||||
readonly inspector: string;
|
||||
readonly date: Date;
|
||||
readonly location: GeoLocation;
|
||||
readonly areaId: string;
|
||||
}
|
||||
|
||||
// Mission commands
|
||||
export interface CreateMissionCommand {
|
||||
readonly sessionId: string;
|
||||
readonly location: GeoLocation;
|
||||
readonly device: DeviceInfo;
|
||||
}
|
||||
|
||||
// Artifact commands
|
||||
export interface RegisterArtifactCommand {
|
||||
readonly missionId: string;
|
||||
readonly type: 'image' | 'video' | 'audio' | 'dataset';
|
||||
readonly storageUri: string;
|
||||
readonly hash: string;
|
||||
readonly createdBy: string;
|
||||
}
|
||||
|
||||
// Observation commands
|
||||
export interface CreateObservationCommand {
|
||||
readonly missionId: string;
|
||||
readonly artifactId: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
// DecisionCase commands
|
||||
export interface CreateDecisionCaseCommand {
|
||||
readonly missionId: string;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
readonly confidence: 'low' | 'medium' | 'high' | 'certain';
|
||||
readonly recommendedAction: string;
|
||||
readonly observationIds: string[];
|
||||
readonly evidenceIds: string[];
|
||||
readonly findingId: string;
|
||||
readonly decisionId: string;
|
||||
readonly reviewId: string;
|
||||
}
|
||||
|
||||
export interface ApproveDecisionCommand {
|
||||
readonly decisionCaseId: string;
|
||||
readonly approvedBy: string;
|
||||
}
|
||||
|
||||
// Review commands
|
||||
export interface StartReviewCommand {
|
||||
readonly decisionCaseId: string;
|
||||
readonly reviewer: string;
|
||||
}
|
||||
|
||||
export interface CompleteReviewCommand {
|
||||
readonly reviewId: string;
|
||||
readonly verdict: 'approved' | 'rejected' | 'needs_more_evidence';
|
||||
readonly comment?: string;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* ApproveDecision Handler
|
||||
*
|
||||
* Orchestrates: ApproveDecisionCommand → Approved DecisionCase
|
||||
*/
|
||||
|
||||
import { DecisionCaseFactory, DecisionCase } from '@landvex/domain';
|
||||
import { DecisionCaseRepository } from '@landvex/infrastructure';
|
||||
import { ApproveDecisionCommand } from '../commands/commands';
|
||||
import { Result, ok, fail, NotFoundError, ValidationError } from '../results/results';
|
||||
|
||||
export interface ApproveDecisionResult {
|
||||
readonly decisionCase: DecisionCase;
|
||||
}
|
||||
|
||||
export class ApproveDecisionHandler {
|
||||
constructor(private readonly decisionCases: DecisionCaseRepository) {}
|
||||
|
||||
async execute(command: ApproveDecisionCommand): Promise<Result<ApproveDecisionResult>> {
|
||||
// Find
|
||||
const decisionCase = await this.decisionCases.findById(command.decisionCaseId as any);
|
||||
if (!decisionCase) {
|
||||
return fail(NotFoundError('DecisionCase', command.decisionCaseId));
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!decisionCase.reviewId) {
|
||||
return fail(ValidationError('DecisionCase cannot be approved without review'));
|
||||
}
|
||||
|
||||
// Business logic (domain factory)
|
||||
const approved = DecisionCaseFactory.approve(decisionCase);
|
||||
|
||||
// Persist
|
||||
await this.decisionCases.save(approved);
|
||||
|
||||
return ok({ decisionCase: approved });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* CreateDecisionCase Handler
|
||||
*
|
||||
* Orchestrates: CreateDecisionCaseCommand → DecisionCase
|
||||
*/
|
||||
|
||||
import { DecisionCaseFactory, IdFactory, DecisionCase } from '@landvex/domain';
|
||||
import { DecisionCaseRepository } from '@landvex/infrastructure';
|
||||
import { CreateDecisionCaseCommand } from '../commands/commands';
|
||||
import { Result, ok, fail, ValidationError } from '../results/results';
|
||||
|
||||
export interface CreateDecisionCaseResult {
|
||||
readonly decisionCase: DecisionCase;
|
||||
}
|
||||
|
||||
export class CreateDecisionCaseHandler {
|
||||
constructor(private readonly decisionCases: DecisionCaseRepository) {}
|
||||
|
||||
async execute(command: CreateDecisionCaseCommand): Promise<Result<CreateDecisionCaseResult>> {
|
||||
// Validation
|
||||
if (!command.title || command.title.trim().length === 0) {
|
||||
return fail(ValidationError('Title is required'));
|
||||
}
|
||||
if (!command.observationIds || command.observationIds.length === 0) {
|
||||
return fail(ValidationError('At least one observation is required'));
|
||||
}
|
||||
if (!command.evidenceIds || command.evidenceIds.length === 0) {
|
||||
return fail(ValidationError('At least one evidence is required'));
|
||||
}
|
||||
|
||||
// Create
|
||||
const decisionCase = DecisionCaseFactory.create({
|
||||
id: IdFactory.decisionCase(1),
|
||||
missionId: command.missionId as any,
|
||||
title: command.title,
|
||||
description: command.description,
|
||||
priority: command.priority,
|
||||
confidence: command.confidence,
|
||||
recommendedAction: command.recommendedAction,
|
||||
observationIds: command.observationIds as any,
|
||||
evidenceIds: command.evidenceIds as any,
|
||||
findingId: command.findingId as any,
|
||||
decisionId: command.decisionId as any,
|
||||
reviewId: command.reviewId as any,
|
||||
});
|
||||
|
||||
// Persist
|
||||
await this.decisionCases.save(decisionCase);
|
||||
|
||||
return ok({ decisionCase });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* CreateFieldSession Handler
|
||||
*
|
||||
* Orchestrates: CreateFieldSessionCommand → FieldSession
|
||||
*
|
||||
* Flow:
|
||||
* 1. Generate ID
|
||||
* 2. Create domain object (factory)
|
||||
* 3. Save to repository
|
||||
* 4. Return result
|
||||
*/
|
||||
|
||||
import { FieldSessionFactory, IdFactory, FieldSession } from '@landvex/domain';
|
||||
import { FieldSessionRepository } from '@landvex/infrastructure';
|
||||
import { CreateFieldSessionCommand } from '../commands/commands';
|
||||
import { Result, ok, fail, ValidationError } from '../results/results';
|
||||
|
||||
export interface CreateFieldSessionResult {
|
||||
readonly session: FieldSession;
|
||||
}
|
||||
|
||||
export class CreateFieldSessionHandler {
|
||||
constructor(private readonly sessions: FieldSessionRepository) {}
|
||||
|
||||
async execute(command: CreateFieldSessionCommand): Promise<Result<CreateFieldSessionResult>> {
|
||||
// Validation
|
||||
if (!command.inspector || command.inspector.trim().length === 0) {
|
||||
return fail(ValidationError('Inspector is required'));
|
||||
}
|
||||
if (!command.date) {
|
||||
return fail(ValidationError('Date is required'));
|
||||
}
|
||||
if (!command.location) {
|
||||
return fail(ValidationError('Location is required'));
|
||||
}
|
||||
|
||||
// Create
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(command.date, 1), // TODO: sequence from repository
|
||||
location: command.location,
|
||||
date: command.date,
|
||||
});
|
||||
|
||||
// Persist
|
||||
await this.sessions.save(session);
|
||||
|
||||
return ok({ session });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* CreateMission Handler
|
||||
*
|
||||
* Orchestrates: CreateMissionCommand → Mission
|
||||
*
|
||||
* Flow:
|
||||
* 1. Verify session exists
|
||||
* 2. Generate ID
|
||||
* 3. Create domain object (factory)
|
||||
* 4. Save to repository
|
||||
* 5. Return result
|
||||
*/
|
||||
|
||||
import { MissionFactory, IdFactory, Mission } from '@landvex/domain';
|
||||
import { MissionRepository, FieldSessionRepository } from '@landvex/infrastructure';
|
||||
import { CreateMissionCommand } from '../commands/commands';
|
||||
import { Result, ok, fail, NotFoundError, ValidationError } from '../results/results';
|
||||
|
||||
export interface CreateMissionResult {
|
||||
readonly mission: Mission;
|
||||
}
|
||||
|
||||
export class CreateMissionHandler {
|
||||
constructor(
|
||||
private readonly missions: MissionRepository,
|
||||
private readonly sessions: FieldSessionRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateMissionCommand): Promise<Result<CreateMissionResult>> {
|
||||
// Verify session exists
|
||||
const session = await this.sessions.findById(command.sessionId as any);
|
||||
if (!session) {
|
||||
return fail(NotFoundError('Session', command.sessionId));
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!command.location) {
|
||||
return fail(ValidationError('Location is required'));
|
||||
}
|
||||
|
||||
// Create
|
||||
const mission = MissionFactory.create({
|
||||
id: IdFactory.mission(new Date(), 1), // TODO: sequence from repository
|
||||
sessionId: command.sessionId as any,
|
||||
location: command.location,
|
||||
device: command.device,
|
||||
});
|
||||
|
||||
// Persist
|
||||
await this.missions.save(mission);
|
||||
|
||||
return ok({ mission });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { CreateFieldSessionHandler, CreateFieldSessionResult } from './create-field-session-handler';
|
||||
export { CreateMissionHandler, CreateMissionResult } from './create-mission-handler';
|
||||
export { CreateDecisionCaseHandler, CreateDecisionCaseResult } from './create-decision-case-handler';
|
||||
export { ApproveDecisionHandler, ApproveDecisionResult } from './approve-decision-handler';
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @landvex/application
|
||||
*
|
||||
* Application layer — orchestrates domain operations.
|
||||
*
|
||||
* Architecture:
|
||||
* UI → Application Layer (commands/handlers) → Domain → Repository Interfaces → Adapters
|
||||
*
|
||||
* No business logic here. Only orchestration.
|
||||
*/
|
||||
|
||||
// Commands
|
||||
export * from './commands/commands';
|
||||
|
||||
// Results
|
||||
export * from './results/results';
|
||||
|
||||
// Handlers
|
||||
export * from './handlers';
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Results — explicit outcome of command execution
|
||||
*
|
||||
* Every command returns a Result.
|
||||
* No exceptions for business failures.
|
||||
*
|
||||
* ADR-007: Command/Result pattern
|
||||
* - Success: contains the result data
|
||||
* - Failure: contains error code and message
|
||||
*/
|
||||
|
||||
export type Result<T, E = DomainError> = Success<T> | Failure<E>;
|
||||
|
||||
export interface Success<T> {
|
||||
readonly success: true;
|
||||
readonly data: T;
|
||||
}
|
||||
|
||||
export interface Failure<E> {
|
||||
readonly success: false;
|
||||
readonly error: E;
|
||||
}
|
||||
|
||||
export interface DomainError {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
export const ok = <T>(data: T): Success<T> => ({ success: true, data });
|
||||
export const fail = <E extends DomainError>(error: E): Failure<E> => ({ success: false, error });
|
||||
|
||||
// Common errors
|
||||
export const NotFoundError = (entity: string, id: string): DomainError => ({
|
||||
code: 'NOT_FOUND',
|
||||
message: `${entity} not found: ${id}`,
|
||||
});
|
||||
|
||||
export const ValidationError = (message: string): DomainError => ({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message,
|
||||
});
|
||||
|
||||
export const InvariantViolationError = (message: string): DomainError => ({
|
||||
code: 'INVARIANT_VIOLATION',
|
||||
message,
|
||||
});
|
||||
|
||||
export const ConflictError = (message: string): DomainError => ({
|
||||
code: 'CONFLICT',
|
||||
message,
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user