Add TypeScript domain and contract bindings

This commit is contained in:
ms2k 2026-07-15 21:00:44 +09:00
commit fecb9a1a8d

View 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')
})
})