Bpistle/src/__tests__/schemata-validation.spec.ts
Cody Tseng b00ff341c8
feat: add schemata schema validation tests (#689)
Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
2026-04-04 15:18:04 +08:00

481 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Schemata validation tests for Jumble's draft event builders.
*
* Validates that every create*DraftEvent function in src/lib/draft-event.ts
* produces events conforming to @nostrability/schemata JSON schemas.
*
* Covers 21 kinds: 0, 1, 3, 5, 6, 7, 16, 17, 1111, 9802,
* 10000, 10001, 10002, 10003, 10012, 10030, 10063,
* 28934, 28936, 30002, 30078
*
* Skipped:
* - kind 1018/1068 (NIP-88) — schemata e-tag schema enforces NIP-10 markers
* but NIP-88 spec uses bare e tags (https://github.com/nostrability/schemata/issues/108)
* - kind 1984 (NIP-56) — schemata schema overly strict on p tag
* (https://github.com/nostrability/schemata/issues/107)
* - kind 31987 — no schemata schema exists yet
*/
// ── Mocks (must be before any imports that use these services) ──────────
import { vi, describe, it, expect, beforeAll } from 'vitest'
vi.mock('@/services/client.service', () => ({
default: {
getEventHint: () => 'wss://relay.example.com',
getReplaeableEventFromCache: () => undefined,
fetchEvent: vi.fn().mockResolvedValue(undefined),
fetchRelayList: vi.fn().mockResolvedValue({ read: [], write: [] })
}
}))
vi.mock('@/services/custom-emoji.service', () => ({
default: {
getEmojiById: () => undefined
}
}))
vi.mock('@/services/media-upload.service', () => ({
default: {
getImetaTagByUrl: () => undefined
}
}))
// ── Imports ─────────────────────────────────────────────────────────────
import { createRequire } from 'node:module'
import Ajv from 'ajv'
import ajvErrors from 'ajv-errors'
import { kinds, type Event } from 'nostr-tools'
import {
createProfileDraftEvent,
createShortTextNoteDraftEvent,
createFollowListDraftEvent,
createDeletionRequestDraftEvent,
createRepostDraftEvent,
createReactionDraftEvent,
createCommentDraftEvent,
createHighlightDraftEvent,
createMuteListDraftEvent,
createRelayListDraftEvent,
createBookmarkDraftEvent,
createPinListDraftEvent,
createFavoriteRelaysDraftEvent,
createUserEmojiListDraftEvent,
createBlossomServerListDraftEvent,
createJoinDraftEvent,
createLeaveDraftEvent,
createSeenNotificationsAtDraftEvent,
createRelaySetDraftEvent,
createExternalContentReactionDraftEvent
} from '@/lib/draft-event'
import { ExtendedKind } from '@/constants'
// ── Schema loading ──────────────────────────────────────────────────────
const require_ = createRequire(import.meta.url)
const schemataBase = require_
.resolve('@nostrability/schemata')
.replace(/\/dist\/.*/, '/dist/nips')
function loadSchema(path: string): object {
return require_(`${schemataBase}/${path}`)
}
/**
* Recursively strip nested $schema, $id, and errorMessage fields
* that confuse AJV's strict mode.
*/
function stripSchemaFields(obj: unknown): unknown {
if (Array.isArray(obj)) {
return obj.map(stripSchemaFields)
}
if (obj !== null && typeof obj === 'object') {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (key === 'errorMessage') continue
if (key === '$schema' || key === '$id') continue
result[key] = stripSchemaFields(value)
}
return result
}
return obj
}
function createValidator(schema: object): ReturnType<Ajv['compile']> {
const cleaned = stripSchemaFields(schema) as Record<string, unknown>
const ajv = new Ajv({ allErrors: true, strict: false })
ajvErrors(ajv)
return ajv.compile(cleaned)
}
// ── Schema definitions (loaded at module level) ─────────────────────────
const kind0Schema = loadSchema('nip-01/kind-0/schema.json')
const kind1Schema = loadSchema('nip-01/kind-1/schema.json')
const kind3Schema = loadSchema('nip-02/kind-3/schema.json')
const kind5Schema = loadSchema('nip-09/kind-5/schema.json')
const kind6Schema = loadSchema('nip-18/kind-6/schema.json')
const kind7Schema = loadSchema('nip-25/kind-7/schema.json')
const kind16Schema = loadSchema('nip-18/kind-16/schema.json')
const kind17Schema = loadSchema('nip-25/kind-17/schema.json')
const kind1111Schema = loadSchema('nip-22/kind-1111/schema.json')
const kind9802Schema = loadSchema('nip-84/kind-9802/schema.json')
const kind10000Schema = loadSchema('nip-51/kind-10000/schema.json')
const kind10001Schema = loadSchema('nip-51/kind-10001/schema.json')
const kind10002Schema = loadSchema('nip-65/kind-10002/schema.json')
const kind10003Schema = loadSchema('nip-51/kind-10003/schema.json')
const kind10012Schema = loadSchema('nip-51/kind-10012/schema.json')
const kind10030Schema = loadSchema('nip-51/kind-10030/schema.json')
const kind10063Schema = loadSchema('nip-b7/kind-10063/schema.json')
const kind28934Schema = loadSchema('nip-43/kind-28934/schema.json')
const kind28936Schema = loadSchema('nip-43/kind-28936/schema.json')
const kind30002Schema = loadSchema('nip-51/kind-30002/schema.json')
const kind30078Schema = loadSchema('nip-78/kind-30078/schema.json')
function buildSchemaRegistry(): Map<number, object> {
const entries: [number, object][] = [
[0, kind0Schema],
[1, kind1Schema],
[3, kind3Schema],
[5, kind5Schema],
[6, kind6Schema],
[7, kind7Schema],
[16, kind16Schema],
[17, kind17Schema],
[1111, kind1111Schema],
[9802, kind9802Schema],
[10000, kind10000Schema],
[10001, kind10001Schema],
[10002, kind10002Schema],
[10003, kind10003Schema],
[10012, kind10012Schema],
[10030, kind10030Schema],
[10063, kind10063Schema],
[28934, kind28934Schema],
[28936, kind28936Schema],
[30002, kind30002Schema],
[30078, kind30078Schema]
]
return new Map(entries)
}
// ── Helpers ─────────────────────────────────────────────────────────────
const FAKE_PUBKEY = '0'.repeat(64)
const FAKE_PUBKEY_2 = '1'.repeat(64)
const FAKE_ID = 'a'.repeat(64)
const FAKE_SIG = 'b'.repeat(128)
type DraftEvent = { kind: number; content: string; tags: string[][]; created_at: number }
function validateDraftEvent(
draft: DraftEvent,
schemaRegistry: Map<number, object>
): { valid: boolean; errors: string[] } {
const schema = schemaRegistry.get(draft.kind)
if (!schema) {
return { valid: false, errors: [`No schema found for kind ${draft.kind}`] }
}
const signedEvent = {
...draft,
pubkey: FAKE_PUBKEY,
id: FAKE_ID,
sig: FAKE_SIG
}
const validate = createValidator(schema)
const valid = validate(signedEvent) as boolean
const errors = valid
? []
: (validate.errors ?? []).map((e) => `${e.instancePath || '/'}: ${e.message}`)
return { valid, errors }
}
/** Build a fake signed Event for functions that require one as input */
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 'c'.repeat(64),
pubkey: FAKE_PUBKEY_2,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'hello nostr',
sig: 'e'.repeat(128),
...overrides
}
}
// ── Tests ───────────────────────────────────────────────────────────────
describe('Schemata Schema Validation', () => {
let schemaRegistry: Map<number, object>
beforeAll(() => {
schemaRegistry = buildSchemaRegistry()
})
it('schema registry has 21 kinds', () => {
expect(schemaRegistry.size).toBe(21)
})
// Kind 0 Profile metadata
it('kind 0 (Profile) via createProfileDraftEvent', () => {
const draft = createProfileDraftEvent(
JSON.stringify({ name: 'Alice', about: 'Test profile', picture: 'https://example.com/avatar.png' })
)
expect(draft.kind).toBe(0)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 1 Short text note
it('kind 1 (Short Text Note) via createShortTextNoteDraftEvent', async () => {
const draft = await createShortTextNoteDraftEvent('Hello, Nostr!', [])
expect(draft.kind).toBe(1)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
it('kind 1 (Short Text Note reply) via createShortTextNoteDraftEvent', async () => {
const parent = makeEvent()
const draft = await createShortTextNoteDraftEvent('Nice post!', [], { parentEvent: parent })
expect(draft.kind).toBe(1)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 3 Follow list
it('kind 3 (Follow List) via createFollowListDraftEvent', () => {
const tags = [
['p', 'a'.repeat(64), 'wss://relay1.example.com', 'alice'],
['p', 'b'.repeat(64), 'wss://relay2.example.com', 'bob']
]
const draft = createFollowListDraftEvent(tags)
expect(draft.kind).toBe(kinds.Contacts)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 5 Deletion request
it('kind 5 (Deletion) via createDeletionRequestDraftEvent', () => {
const event = makeEvent({ kind: 1 })
const draft = createDeletionRequestDraftEvent(event)
expect(draft.kind).toBe(kinds.EventDeletion)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 6 Repost (text note)
it('kind 6 (Repost) via createRepostDraftEvent', () => {
const event = makeEvent({ kind: 1 })
const draft = createRepostDraftEvent(event)
expect(draft.kind).toBe(kinds.Repost)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 7 Reaction
it('kind 7 (Reaction: like) via createReactionDraftEvent', () => {
const event = makeEvent()
const draft = createReactionDraftEvent(event)
expect(draft.kind).toBe(kinds.Reaction)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
it('kind 7 (Reaction: custom emoji) via createReactionDraftEvent', () => {
const event = makeEvent()
const draft = createReactionDraftEvent(event, '🤙')
expect(draft.kind).toBe(kinds.Reaction)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 16 Generic repost (non-text)
it('kind 16 (Generic Repost) via createRepostDraftEvent', () => {
const event = makeEvent({ kind: 30023 })
const draft = createRepostDraftEvent(event)
expect(draft.kind).toBe(kinds.GenericRepost)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 17 External content reaction
it('kind 17 (External Content Reaction) via createExternalContentReactionDraftEvent', () => {
const draft = createExternalContentReactionDraftEvent('https://example.com/article')
expect(draft.kind).toBe(ExtendedKind.EXTERNAL_CONTENT_REACTION)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
it('kind 17 (External Content Reaction: custom) via createExternalContentReactionDraftEvent', () => {
const draft = createExternalContentReactionDraftEvent('https://example.com/article', '🔥')
expect(draft.kind).toBe(ExtendedKind.EXTERNAL_CONTENT_REACTION)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 1111 Comment
it('kind 1111 (Comment on event) via createCommentDraftEvent', async () => {
const parent = makeEvent({ kind: 1 })
const draft = await createCommentDraftEvent('Great post!', parent, [])
expect(draft.kind).toBe(ExtendedKind.COMMENT)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
it('kind 1111 (Comment on external content) via createCommentDraftEvent', async () => {
const draft = await createCommentDraftEvent('Interesting article', 'https://example.com/article', [])
expect(draft.kind).toBe(ExtendedKind.COMMENT)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 9802 Highlight
it('kind 9802 (Highlight from event) via createHighlightDraftEvent', () => {
const event = makeEvent({ kind: 30023, content: 'A long article with highlighted text in it.' })
const draft = createHighlightDraftEvent('highlighted text', '', event, [])
expect(draft.kind).toBe(kinds.Highlights)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10000 Mute list
it('kind 10000 (Mute List) via createMuteListDraftEvent', () => {
const tags = [
['p', 'a'.repeat(64)],
['e', 'b'.repeat(64)],
['word', 'spam'],
['t', 'nsfw']
]
const draft = createMuteListDraftEvent(tags)
expect(draft.kind).toBe(kinds.Mutelist)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10001 Pin list
it('kind 10001 (Pin List) via createPinListDraftEvent', () => {
const tags = [['e', 'a'.repeat(64)], ['e', 'b'.repeat(64)]]
const draft = createPinListDraftEvent(tags)
expect(draft.kind).toBe(kinds.Pinlist)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10002 Relay list
it('kind 10002 (Relay List) via createRelayListDraftEvent', () => {
const relays = [
{ url: 'wss://relay1.example.com', scope: 'both' as const },
{ url: 'wss://relay2.example.com', scope: 'read' as const }
]
const draft = createRelayListDraftEvent(relays)
expect(draft.kind).toBe(kinds.RelayList)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10003 Bookmark list
it('kind 10003 (Bookmarks) via createBookmarkDraftEvent', () => {
const tags = [
['e', 'a'.repeat(64)],
['a', `30023:${'b'.repeat(64)}:my-article`],
['r', 'https://example.com']
]
const draft = createBookmarkDraftEvent(tags)
expect(draft.kind).toBe(kinds.BookmarkList)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10012 Favorite relays
it('kind 10012 (Favorite Relays) via createFavoriteRelaysDraftEvent', () => {
const draft = createFavoriteRelaysDraftEvent(
['wss://relay1.example.com', 'wss://relay2.example.com'],
[]
)
expect(draft.kind).toBe(ExtendedKind.FAVORITE_RELAYS)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10030 User emoji list
it('kind 10030 (User Emoji List) via createUserEmojiListDraftEvent', () => {
const tags = [['a', `30030:${'a'.repeat(64)}:my-emojis`]]
const draft = createUserEmojiListDraftEvent(tags)
expect(draft.kind).toBe(kinds.UserEmojiList)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 10063 Blossom server list
it('kind 10063 (Blossom Server List) via createBlossomServerListDraftEvent', () => {
const draft = createBlossomServerListDraftEvent([
'https://blossom1.example.com',
'https://blossom2.example.com'
])
expect(draft.kind).toBe(ExtendedKind.BLOSSOM_SERVER_LIST)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 28934 Join request (NIP-43)
it('kind 28934 (Join) via createJoinDraftEvent', () => {
const draft = createJoinDraftEvent('invite-code-123')
expect(draft.kind).toBe(28934)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 28936 Leave request (NIP-43)
it('kind 28936 (Leave) via createLeaveDraftEvent', () => {
const draft = createLeaveDraftEvent()
expect(draft.kind).toBe(28936)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 30002 Relay set
it('kind 30002 (Relay Set) via createRelaySetDraftEvent', () => {
const draft = createRelaySetDraftEvent({
id: 'my-set',
name: 'My Relay Set',
relayUrls: ['wss://relay1.example.com', 'wss://relay2.example.com']
})
expect(draft.kind).toBe(kinds.Relaysets)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
// Kind 30078 Application-specific data (seen notifications)
it('kind 30078 (Application Data) via createSeenNotificationsAtDraftEvent', () => {
const draft = createSeenNotificationsAtDraftEvent()
expect(draft.kind).toBe(kinds.Application)
const result = validateDraftEvent(draft, schemaRegistry)
expect(result.errors).toEqual([])
expect(result.valid).toBe(true)
})
})