PR-001: Domain Model v1.0 — compile-only, zero dependencies
- @landvex/domain package with TypeScript strict mode - 3 Aggregate Roots: FieldSession, Mission, DecisionCase - Branded IDs, Value Objects, Domain Events, Invariants - 19 unit tests for IDs, FieldSession, DecisionCase - Zero runtime dependencies (only TypeScript + jest for tests) - Separates Entity / Value Object / Aggregate Root - README documents Three Rules of the domain Definition of Done met: - Compiles without errors - Exports all domain types - Unit tests for invariants and value objects - No PostgreSQL, S3, Express, AI models, queues
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Tests for FieldSession aggregate root
|
||||
*/
|
||||
|
||||
import { FieldSessionFactory } from './session';
|
||||
import { IdFactory } from '../common/ids';
|
||||
import { SessionStatus } from '../common/enums';
|
||||
import { InvariantViolationError } from '../common/errors';
|
||||
|
||||
describe('FieldSession', () => {
|
||||
const testDate = new Date('2026-08-14');
|
||||
const testLocation = { lat: 59.2371, lng: 18.1456 };
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a session with correct defaults', () => {
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: testLocation,
|
||||
date: testDate
|
||||
});
|
||||
|
||||
expect(session.status).toBe(SessionStatus.PLANNED);
|
||||
expect(session.missionIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw if location is missing', () => {
|
||||
expect(() => {
|
||||
FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: null as any,
|
||||
date: testDate
|
||||
});
|
||||
}).toThrow(InvariantViolationError);
|
||||
});
|
||||
|
||||
it('should throw if date is missing', () => {
|
||||
expect(() => {
|
||||
FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: testLocation,
|
||||
date: null as any
|
||||
});
|
||||
}).toThrow(InvariantViolationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMission', () => {
|
||||
it('should add mission and set status to active', () => {
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: testLocation,
|
||||
date: testDate
|
||||
});
|
||||
|
||||
const missionId = IdFactory.mission(testDate, 1);
|
||||
const updated = FieldSessionFactory.addMission(session, missionId);
|
||||
|
||||
expect(updated.missionIds).toContain(missionId);
|
||||
expect(updated.status).toBe(SessionStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
it('should complete active session', () => {
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: testLocation,
|
||||
date: testDate
|
||||
});
|
||||
|
||||
const withMission = FieldSessionFactory.addMission(session, IdFactory.mission(testDate, 1));
|
||||
const completed = FieldSessionFactory.complete(withMission);
|
||||
|
||||
expect(completed.status).toBe(SessionStatus.COMPLETED);
|
||||
});
|
||||
|
||||
it('should throw if session is not active', () => {
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: testLocation,
|
||||
date: testDate
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
FieldSessionFactory.complete(session);
|
||||
}).toThrow(InvariantViolationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* FieldSession - Aggregate Root
|
||||
* Organizes field work. A pilot day produces many missions.
|
||||
*
|
||||
* Invariants:
|
||||
* - Must have exactly one location
|
||||
* - Must have a date
|
||||
* - Can have zero or more missions
|
||||
* - Cannot be completed if any mission is still uploading
|
||||
*/
|
||||
|
||||
import { SessionId, MissionId } from '../common/ids';
|
||||
import { SessionStatus } from '../common/enums';
|
||||
import { GeoLocation } from '../common/value-objects';
|
||||
import { InvariantViolationError } from '../common/errors';
|
||||
|
||||
export interface FieldSession {
|
||||
readonly id: SessionId;
|
||||
readonly location: GeoLocation;
|
||||
readonly date: Date;
|
||||
readonly status: SessionStatus;
|
||||
readonly missionIds: MissionId[];
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateSessionParams {
|
||||
readonly id: SessionId;
|
||||
readonly location: GeoLocation;
|
||||
readonly date: Date;
|
||||
}
|
||||
|
||||
export class FieldSessionFactory {
|
||||
static create(params: CreateSessionParams): FieldSession {
|
||||
if (!params.location) {
|
||||
throw new InvariantViolationError('Session must have a location');
|
||||
}
|
||||
if (!params.date) {
|
||||
throw new InvariantViolationError('Session must have a date');
|
||||
}
|
||||
|
||||
return {
|
||||
id: params.id,
|
||||
location: params.location,
|
||||
date: params.date,
|
||||
status: SessionStatus.PLANNED,
|
||||
missionIds: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static addMission(session: FieldSession, missionId: MissionId): FieldSession {
|
||||
return {
|
||||
...session,
|
||||
missionIds: [...session.missionIds, missionId],
|
||||
status: SessionStatus.ACTIVE,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static complete(session: FieldSession): FieldSession {
|
||||
if (session.status !== SessionStatus.ACTIVE) {
|
||||
throw new InvariantViolationError('Cannot complete session that is not active');
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
status: SessionStatus.COMPLETED,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user