217 lines
6.3 KiB
TypeScript
217 lines
6.3 KiB
TypeScript
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')
|
|
})
|
|
})
|