Add TypeScript domain and contract bindings
This commit is contained in:
parent
4da5a980ed
commit
fecb9a1a8d
1 changed files with 1553 additions and 1 deletions
217
web/src/domain/domain.unit.test.ts
Normal file
217
web/src/domain/domain.unit.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import corpusJson from '../../../contracts/golden/domain.json'
|
||||
import type {
|
||||
AttemptOutcomeRequestDto,
|
||||
EventFrameDto,
|
||||
JobSubmissionDto,
|
||||
} from '../api/contracts'
|
||||
import {
|
||||
decodeAttemptOutcome,
|
||||
decodeEventFrame,
|
||||
decodeJobFailure,
|
||||
decodeJobSubmission,
|
||||
} from '../api/decode'
|
||||
import { canonicalJson, jsonValue, requestFingerprint } from './fingerprint'
|
||||
import {
|
||||
failureDisposition,
|
||||
isJobPhase,
|
||||
isTerminalPhase,
|
||||
jobPhases,
|
||||
runtimeFailureClass,
|
||||
} from './vocabulary'
|
||||
import {
|
||||
bucketName,
|
||||
memberPath,
|
||||
storageScopeCovers,
|
||||
targetPrefix,
|
||||
} from './validation'
|
||||
|
||||
const corpus = corpusJson
|
||||
|
||||
const dtoCompileCases = [
|
||||
{
|
||||
source: {
|
||||
kind: 'http',
|
||||
url: 'https://example.invalid/file',
|
||||
result_path: 'file.bin',
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly JobSubmissionDto[]
|
||||
|
||||
const eventCompileCase = {
|
||||
schema_version: 1,
|
||||
kind: 'heartbeat',
|
||||
event_id: 'event_0001',
|
||||
sequence: 1,
|
||||
cursor: 'cursor-value-0001',
|
||||
occurred_at: '2026-07-15T00:00:00Z',
|
||||
} as const satisfies EventFrameDto
|
||||
|
||||
const outcomeCompileCase = {
|
||||
operation_id: 'operation_01',
|
||||
fence: 1,
|
||||
worker_incarnation: 'worker_0001',
|
||||
outcome: {
|
||||
kind: 'failure',
|
||||
failure: {
|
||||
class: 'transient_source',
|
||||
message: 'source temporarily unavailable',
|
||||
},
|
||||
},
|
||||
} as const satisfies AttemptOutcomeRequestDto
|
||||
|
||||
describe('shared domain corpus', () => {
|
||||
it('preserves protocol phase and failure vocabulary', () => {
|
||||
expect(corpus.job_phases).toEqual(jobPhases)
|
||||
expect(corpus.job_phases.every(isJobPhase)).toBe(true)
|
||||
expect(
|
||||
corpus.job_phases
|
||||
.slice(0, 4)
|
||||
.every((phase) => isJobPhase(phase) && !isTerminalPhase(phase)),
|
||||
).toBe(true)
|
||||
expect(
|
||||
corpus.job_phases
|
||||
.slice(4)
|
||||
.every((phase) => isJobPhase(phase) && isTerminalPhase(phase)),
|
||||
).toBe(true)
|
||||
for (const failure of corpus.runtime_failures) {
|
||||
expect(failureDisposition(runtimeFailureClass(failure.class))).toBe(
|
||||
failure.disposition,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('validates and normalizes shared domain values', () => {
|
||||
for (const value of corpus.buckets.valid)
|
||||
expect(bucketName(value)).toBe(value)
|
||||
for (const value of corpus.buckets.invalid) {
|
||||
expect(() => bucketName(value)).toThrow('invalid bucket name')
|
||||
}
|
||||
for (const value of corpus.target_prefixes.valid) {
|
||||
expect(targetPrefix(value.input, [])).toBe(value.canonical)
|
||||
}
|
||||
for (const value of corpus.target_prefixes.invalid) {
|
||||
expect(() => targetPrefix(value, [])).toThrow('invalid target prefix')
|
||||
}
|
||||
for (const value of corpus.reserved_target_prefixes) {
|
||||
expect(() => targetPrefix(value.input, value.reserved)).toThrow(
|
||||
'target prefix uses a reserved segment',
|
||||
)
|
||||
}
|
||||
for (const value of corpus.member_paths.valid) {
|
||||
expect(memberPath(value.input)).toBe(value.canonical)
|
||||
}
|
||||
for (const value of corpus.member_paths.invalid) {
|
||||
expect(() => memberPath(value)).toThrow('invalid result member path')
|
||||
}
|
||||
})
|
||||
|
||||
it('matches RFC 8785 canonical request fingerprints', async () => {
|
||||
for (const value of corpus.fingerprints) {
|
||||
const request = jsonValue(value.value)
|
||||
expect(canonicalJson(request)).toBe(value.canonical)
|
||||
await expect(requestFingerprint(request)).resolves.toBe(value.sha256)
|
||||
}
|
||||
expect(() => jsonValue('\ud800')).toThrow(
|
||||
'canonical JSON does not permit lone surrogates',
|
||||
)
|
||||
expect(() => jsonValue({ '\udc00': true })).toThrow(
|
||||
'canonical JSON does not permit lone surrogates',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses exact target tuples and path segments for scope coverage', () => {
|
||||
for (const value of corpus.scope_coverage) {
|
||||
const scope = {
|
||||
store: value.scope[0],
|
||||
bucket: bucketName(value.scope[1]),
|
||||
prefix: targetPrefix(value.scope[2], []),
|
||||
}
|
||||
const target = {
|
||||
store: value.target[0],
|
||||
bucket: bucketName(value.target[1]),
|
||||
prefix: targetPrefix(value.target[2], []),
|
||||
}
|
||||
expect(storageScopeCovers(scope, target)).toBe(value.covers)
|
||||
}
|
||||
})
|
||||
|
||||
it('round trips mutable job submission DTOs', () => {
|
||||
for (const value of corpus.job_submissions) {
|
||||
expect(decodeJobSubmission(value)).toEqual(value)
|
||||
}
|
||||
for (const value of corpus.event_frames) {
|
||||
expect(decodeEventFrame(value)).toEqual(value)
|
||||
}
|
||||
for (const value of corpus.attempt_outcomes) {
|
||||
expect(decodeAttemptOutcome(value)).toEqual(value)
|
||||
}
|
||||
for (const value of corpus.job_failures) {
|
||||
expect(decodeJobFailure(value)).toEqual(value)
|
||||
}
|
||||
expect(dtoCompileCases).toHaveLength(1)
|
||||
expect(eventCompileCase.kind).toBe('heartbeat')
|
||||
expect(outcomeCompileCase.outcome.kind).toBe('failure')
|
||||
})
|
||||
|
||||
it('rejects contradictory job failure dispositions', () => {
|
||||
expect(() =>
|
||||
decodeJobFailure({
|
||||
class: 'source_not_found',
|
||||
message: 'missing',
|
||||
retryable: true,
|
||||
}),
|
||||
).toThrow('invalid retryable failure class')
|
||||
expect(() =>
|
||||
decodeJobFailure({
|
||||
class: 'transient_target',
|
||||
message: 'unavailable',
|
||||
retryable: false,
|
||||
}),
|
||||
).toThrow('invalid permanent failure class')
|
||||
})
|
||||
|
||||
it('rejects WebSocket values outside the schema', () => {
|
||||
expect(() =>
|
||||
decodeEventFrame({
|
||||
schema_version: 2,
|
||||
kind: 'heartbeat',
|
||||
event_id: 'event_0001',
|
||||
sequence: 1,
|
||||
cursor: 'cursor-value-0001',
|
||||
occurred_at: '2026-07-15T00:00:00Z',
|
||||
}),
|
||||
).toThrow('unsupported schema version')
|
||||
expect(() =>
|
||||
decodeEventFrame({
|
||||
schema_version: 1,
|
||||
kind: 'heartbeat',
|
||||
event_id: 'event_0001',
|
||||
sequence: 0,
|
||||
cursor: 'short',
|
||||
occurred_at: 'not-a-date',
|
||||
}),
|
||||
).toThrow('expected positive integer')
|
||||
expect(() =>
|
||||
decodeEventFrame({
|
||||
schema_version: 1,
|
||||
kind: 'heartbeat',
|
||||
event_id: 'event_0001',
|
||||
sequence: 1,
|
||||
cursor: 'cursor-value-0001',
|
||||
occurred_at: '2026-02-30T00:00:00Z',
|
||||
}),
|
||||
).toThrow('invalid RFC 3339 date-time')
|
||||
expect(() =>
|
||||
decodeEventFrame({
|
||||
schema_version: 1,
|
||||
kind: 'heartbeat',
|
||||
event_id: 'event_0001',
|
||||
sequence: 1,
|
||||
cursor: 'cursor-value-0001',
|
||||
occurred_at: '2026-01-01T24:00:00Z',
|
||||
}),
|
||||
).toThrow('invalid RFC 3339 date-time')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue