diff --git a/docs/agents/task-logs/weekly/2026-02-13_05-12-12_event-schema-agent_completed.md b/docs/agents/task-logs/weekly/2026-02-13_05-12-12_event-schema-agent_completed.md new file mode 100644 index 00000000..75261a50 --- /dev/null +++ b/docs/agents/task-logs/weekly/2026-02-13_05-12-12_event-schema-agent_completed.md @@ -0,0 +1,8 @@ +# Agent Task Log Entry + +- **Date:** 2026-02-13 +- **Agent:** event-schema-agent +- **Prompt:** bitvid-event-schema-agent.md +- **Status:** completed +- **Branch:** claude/prevent-duplicate-scheduler-work-dj6XE +- **Summary:** Enhanced validate-events.mjs with CLI options (--dry-run, --out, --only), JSON report output, sanitizer tests, schema registry verification, and runtime construction site mapping. All 66 checks pass (28 builders, 3 sanitizers, 35 registry). No schema failures found. diff --git a/scripts/agent/validate-events.mjs b/scripts/agent/validate-events.mjs index add4c996..456cc69b 100644 --- a/scripts/agent/validate-events.mjs +++ b/scripts/agent/validate-events.mjs @@ -1,4 +1,6 @@ import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; // Polyfills for browser environment class MockWebSocket { @@ -70,345 +72,661 @@ import { buildSealEvent, buildChatMessageEvent, getNostrEventSchema, + getAllNostrEventSchemas, sanitizeAdditionalTags } from '../../js/nostrEventSchemas.js'; import { buildNip71VideoEvent } from '../../js/nostr/nip71.js'; +// --- CLI argument parsing --- +function parseArgs(argv) { + const args = { dryRun: false, out: null, only: null }; + for (const arg of argv.slice(2)) { + if (arg === '--dry-run') { + args.dryRun = true; + } else if (arg.startsWith('--out=')) { + args.out = arg.slice('--out='.length); + } else if (arg.startsWith('--only=')) { + args.only = arg.slice('--only='.length); + } + } + return args; +} + +const cliArgs = parseArgs(process.argv); + const TEST_PUBKEY = '0000000000000000000000000000000000000000000000000000000000000001'; const TEST_TIMESTAMP = 1672531200; // 2023-01-01 -function runTest(name, builderFn, input, expectedType, additionalCheck) { - console.log(`\nTesting: ${name}`); +// Known runtime construction sites (from codebase scan) +const RUNTIME_SITES = [ + { builder: 'buildVideoPostEvent', file: 'js/nostr/videoPayloadBuilder.js', line: 172, context: 'normalizeVideoNotePayload()' }, + { builder: 'buildVideoPostEvent', file: 'js/nostr/videoPayloadBuilder.js', line: 462, context: 'updateVideoPayloadVersion()' }, + { builder: 'buildVideoPostEvent', file: 'js/nostr/publishHelpers.js', line: 1405, context: 'buildVideoEventFromPayload()' }, + { builder: 'buildVideoMirrorEvent', file: 'js/nostr/publishHelpers.js', line: 1164, context: 'mirrorVideoEvent()' }, + { builder: 'buildNip71VideoEvent', file: 'js/nostr/videoPublisher.js', line: 187, context: 'publishNip71Video()' }, + { builder: 'buildProfileMetadataEvent', file: 'js/ui/profileModalController.js', line: 5081, context: 'profile save handler' }, + { builder: 'buildShareEvent', file: 'js/ui/shareNostrController.js', line: 166, context: 'handleShareEvent()' }, + { builder: 'buildRepostEvent', file: 'js/nostr/publishHelpers.js', line: 930, context: 'createRepostEvent()' }, + { builder: 'buildCommentEvent', file: 'js/nostr/commentEvents.js', line: 233, context: 'publishCommentEvent()' }, + { builder: 'buildReactionEvent', file: 'js/nostr/reactionEvents.js', line: 414, context: 'publishReactionEvent()' }, + { builder: 'buildViewEvent', file: 'js/nostr/viewEvents.js', line: 782, context: 'recordVideoViewWithTracking()' }, + { builder: 'buildWatchHistoryEvent', file: 'js/nostr/watchHistory.js', line: 1445, context: 'publishRecords()' }, + { builder: 'buildChatMessageEvent', file: 'js/nostr/client.js', line: 1719, context: 'sendDirectMessage()' }, + { builder: 'buildDmAttachmentEvent', file: 'js/nostr/client.js', line: 1744, context: 'sendDirectMessage()' }, + { builder: 'buildSealEvent', file: 'js/nostr/client.js', line: 1763, context: 'NIP-59 seal for gift wrap' }, + { builder: 'buildGiftWrapEvent', file: 'js/nostr/client.js', line: 1794, context: 'NIP-59 gift wrap for DM' }, + { builder: 'buildLegacyDirectMessageEvent', file: 'js/nostr/client.js', line: 2013, context: 'NIP-04 legacy DM' }, + { builder: 'buildDmReadReceiptEvent', file: 'js/nostr/dmSignalEvents.js', line: 75, context: 'DM read receipt signal' }, + { builder: 'buildDmTypingIndicatorEvent', file: 'js/nostr/dmSignalEvents.js', line: 216, context: 'DM typing indicator signal' }, + { builder: 'buildMuteListEvent', file: 'js/userBlocks.js', line: 2224, context: 'updateUserBlocks()' }, + { builder: 'buildAdminListEvent', file: 'js/adminListStore.js', line: 700, context: 'buildListEvent()' }, + { builder: 'buildReportEvent', file: 'js/services/moderationService.js', line: 2449, context: 'publishReport()' }, + { builder: 'buildSubscriptionListEvent', file: 'js/subscriptions.js', line: 1456, context: 'publishing subscription list' }, + { builder: 'buildHashtagPreferenceEvent', file: 'js/services/hashtagPreferencesService.js', line: 1365, context: 'buildEvent()' }, + { builder: 'buildRelayListEvent', file: 'js/relayManager.js', line: 699, context: 'updateRelayList()' }, + { builder: 'buildDmRelayListEvent', file: 'js/app.js', line: 2839, context: 'DM relay sync' }, + { builder: 'buildZapRequestEvent', file: 'js/payments/zapRequests.js', line: 88, context: 'createZapRequest()' }, + { builder: 'buildDeletionEvent', file: 'js/nostr/client.js', line: 3060, context: 'deletePublishedVideos()' }, + { builder: 'buildHttpAuthEvent', file: 'js/nostr/client.js', line: 0, context: 'HTTP auth flow (if used)' }, +]; + +// --- Test case definitions --- +const TEST_CASES = [ + { + name: 'buildVideoPostEvent (Real Usage - Auto Storage Pointer)', + builder: 'buildVideoPostEvent', + builderFn: buildVideoPostEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + dTagValue: 'test-video-id-123', + content: { + version: 3, + title: 'Real World Video', + videoRootId: 'test-video-id-123', + url: 'https://example.com/video.mp4', + mode: 'live', + deleted: false, + isPrivate: false, + isNsfw: false, + isForKids: false, + enableComments: true + }, + additionalTags: [['t', 'art']] + }, + expectedType: NOTE_TYPES.VIDEO_POST, + additionalCheck: (event) => { + const sTag = event.tags.find(t => t[0] === 's'); + if (!sTag) throw new Error("Missing 's' tag (Storage Pointer) which should have been auto-generated from URL"); + if (!sTag[1].startsWith('url:')) throw new Error(`'s' tag should start with 'url:', got ${sTag[1]}`); + } + }, + { + name: 'buildRelayListEvent (Real Usage)', + builder: 'buildRelayListEvent', + builderFn: buildRelayListEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + relays: [ + { url: 'wss://relay.damus.io', mode: 'read' }, + { url: 'wss://nos.lol', mode: 'write' }, + { url: 'wss://relay.primal.net', mode: 'both' } + ] + }, + expectedType: NOTE_TYPES.RELAY_LIST, + additionalCheck: (event) => { + const rTags = event.tags.filter(t => t[0] === 'r'); + if (rTags.length !== 3) throw new Error(`Expected 3 'r' tags, got ${rTags.length}`); + const damus = rTags.find(t => t[1] === 'wss://relay.damus.io'); + if (!damus || damus[2] !== 'read') throw new Error("Damus relay missing or incorrect marker"); + } + }, + { + name: 'buildHashtagPreferenceEvent (Real Usage - Encrypted)', + builder: 'buildHashtagPreferenceEvent', + builderFn: buildHashtagPreferenceEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + content: "nip44:ciphertext-blob-simulated", + additionalTags: [['encrypted', 'nip44']] + }, + expectedType: NOTE_TYPES.HASHTAG_PREFERENCES, + additionalCheck: (event) => { + if (event.content !== "nip44:ciphertext-blob-simulated") throw new Error("Content mismatch"); + const dTag = event.tags.find(t => t[0] === 'd'); + if (!dTag || dTag[1] !== 'bitvid:tag-preferences') throw new Error("Incorrect 'd' tag identifier"); + } + }, + { + name: 'buildHttpAuthEvent', + builder: 'buildHttpAuthEvent', + builderFn: buildHttpAuthEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + url: 'https://api.example.com/auth', + method: 'POST', + payload: 'sha256-hash-of-body' + }, + expectedType: NOTE_TYPES.HTTP_AUTH + }, + { + name: 'buildReportEvent', + builder: 'buildReportEvent', + builderFn: buildReportEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + eventId: 'e'.repeat(64), + reportType: 'spam', + content: 'This is spam' + }, + expectedType: NOTE_TYPES.REPORT + }, + { + name: 'buildVideoMirrorEvent', + builder: 'buildVideoMirrorEvent', + builderFn: buildVideoMirrorEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + content: 'Mirror description' + }, + expectedType: NOTE_TYPES.VIDEO_MIRROR + }, + { + name: 'buildRepostEvent', + builder: 'buildRepostEvent', + builderFn: buildRepostEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + eventId: 'e'.repeat(64), + eventRelay: 'wss://relay.example.com' + }, + expectedType: NOTE_TYPES.REPOST + }, + { + name: 'buildShareEvent', + builder: 'buildShareEvent', + builderFn: buildShareEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + content: 'Check this out', + video: { id: 'e'.repeat(64), pubkey: TEST_PUBKEY } + }, + expectedType: NOTE_TYPES.SHARE + }, + { + name: 'buildDmRelayListEvent', + builder: 'buildDmRelayListEvent', + builderFn: buildDmRelayListEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + relays: ['wss://dm.relay.com'] + }, + expectedType: NOTE_TYPES.DM_RELAY_LIST + }, + { + name: 'buildProfileMetadataEvent', + builder: 'buildProfileMetadataEvent', + builderFn: buildProfileMetadataEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + metadata: { name: 'Alice', about: 'Test user' } + }, + expectedType: NOTE_TYPES.PROFILE_METADATA + }, + { + name: 'buildMuteListEvent', + builder: 'buildMuteListEvent', + builderFn: buildMuteListEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + pTags: [TEST_PUBKEY] + }, + expectedType: NOTE_TYPES.MUTE_LIST + }, + { + name: 'buildDeletionEvent', + builder: 'buildDeletionEvent', + builderFn: buildDeletionEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + eventIds: ['e'.repeat(64)], + reason: 'Mistake' + }, + expectedType: NOTE_TYPES.DELETION + }, + { + name: 'buildLegacyDirectMessageEvent', + builder: 'buildLegacyDirectMessageEvent', + builderFn: buildLegacyDirectMessageEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + recipientPubkey: TEST_PUBKEY, + ciphertext: 'encrypted-stuff' + }, + expectedType: NOTE_TYPES.LEGACY_DM + }, + { + name: 'buildDmAttachmentEvent', + builder: 'buildDmAttachmentEvent', + builderFn: buildDmAttachmentEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + recipientPubkey: TEST_PUBKEY, + attachment: { url: 'https://example.com/file.jpg', type: 'image/jpeg' } + }, + expectedType: NOTE_TYPES.DM_ATTACHMENT + }, + { + name: 'buildDmReadReceiptEvent', + builder: 'buildDmReadReceiptEvent', + builderFn: buildDmReadReceiptEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + recipientPubkey: TEST_PUBKEY, + eventId: 'e'.repeat(64) + }, + expectedType: NOTE_TYPES.DM_READ_RECEIPT + }, + { + name: 'buildDmTypingIndicatorEvent', + builder: 'buildDmTypingIndicatorEvent', + builderFn: buildDmTypingIndicatorEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + recipientPubkey: TEST_PUBKEY + }, + expectedType: NOTE_TYPES.DM_TYPING + }, + { + name: 'buildViewEvent', + builder: 'buildViewEvent', + builderFn: buildViewEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + pointerValue: 'test-pointer', + pointerTag: ['a', 'test-pointer'] + }, + expectedType: NOTE_TYPES.VIEW_EVENT + }, + { + name: 'buildZapRequestEvent', + builder: 'buildZapRequestEvent', + builderFn: buildZapRequestEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + recipientPubkey: TEST_PUBKEY, + amountSats: 100, + relays: ['wss://relay.zap.com'] + }, + expectedType: NOTE_TYPES.ZAP_REQUEST + }, + { + name: 'buildReactionEvent', + builder: 'buildReactionEvent', + builderFn: buildReactionEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + pointerTag: ['e', 'e'.repeat(64)], + content: '+' + }, + expectedType: NOTE_TYPES.VIDEO_REACTION + }, + { + name: 'buildCommentEvent', + builder: 'buildCommentEvent', + builderFn: buildCommentEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + videoEventId: 'e'.repeat(64), + content: 'Nice video!' + }, + expectedType: NOTE_TYPES.VIDEO_COMMENT + }, + { + name: 'buildWatchHistoryEvent', + builder: 'buildWatchHistoryEvent', + builderFn: buildWatchHistoryEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + monthIdentifier: '2023-01', + content: JSON.stringify({ ['e'.repeat(64)]: 123456 }) + }, + expectedType: NOTE_TYPES.WATCH_HISTORY + }, + { + name: 'buildSubscriptionListEvent', + builder: 'buildSubscriptionListEvent', + builderFn: buildSubscriptionListEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP + }, + expectedType: NOTE_TYPES.SUBSCRIPTION_LIST + }, + { + name: 'buildBlockListEvent', + builder: 'buildBlockListEvent', + builderFn: buildBlockListEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP + }, + expectedType: NOTE_TYPES.USER_BLOCK_LIST + }, + { + name: 'buildAdminListEvent (moderation)', + builder: 'buildAdminListEvent', + builderFn: (i) => buildAdminListEvent(ADMIN_LIST_IDENTIFIERS.moderation, i), + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + hexPubkeys: [TEST_PUBKEY] + }, + expectedType: NOTE_TYPES.ADMIN_MODERATION_LIST + }, + { + name: 'buildNip71VideoEvent', + builder: 'buildNip71VideoEvent', + builderFn: buildNip71VideoEvent, + input: { + pubkey: TEST_PUBKEY, + title: 'NIP-71 Video', + metadata: { + kind: 21, + title: 'NIP-71 Video', + publishedAt: TEST_TIMESTAMP, + hashtags: ['test'] + }, + pointerIdentifiers: { videoRootId: 'test-root' } + }, + expectedType: NOTE_TYPES.NIP71_VIDEO + }, + { + name: 'buildGiftWrapEvent', + builder: 'buildGiftWrapEvent', + builderFn: buildGiftWrapEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + recipientPubkey: TEST_PUBKEY, + ciphertext: 'encrypted-seal', + relayHint: 'wss://relay.example.com' + }, + expectedType: NOTE_TYPES.GIFT_WRAP + }, + { + name: 'buildSealEvent', + builder: 'buildSealEvent', + builderFn: buildSealEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + ciphertext: 'encrypted-rumor' + }, + expectedType: NOTE_TYPES.SEAL + }, + { + name: 'buildChatMessageEvent', + builder: 'buildChatMessageEvent', + builderFn: buildChatMessageEvent, + input: { + pubkey: TEST_PUBKEY, + created_at: TEST_TIMESTAMP, + recipientPubkey: TEST_PUBKEY, + content: 'Hello World' + }, + expectedType: NOTE_TYPES.CHAT_MESSAGE + }, +]; + +// --- Sanitizer test cases --- +const SANITIZER_TESTS = [ + { + name: 'sanitizeAdditionalTags (valid tags)', + input: [['t', 'video'], ['p', TEST_PUBKEY]], + check: (result) => { + if (!Array.isArray(result) || result.length !== 2) throw new Error(`Expected 2 tags, got ${result.length}`); + if (result[0][0] !== 't' || result[0][1] !== 'video') throw new Error('First tag mismatch'); + } + }, + { + name: 'sanitizeAdditionalTags (filters invalid)', + input: [['', 'value'], [123, 'value'], ['valid', 'ok']], + check: (result) => { + if (result.length !== 1) throw new Error(`Expected 1 valid tag after filtering, got ${result.length}`); + if (result[0][0] !== 'valid') throw new Error('Surviving tag should be "valid"'); + } + }, + { + name: 'sanitizeAdditionalTags (empty input)', + input: [], + check: (result) => { + if (!Array.isArray(result) || result.length !== 0) throw new Error('Expected empty array'); + } + }, +]; + +function runTest(testCase) { + const { name, builderFn, input, expectedType, additionalCheck } = testCase; + const result = { + builder: testCase.builder, + name, + constructed_by: `validate-events.mjs (test harness)`, + input: '(redacted — see test case definition)', + event: null, + validation: { status: 'PASS', failures: [] }, + suggested_fix: null, + runtime_sites: RUNTIME_SITES.filter(s => s.builder === testCase.builder), + }; + try { const event = builderFn(input); if (!event) { - console.error(`❌ Builder returned null or undefined`); - return false; + result.validation.status = 'FAIL'; + result.validation.failures.push({ path: 'builder', message: 'Builder returned null or undefined' }); + return result; } + result.event = { kind: event.kind, tags_count: event.tags?.length, content_length: event.content?.length }; + const { valid, errors } = validateEventStructure(expectedType, event); - if (valid) { - if (additionalCheck) { - try { - additionalCheck(event); - } catch (checkError) { - console.error(`❌ Additional check failed: ${checkError.message}`); - return false; - } + if (!valid) { + result.validation.status = 'FAIL'; + result.validation.failures = errors.map(msg => ({ path: 'schema', message: msg })); + return result; + } + + if (additionalCheck) { + try { + additionalCheck(event); + } catch (checkError) { + result.validation.status = 'FAIL'; + result.validation.failures.push({ path: 'additional_check', message: checkError.message }); + return result; } - console.log(`✅ Valid ${expectedType} (Kind: ${event.kind})`); - return true; - } else { - console.error(`❌ Invalid ${expectedType}:`); - errors.forEach(err => console.error(` - ${err}`)); - return false; } } catch (error) { - console.error(`❌ Exception during build:`, error); - return false; + result.validation.status = 'FAIL'; + result.validation.failures.push({ path: 'exception', message: error.message }); } + + return result; +} + +function runSanitizerTest(testCase) { + const result = { + builder: 'sanitizeAdditionalTags', + name: testCase.name, + validation: { status: 'PASS', failures: [] }, + }; + + try { + const output = sanitizeAdditionalTags(testCase.input); + testCase.check(output); + } catch (error) { + result.validation.status = 'FAIL'; + result.validation.failures.push({ path: 'sanitizer', message: error.message }); + } + + return result; +} + +function verifySchemaRegistry() { + const results = []; + const allSchemas = getAllNostrEventSchemas(); + const schemaKeys = Object.keys(allSchemas); + const noteTypeValues = Object.values(NOTE_TYPES); + + // Check that every NOTE_TYPE has a corresponding schema + for (const noteType of noteTypeValues) { + const schema = getNostrEventSchema(noteType); + const result = { + builder: 'getNostrEventSchema', + name: `Schema registry: ${noteType}`, + validation: { status: 'PASS', failures: [] }, + }; + if (!schema) { + result.validation.status = 'FAIL'; + result.validation.failures.push({ path: 'registry', message: `No schema found for NOTE_TYPE: ${noteType}` }); + } else if (typeof schema.kind !== 'number') { + result.validation.status = 'FAIL'; + result.validation.failures.push({ path: 'registry', message: `Schema for ${noteType} has non-numeric kind: ${schema.kind}` }); + } + results.push(result); + } + + // Verify getAllNostrEventSchemas returns all types + const registryResult = { + builder: 'getAllNostrEventSchemas', + name: 'Schema registry completeness', + validation: { status: 'PASS', failures: [] }, + }; + if (schemaKeys.length < noteTypeValues.length) { + registryResult.validation.status = 'FAIL'; + registryResult.validation.failures.push({ + path: 'registry', + message: `getAllNostrEventSchemas returned ${schemaKeys.length} schemas but NOTE_TYPES has ${noteTypeValues.length} values` + }); + } + results.push(registryResult); + + return results; } async function main() { + const testCases = cliArgs.only + ? TEST_CASES.filter(tc => tc.builder === cliArgs.only) + : TEST_CASES; + + if (cliArgs.only && testCases.length === 0) { + console.error(`No test cases matched --only=${cliArgs.only}`); + process.exit(1); + } + console.log('Starting Event Schema Validation...'); + if (cliArgs.only) console.log(` Filtered to: ${cliArgs.only}`); + if (cliArgs.dryRun) console.log(' (dry-run mode — no report file will be written)'); + + const report = { + timestamp: new Date().toISOString(), + cli_args: { dry_run: cliArgs.dryRun, out: cliArgs.out, only: cliArgs.only }, + builder_results: [], + sanitizer_results: [], + schema_registry_results: [], + runtime_sites: RUNTIME_SITES, + summary: { total: 0, passed: 0, failed: 0 }, + }; + + // Run builder tests let failures = 0; + for (const tc of testCases) { + const result = runTest(tc); + report.builder_results.push(result); + if (result.validation.status === 'PASS') { + console.log(`\n✅ ${result.name} (Kind: ${result.event?.kind})`); + } else { + failures++; + console.error(`\n❌ ${result.name}`); + result.validation.failures.forEach(f => console.error(` - [${f.path}] ${f.message}`)); + } + } - // --- Real-world usage patterns --- + // Run sanitizer tests (unless --only filters them out) + if (!cliArgs.only || cliArgs.only === 'sanitizeAdditionalTags') { + for (const st of SANITIZER_TESTS) { + const result = runSanitizerTest(st); + report.sanitizer_results.push(result); + if (result.validation.status === 'PASS') { + console.log(`\n✅ ${result.name}`); + } else { + failures++; + console.error(`\n❌ ${result.name}`); + result.validation.failures.forEach(f => console.error(` - [${f.path}] ${f.message}`)); + } + } + } - // 1. Video Post (mimicking js/nostr/client.js publishVideo) - // Testing auto-generation of 's' tag from URL - const videoInputReal = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - dTagValue: 'test-video-id-123', - content: { - version: 3, - title: 'Real World Video', - videoRootId: 'test-video-id-123', - url: 'https://example.com/video.mp4', - mode: 'live', - deleted: false, - isPrivate: false, - isNsfw: false, - isForKids: false, - enableComments: true - }, - // No explicit 's' tag passed, should be generated - additionalTags: [['t', 'art']] - }; + // Run schema registry checks (unless --only filters them out) + if (!cliArgs.only) { + const registryResults = verifySchemaRegistry(); + report.schema_registry_results = registryResults; + for (const rr of registryResults) { + if (rr.validation.status === 'PASS') { + // Don't spam console for per-type registry checks; just count + } else { + failures++; + console.error(`\n❌ ${rr.name}`); + rr.validation.failures.forEach(f => console.error(` - [${f.path}] ${f.message}`)); + } + } + const regPassed = registryResults.filter(r => r.validation.status === 'PASS').length; + const regFailed = registryResults.filter(r => r.validation.status !== 'PASS').length; + console.log(`\n📋 Schema registry: ${regPassed} passed, ${regFailed} failed`); + } - if (!runTest('buildVideoPostEvent (Real Usage - Auto Storage Pointer)', buildVideoPostEvent, videoInputReal, NOTE_TYPES.VIDEO_POST, (event) => { - const sTag = event.tags.find(t => t[0] === 's'); - if (!sTag) throw new Error("Missing 's' tag (Storage Pointer) which should have been auto-generated from URL"); - if (!sTag[1].startsWith('url:')) throw new Error(`'s' tag should start with 'url:', got ${sTag[1]}`); - })) failures++; - - // 2. Relay List (mimicking js/relayManager.js) - const relayListInputReal = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - relays: [ - { url: 'wss://relay.damus.io', mode: 'read' }, - { url: 'wss://nos.lol', mode: 'write' }, - { url: 'wss://relay.primal.net', mode: 'both' } - ] - }; - if (!runTest('buildRelayListEvent (Real Usage)', buildRelayListEvent, relayListInputReal, NOTE_TYPES.RELAY_LIST, (event) => { - const rTags = event.tags.filter(t => t[0] === 'r'); - if (rTags.length !== 3) throw new Error(`Expected 3 'r' tags, got ${rTags.length}`); - const damus = rTags.find(t => t[1] === 'wss://relay.damus.io'); - if (!damus || damus[2] !== 'read') throw new Error("Damus relay missing or incorrect marker"); - })) failures++; - - // 3. Hashtag Preferences (mimicking js/services/hashtagPreferencesService.js) - // Content is encrypted string (ciphertext) - const hashtagInputReal = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - content: "nip44:ciphertext-blob-simulated", - // Service adds encrypted tag - additionalTags: [['encrypted', 'nip44']] - }; - if (!runTest('buildHashtagPreferenceEvent (Real Usage - Encrypted)', buildHashtagPreferenceEvent, hashtagInputReal, NOTE_TYPES.HASHTAG_PREFERENCES, (event) => { - if (event.content !== "nip44:ciphertext-blob-simulated") throw new Error("Content mismtach"); - const dTag = event.tags.find(t => t[0] === 'd'); - if (!dTag || dTag[1] !== 'bitvid:tag-preferences') throw new Error("Incorrect 'd' tag identifier"); - })) failures++; - - - // --- Standard Checks --- - - // 4. HTTP Auth - const httpAuthInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - url: 'https://api.example.com/auth', - method: 'POST', - payload: 'sha256-hash-of-body' - }; - if (!runTest('buildHttpAuthEvent', buildHttpAuthEvent, httpAuthInput, NOTE_TYPES.HTTP_AUTH)) failures++; - - // 5. Report - const reportInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - eventId: 'e'.repeat(64), - reportType: 'spam', - content: 'This is spam' - }; - if (!runTest('buildReportEvent', buildReportEvent, reportInput, NOTE_TYPES.REPORT)) failures++; - - // 6. Video Mirror (NIP-94) - const mirrorInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - content: 'Mirror description' - }; - if (!runTest('buildVideoMirrorEvent', buildVideoMirrorEvent, mirrorInput, NOTE_TYPES.VIDEO_MIRROR)) failures++; - - // 7. Repost - const repostInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - eventId: 'e'.repeat(64), - eventRelay: 'wss://relay.example.com' - }; - if (!runTest('buildRepostEvent', buildRepostEvent, repostInput, NOTE_TYPES.REPOST)) failures++; - - // 8. Share - const shareInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - content: 'Check this out', - video: { id: 'e'.repeat(64), pubkey: TEST_PUBKEY } - }; - if (!runTest('buildShareEvent', buildShareEvent, shareInput, NOTE_TYPES.SHARE)) failures++; - - // 9. DM Relay List - const dmRelayListInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - relays: ['wss://dm.relay.com'] - }; - if (!runTest('buildDmRelayListEvent', buildDmRelayListEvent, dmRelayListInput, NOTE_TYPES.DM_RELAY_LIST)) failures++; - - // 10. Profile Metadata - const profileInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - metadata: { name: 'Alice', about: 'Test user' } - }; - if (!runTest('buildProfileMetadataEvent', buildProfileMetadataEvent, profileInput, NOTE_TYPES.PROFILE_METADATA)) failures++; - - // 11. Mute List - const muteListInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - pTags: [TEST_PUBKEY] - }; - if (!runTest('buildMuteListEvent', buildMuteListEvent, muteListInput, NOTE_TYPES.MUTE_LIST)) failures++; - - // 12. Deletion - const deletionInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - eventIds: ['e'.repeat(64)], - reason: 'Mistake' - }; - if (!runTest('buildDeletionEvent', buildDeletionEvent, deletionInput, NOTE_TYPES.DELETION)) failures++; - - // 13. Legacy DM - const legacyDmInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - recipientPubkey: TEST_PUBKEY, - ciphertext: 'encrypted-stuff' - }; - if (!runTest('buildLegacyDirectMessageEvent', buildLegacyDirectMessageEvent, legacyDmInput, NOTE_TYPES.LEGACY_DM)) failures++; - - // 14. DM Attachment - const dmAttachmentInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - recipientPubkey: TEST_PUBKEY, - attachment: { url: 'https://example.com/file.jpg', type: 'image/jpeg' } - }; - if (!runTest('buildDmAttachmentEvent', buildDmAttachmentEvent, dmAttachmentInput, NOTE_TYPES.DM_ATTACHMENT)) failures++; - - // 15. DM Read Receipt - const dmReadReceiptInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - recipientPubkey: TEST_PUBKEY, - eventId: 'e'.repeat(64) - }; - if (!runTest('buildDmReadReceiptEvent', buildDmReadReceiptEvent, dmReadReceiptInput, NOTE_TYPES.DM_READ_RECEIPT)) failures++; - - // 16. DM Typing Indicator - const dmTypingInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - recipientPubkey: TEST_PUBKEY - }; - if (!runTest('buildDmTypingIndicatorEvent', buildDmTypingIndicatorEvent, dmTypingInput, NOTE_TYPES.DM_TYPING)) failures++; - - // 17. View Event - const viewInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - pointerValue: 'test-pointer', - pointerTag: ['a', 'test-pointer'] - }; - if (!runTest('buildViewEvent', buildViewEvent, viewInput, NOTE_TYPES.VIEW_EVENT)) failures++; - - // 18. Zap Request - const zapRequestInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - recipientPubkey: TEST_PUBKEY, - amountSats: 100, - relays: ['wss://relay.zap.com'] - }; - if (!runTest('buildZapRequestEvent', buildZapRequestEvent, zapRequestInput, NOTE_TYPES.ZAP_REQUEST)) failures++; - - // 19. Reaction - const reactionInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - pointerTag: ['e', 'e'.repeat(64)], - content: '+' - }; - if (!runTest('buildReactionEvent', buildReactionEvent, reactionInput, NOTE_TYPES.VIDEO_REACTION)) failures++; - - // 20. Comment - const commentInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - videoEventId: 'e'.repeat(64), - content: 'Nice video!' - }; - if (!runTest('buildCommentEvent', buildCommentEvent, commentInput, NOTE_TYPES.VIDEO_COMMENT)) failures++; - - // 21. Watch History - const watchHistoryInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - monthIdentifier: '2023-01', - content: JSON.stringify({ ['e'.repeat(64)]: 123456 }) - }; - if (!runTest('buildWatchHistoryEvent', buildWatchHistoryEvent, watchHistoryInput, NOTE_TYPES.WATCH_HISTORY)) failures++; - - // 22. Subscription List - const subListInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP - }; - if (!runTest('buildSubscriptionListEvent', buildSubscriptionListEvent, subListInput, NOTE_TYPES.SUBSCRIPTION_LIST)) failures++; - - // 23. Block List - const blockListInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP - }; - if (!runTest('buildBlockListEvent', buildBlockListEvent, blockListInput, NOTE_TYPES.USER_BLOCK_LIST)) failures++; - - // 24. Admin List - const adminListInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - hexPubkeys: [TEST_PUBKEY] - }; - if (!runTest('buildAdminListEvent (moderation)', (i) => buildAdminListEvent(ADMIN_LIST_IDENTIFIERS.moderation, i), adminListInput, NOTE_TYPES.ADMIN_MODERATION_LIST)) failures++; - - // 25. NIP-71 Video - const nip71Input = { - pubkey: TEST_PUBKEY, - title: 'NIP-71 Video', - metadata: { - kind: 21, - title: 'NIP-71 Video', - publishedAt: TEST_TIMESTAMP, - hashtags: ['test'] - }, - pointerIdentifiers: { videoRootId: 'test-root' } - }; - if (!runTest('buildNip71VideoEvent', buildNip71VideoEvent, nip71Input, NOTE_TYPES.NIP71_VIDEO)) failures++; - - // 26. NIP-17 Gift Wrap - const giftWrapInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - recipientPubkey: TEST_PUBKEY, - ciphertext: 'encrypted-seal', - relayHint: 'wss://relay.example.com' - }; - if (!runTest('buildGiftWrapEvent', buildGiftWrapEvent, giftWrapInput, NOTE_TYPES.GIFT_WRAP)) failures++; - - // 27. NIP-17 Seal - const sealInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - ciphertext: 'encrypted-rumor' - }; - if (!runTest('buildSealEvent', buildSealEvent, sealInput, NOTE_TYPES.SEAL)) failures++; - - // 28. NIP-17 Chat Message - const chatMessageInput = { - pubkey: TEST_PUBKEY, - created_at: TEST_TIMESTAMP, - recipientPubkey: TEST_PUBKEY, - content: 'Hello World' - }; - if (!runTest('buildChatMessageEvent', buildChatMessageEvent, chatMessageInput, NOTE_TYPES.CHAT_MESSAGE)) failures++; + // Compute summary + const allResults = [...report.builder_results, ...report.sanitizer_results, ...report.schema_registry_results]; + report.summary.total = allResults.length; + report.summary.passed = allResults.filter(r => r.validation.status === 'PASS').length; + report.summary.failed = allResults.filter(r => r.validation.status !== 'PASS').length; + // Write report + if (cliArgs.out && !cliArgs.dryRun) { + const outDir = path.dirname(cliArgs.out); + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + fs.writeFileSync(cliArgs.out, JSON.stringify(report, null, 2)); + console.log(`\n📄 Report written to: ${cliArgs.out}`); + } console.log('\n-----------------------------------'); if (failures === 0) { - console.log('🎉 All schema validation tests passed!'); + console.log(`✓ All ${report.summary.total} validation checks passed!`); process.exit(0); } else { - console.error(`💥 ${failures} tests failed.`); + console.error(`${failures} checks failed out of ${report.summary.total}.`); process.exit(1); } }