Skip to content

Commit

Permalink
Add tests for MID events handling
Browse files Browse the repository at this point in the history
  • Loading branch information
PaulLeCam committed Jun 21, 2024
1 parent b8f0eb6 commit 3364153
Show file tree
Hide file tree
Showing 12 changed files with 926 additions and 270 deletions.
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@
"test:ci": "turbo run test:ci -- --passWithNoTests"
},
"devDependencies": {
"@biomejs/biome": "1.8.1",
"@biomejs/biome": "1.8.2",
"@jest/globals": "^29.7.0",
"@swc/cli": "^0.3.12",
"@swc/core": "^1.6.3",
"@swc/jest": "^0.2.36",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.5",
"@types/node": "^20.14.7",
"del-cli": "^5.1.0",
"jest": "^29.7.0",
"tsx": "^4.15.6",
"tsx": "^4.15.7",
"turbo": "^2.0.4",
"typescript": "^5.4.5"
"typescript": "^5.5.2"
},
"pnpm": {
"overrides": {}
Expand Down
24 changes: 11 additions & 13 deletions packages/document-handler/src/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,28 +150,26 @@ export function assertEventLinksToState(
payload: DocumentDataEventPayload | TimeEvent,
state: DocumentState,
) {
if (state.log.length === 0) {
throw new Error('Invalid document state: log is empty')
}

const initCID = state.log[0]

// Older versions of the CAS created time events without an 'id' field, so only check
// the event payload 'id' field if it is present.
if (payload.id != null && !payload.id.equals(state.cid)) {
if (payload.id != null && !payload.id.equals(initCID)) {
throw new Error(
`Invalid genesis CID in event payload for document ${getStreamID(state.cid)}. Found: ${
`Invalid init CID in event payload for document ${getStreamID(initCID)}. Found: ${
payload.id
}, expected ${state.cid}`,
)
}

const [init, ...changes] = state.log
if (init == null) {
throw new Error(
`Invalid state for document ${getStreamID(state.cid)}: log is empty`,
}, expected ${initCID}`,
)
}

const expectedPrev =
changes.length === 0 ? state.cid : changes[changes.length - 1].id
const expectedPrev = state.log[state.log.length - 1]
if (!payload.prev.equals(expectedPrev)) {
throw new Error(
`Commit doesn't properly point to previous event payload in log for document ${getStreamID(state.cid)}. Expected ${expectedPrev}, found 'prev' ${payload.prev}`,
`Commit doesn't properly point to previous event payload in log for document ${getStreamID(initCID)}. Expected ${expectedPrev}, found 'prev' ${payload.prev}`,
)
}
}
4 changes: 2 additions & 2 deletions packages/document-handler/src/codecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
DocumentInitEventPayload,
} from '@ceramic-sdk/document-protocol'
import { SignedEvent, TimeEvent } from '@ceramic-sdk/events'
import { type TypeOf, union } from 'codeco'
import { type OutputOf, type TypeOf, union } from 'codeco'
import 'ts-essentials' // Import needed for TS reference

export const DocumentEvent = union(
Expand All @@ -15,7 +15,7 @@ export const DocumentEvent = union(
],
'DocumentEvent',
)
export type DocumentEvent = TypeOf<typeof DocumentEvent>
export type DocumentEvent = OutputOf<typeof DocumentEvent>

export const DocumentEventPayload = union(
[
Expand Down
27 changes: 17 additions & 10 deletions packages/document-handler/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function handleDeterministicInitPayload(
const { data, header } = payload
if (data !== null) {
throw new Error(
'Deterministic init commits for ModelInstanceDocuments must not have content',
'Deterministic init events for ModelInstanceDocuments must not have content',
)
}

Expand All @@ -38,14 +38,13 @@ export async function handleDeterministicInitPayload(
assertValidInitHeader(definition, header)

return {
cid,
content: null,
metadata: {
controller: header.controllers[0],
model: header.model,
unique: header.unique,
},
log: [payload],
log: [cid],
}
}

Expand All @@ -55,6 +54,11 @@ export async function handleInitPayload(
context: Context,
): Promise<DocumentState> {
const { data, header } = payload
if (data == null) {
throw new Error(
'Signed init events for ModelInstanceDocuments must have content',
)
}
assertValidContentLength(data)

const modelID = header.model.toString()
Expand All @@ -65,7 +69,6 @@ export async function handleInitPayload(
await validateRelationsContent(context, definition, data)

return {
cid,
content: data,
metadata: {
controller: header.controllers[0],
Expand All @@ -74,17 +77,20 @@ export async function handleInitPayload(
context: header.context,
shouldIndex: header.shouldIndex,
},
log: [payload],
log: [cid],
}
}

export async function handleDataPayload(
cid: CID,
payload: DocumentDataEventPayload,
context: Context,
): Promise<DocumentState> {
const state = await context.getDocumentState(payload.id)
assertEventLinksToState(payload, state)

const metadata = { ...state.metadata }

// Check the header is valid when provided
if (payload.header != null) {
const { shouldIndex, ...others } = payload.header
Expand All @@ -98,7 +104,7 @@ export async function handleDataPayload(
}
// Update metadata if needed
if (shouldIndex != null) {
state.metadata.shouldIndex = shouldIndex
metadata.shouldIndex = shouldIndex
}
}

Expand All @@ -124,16 +130,17 @@ export async function handleDataPayload(
// Validate relations
await validateRelationsContent(context, definition, content)

return { ...state, log: [...state.log, payload] }
return { content, metadata, log: [...state.log, cid] }
}

export async function handleTimeEvent(
cid: CID,
event: TimeEvent,
context: Context,
): Promise<DocumentState> {
const state = await context.getDocumentState(event.id)
assertEventLinksToState(event, state)
return { ...state, log: [...state.log, event] }
return { ...state, log: [...state.log, cid] }
}

export async function handleEvent(
Expand All @@ -149,15 +156,15 @@ export async function handleEvent(
if (container.signed) {
// Signed event is either non-deterministic init or data
if (DocumentDataEventPayload.is(container.payload)) {
return await handleDataPayload(container.payload, context)
return await handleDataPayload(cid, container.payload, context)
}
if (DocumentInitEventPayload.is(container.payload)) {
return await handleInitPayload(cid, container.payload, context)
}
}
// Unsigned event is either deterministic init or time
if (TimeEvent.is(container.payload)) {
return await handleTimeEvent(container.payload as TimeEvent, context)
return await handleTimeEvent(cid, container.payload as TimeEvent, context)
}
return await handleDeterministicInitPayload(cid, container.payload, context)
}
23 changes: 4 additions & 19 deletions packages/document-handler/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
import type {
DeterministicInitEventPayload,
DocumentDataEventPayload,
DocumentInitEventPayload,
DocumentMetadata,
} from '@ceramic-sdk/document-protocol'
import type { TimeEvent } from '@ceramic-sdk/events'
import type { DocumentMetadata } from '@ceramic-sdk/document-protocol'
import type { ModelDefinition } from '@ceramic-sdk/model-protocol'
import type { DID } from 'dids'
import type { CID } from 'multiformats/cid'

export type InitEventPayload =
| DeterministicInitEventPayload
| DocumentInitEventPayload
export type ChangeEventPayload = DocumentDataEventPayload | TimeEvent

export type UnknowContent = Record<string, unknown>

export type DocumentSnapshot = {
export type DocumentState = {
content: UnknowContent | null
metadata: DocumentMetadata
}

export type DocumentState = DocumentSnapshot & {
cid: CID
log: [InitEventPayload, ...Array<ChangeEventPayload>]
log: [CID, ...Array<CID>]
}

export type Context = {
getDocumentSnapshot: (id: string) => Promise<DocumentSnapshot>
getDocumentModel: (id: string) => Promise<string>
getDocumentState: (cid: CID) => Promise<DocumentState>
getModelDefinition: (id: string) => Promise<ModelDefinition>
verifier: DID
Expand Down
5 changes: 1 addition & 4 deletions packages/document-handler/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ export async function validateRelation(
expectedModelID: string,
fieldName: string,
): Promise<void> {
// Ensure linked stream can be loaded and is a MID
const doc = await context.getDocumentSnapshot(docID)

const modelID = doc.metadata.model.toString()
const modelID = await context.getDocumentModel(docID)
if (modelID === expectedModelID) {
// Exact model used, relation is valid
return
Expand Down
50 changes: 47 additions & 3 deletions packages/document-handler/test/assertions.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,61 @@
import type {
DocumentInitEventHeader,
DocumentMetadata,
import {
type DocumentDataEventPayload,
type DocumentInitEventHeader,
type DocumentMetadata,
getStreamID,
} from '@ceramic-sdk/document-protocol'
import { randomCID } from '@ceramic-sdk/identifiers'
import type { ModelDefinitionV2 } from '@ceramic-sdk/model-protocol'

import {
assertEventLinksToState,
assertNoImmutableFieldChange,
assertValidContent,
assertValidInitHeader,
assertValidUniqueValue,
} from '../src/assertions.js'
import type { DocumentState } from '../src/types.js'
import { encodeUniqueFieldsValue } from '../src/utils.js'

describe('assertEventLinksToState()', () => {
test('throws if the state log is empty', () => {
const cid = randomCID()
expect(() => {
assertEventLinksToState(
{ id: cid } as unknown as DocumentDataEventPayload,
{ cid, log: [] } as unknown as DocumentState,
)
}).toThrow('Invalid document state: log is empty')
})

test('throws if the event id does not match the init event cid', () => {
const expectedID = randomCID()
const invalidID = randomCID()
expect(() => {
assertEventLinksToState(
{ id: invalidID } as unknown as DocumentDataEventPayload,
{ log: [expectedID] } as unknown as DocumentState,
)
}).toThrow(
`Invalid init CID in event payload for document ${getStreamID(expectedID)}. Found: ${invalidID}, expected ${expectedID}`,
)
})

test('throws if the event prev does not match the previous event cid', () => {
const initID = randomCID()
const expectedID = randomCID()
const invalidID = randomCID()
expect(() => {
assertEventLinksToState(
{ id: initID, prev: invalidID } as unknown as DocumentDataEventPayload,
{ log: [initID, expectedID] } as unknown as DocumentState,
)
}).toThrow(
`Commit doesn't properly point to previous event payload in log for document ${getStreamID(initID)}. Expected ${expectedID}, found 'prev' ${invalidID}`,
)
})
})

describe('assertNoImmutableFieldChange()', () => {
test('throws if an immutable field is changed', () => {
expect(() => {
Expand Down
Loading

0 comments on commit 3364153

Please sign in to comment.