UNPKG

@nostr-dev-kit/ndk

Version:

NDK - Nostr Development Kit. Includes AI Guardrails to catch common mistakes during development.

944 lines (781 loc) 36.9 kB
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type RelayMock, RelayPoolMock } from "../../test"; import { NDKKind } from "../events/kinds"; import { NDK } from "../ndk"; import type { NDKRelay } from "../relay"; import { NDKRelaySet } from "../relay/sets"; import { type NDKFilter, NDKSubscription } from "../subscription"; describe("Filter Validation", () => { let ndk: NDK; let pool: RelayPoolMock; let mockRelay: RelayMock; // Valid hex pubkeys/ids for testing (64 character hex strings) const validPubkey1 = "0000000000000000000000000000000000000000000000000000000000000001"; const validPubkey2 = "0000000000000000000000000000000000000000000000000000000000000002"; const validPubkey3 = "0000000000000000000000000000000000000000000000000000000000000003"; const validEventId1 = "1111111111111111111111111111111111111111111111111111111111111111"; const validEventId2 = "2222222222222222222222222222222222222222222222222222222222222222"; beforeEach(() => { // Create a mock relay pool pool = new RelayPoolMock(); // Create NDK instance with explicit relay URLs ndk = new NDK({ explicitRelayUrls: ["wss://relay.test.com"], // Start with validate mode by default filterValidationMode: "validate", }); // Replace the relay pool with our mock // @ts-expect-error - We're intentionally replacing the pool for testing ndk.pool = pool; // Add a mock relay mockRelay = pool.addMockRelay("wss://relay.test.com"); }); afterEach(() => { pool.disconnectAll(); pool.resetAll(); }); describe("Validate mode (default)", () => { it("should throw an error when filter contains undefined in authors array", () => { const badFilter: NDKFilter = { // @ts-expect-error - Intentionally passing bad data authors: [validPubkey1, undefined, validPubkey2], kinds: [NDKKind.Text], }; expect(() => { ndk.subscribe(badFilter); }).toThrow("Invalid filter(s) detected"); }); it("should throw an error when filter contains undefined in kinds array", () => { const badFilter: NDKFilter = { authors: [validPubkey1], // @ts-expect-error - Intentionally passing bad data kinds: [NDKKind.Text, undefined, NDKKind.Metadata], }; expect(() => { ndk.subscribe(badFilter); }).toThrow("Invalid filter(s) detected"); }); it("should throw an error when filter contains undefined in ids array", () => { const badFilter: NDKFilter = { // @ts-expect-error - Intentionally passing bad data ids: [validEventId1, undefined, validEventId2], kinds: [NDKKind.Text], }; expect(() => { ndk.subscribe(badFilter); }).toThrow("Invalid filter(s) detected"); }); it("should throw an error when filter contains undefined in tag filters", () => { const badFilter: NDKFilter = { kinds: [NDKKind.Text], // @ts-expect-error - Intentionally passing bad data "#t": ["bitcoin", undefined, "nostr"], }; expect(() => { ndk.subscribe(badFilter); }).toThrow("Invalid filter(s) detected"); }); it("should NOT throw when filter is valid", () => { const goodFilter: NDKFilter = { authors: [validPubkey1, validPubkey2], kinds: [NDKKind.Text, NDKKind.Metadata], }; expect(() => { const sub = ndk.subscribe(goodFilter); sub.stop(); // Clean up }).not.toThrow(); }); it("should provide detailed error message with all issues", () => { const badFilter: NDKFilter = { // @ts-expect-error - Intentionally passing bad data authors: [validPubkey1, undefined], // @ts-expect-error - Intentionally passing bad data kinds: [NDKKind.Text, undefined], // @ts-expect-error - Intentionally passing bad data "#t": ["bitcoin", undefined], }; expect(() => { ndk.subscribe(badFilter); }).toThrow( /Filter\[0\]\.authors\[1\] is undefined.*Filter\[0\]\.kinds\[1\] is undefined.*Filter\[0\]\.#t\[1\] is undefined/s, ); }); }); describe("Fix mode", () => { beforeEach(() => { ndk.filterValidationMode = "fix"; }); it("should remove undefined values from authors and send clean filter to relay", async () => { const badFilter: NDKFilter = { // @ts-expect-error - Intentionally passing bad data authors: [validPubkey1, undefined, validPubkey2], kinds: [NDKKind.Text], }; const relaySet = new NDKRelaySet(new Set([mockRelay as unknown as NDKRelay]), ndk); const sub = new NDKSubscription(ndk, badFilter, { subId: "test-sub-fix-authors", relaySet, }); // Start the subscription sub.start(); // Wait a bit for the subscription to be sent await new Promise((resolve) => setTimeout(resolve, 10)); // Check what was sent to the relay const sentMessages = mockRelay.messageLog.filter((m) => m.direction === "out"); expect(sentMessages).toHaveLength(1); const sentMessage = JSON.parse(sentMessages[0].message); expect(sentMessage[0]).toBe("REQ"); expect(sentMessage[1]).toBe("test-sub-fix-authors"); const sentFilter = sentMessage[2]; expect(sentFilter.authors).toEqual([validPubkey1, validPubkey2]); // undefined removed expect(sentFilter.kinds).toEqual([NDKKind.Text]); sub.stop(); }); it("should remove undefined values from kinds and send clean filter to relay", async () => { const badFilter: NDKFilter = { authors: [validPubkey1], // @ts-expect-error - Intentionally passing bad data kinds: [NDKKind.Text, undefined, NDKKind.Metadata], }; const relaySet = new NDKRelaySet(new Set([mockRelay as unknown as NDKRelay]), ndk); const sub = new NDKSubscription(ndk, badFilter, { subId: "test-sub-fix-kinds", relaySet, }); sub.start(); await new Promise((resolve) => setTimeout(resolve, 10)); const sentMessages = mockRelay.messageLog.filter((m) => m.direction === "out"); const sentMessage = JSON.parse(sentMessages[0].message); const sentFilter = sentMessage[2]; expect(sentFilter.kinds).toEqual([NDKKind.Text, NDKKind.Metadata]); // undefined removed expect(sentFilter.authors).toEqual([validPubkey1]); sub.stop(); }); it("should remove undefined values from tag filters and send clean filter to relay", async () => { const badFilter: NDKFilter = { kinds: [NDKKind.Text], // @ts-expect-error - Intentionally passing bad data "#t": ["bitcoin", undefined, "nostr"], // @ts-expect-error - Intentionally passing bad data "#p": [undefined, validPubkey1], }; const relaySet = new NDKRelaySet(new Set([mockRelay as unknown as NDKRelay]), ndk); const sub = new NDKSubscription(ndk, badFilter, { subId: "test-sub-fix-tags", relaySet, }); sub.start(); await new Promise((resolve) => setTimeout(resolve, 10)); const sentMessages = mockRelay.messageLog.filter((m) => m.direction === "out"); const sentMessage = JSON.parse(sentMessages[0].message); const sentFilter = sentMessage[2]; expect(sentFilter["#t"]).toEqual(["bitcoin", "nostr"]); // undefined removed expect(sentFilter["#p"]).toEqual([validPubkey1]); // undefined removed sub.stop(); }); it("should handle filters that become empty after fixing", async () => { const badFilter: NDKFilter = { // @ts-expect-error - Intentionally passing bad data authors: [undefined, undefined], // All undefined kinds: [NDKKind.Text], }; const relaySet = new NDKRelaySet(new Set([mockRelay as unknown as NDKRelay]), ndk); const sub = new NDKSubscription(ndk, badFilter, { subId: "test-sub-fix-empty", relaySet, }); sub.start(); await new Promise((resolve) => setTimeout(resolve, 10)); const sentMessages = mockRelay.messageLog.filter((m) => m.direction === "out"); const sentMessage = JSON.parse(sentMessages[0].message); const sentFilter = sentMessage[2]; // Authors field should be removed entirely when all values are undefined expect(sentFilter.authors).toBeUndefined(); expect(sentFilter.kinds).toEqual([NDKKind.Text]); sub.stop(); }); }); describe("Ignore mode (legacy behavior)", () => { beforeEach(() => { ndk.filterValidationMode = "ignore"; }); it("should pass filters with undefined values as-is without throwing", () => { const badFilter: NDKFilter = { // @ts-expect-error - Intentionally passing bad data authors: [validPubkey1, undefined, validPubkey2], kinds: [NDKKind.Text], }; expect(() => { const sub = ndk.subscribe(badFilter); sub.stop(); }).not.toThrow(); }); it("should send filters with undefined values to relay (will likely cause issues)", async () => { const badFilter: NDKFilter = { // @ts-expect-error - Intentionally passing bad data authors: [validPubkey1, undefined, validPubkey2], kinds: [NDKKind.Text], }; const relaySet = new NDKRelaySet(new Set([mockRelay as unknown as NDKRelay]), ndk); const sub = new NDKSubscription(ndk, badFilter, { subId: "test-sub-ignore", relaySet, }); sub.start(); await new Promise((resolve) => setTimeout(resolve, 10)); const sentMessages = mockRelay.messageLog.filter((m) => m.direction === "out"); const sentMessage = JSON.parse(sentMessages[0].message); const sentFilter = sentMessage[2]; // In ignore mode, the filter is sent as-is with undefined values // Note: JSON.stringify converts undefined to null in arrays expect(sentFilter.authors).toEqual([validPubkey1, null, validPubkey2]); sub.stop(); }); }); describe("Multiple filters in subscription", () => { it("should validate all filters in validate mode", () => { ndk.filterValidationMode = "validate"; const badFilters: NDKFilter[] = [ { // @ts-expect-error authors: [validPubkey1, undefined], kinds: [NDKKind.Text], }, { authors: [validPubkey2], // @ts-expect-error kinds: [NDKKind.Metadata, undefined], }, ]; expect(() => { ndk.subscribe(badFilters); }).toThrow(/Filter\[0\]\.authors\[1\] is undefined.*Filter\[1\]\.kinds\[1\] is undefined/s); }); it("should fix all filters in fix mode", async () => { ndk.filterValidationMode = "fix"; const badFilters: NDKFilter[] = [ { // @ts-expect-error authors: [validPubkey1, undefined], kinds: [NDKKind.Text], }, { authors: [validPubkey2], // @ts-expect-error kinds: [NDKKind.Metadata, undefined], }, ]; const relaySet = new NDKRelaySet(new Set([mockRelay as unknown as NDKRelay]), ndk); const sub = new NDKSubscription(ndk, badFilters, { subId: "test-sub-multi-fix", relaySet, }); sub.start(); await new Promise((resolve) => setTimeout(resolve, 10)); const sentMessages = mockRelay.messageLog.filter((m) => m.direction === "out"); const sentMessage = JSON.parse(sentMessages[0].message); // Filters are at indices 2 and 3 in the REQ message const sentFilter1 = sentMessage[2]; const sentFilter2 = sentMessage[3]; expect(sentFilter1.authors).toEqual([validPubkey1]); expect(sentFilter2.kinds).toEqual([NDKKind.Metadata]); sub.stop(); }); }); describe("Integration with ndk.subscribe()", () => { it("should use NDK instance's filterValidationMode setting", () => { // Test validate mode ndk.filterValidationMode = "validate"; expect(() => { // @ts-expect-error ndk.subscribe({ authors: [undefined, validPubkey1] }); }).toThrow(); // Test fix mode ndk.filterValidationMode = "fix"; expect(() => { const sub = ndk.subscribe({ // @ts-expect-error authors: [undefined, validPubkey1], }); sub.stop(); }).not.toThrow(); // Test ignore mode ndk.filterValidationMode = "ignore"; expect(() => { const sub = ndk.subscribe({ // @ts-expect-error authors: [undefined, validPubkey1], }); sub.stop(); }).not.toThrow(); }); it("should work with real-world filter scenarios", async () => { ndk.filterValidationMode = "fix"; // Simulate a real-world scenario where a map operation might return undefined const userIds = ["user1", "deleted_user", "user3"]; const getUserPubkey = (id: string) => { if (id === "deleted_user") return undefined; // Return valid hex pubkeys if (id === "user1") return "0000000000000000000000000000000000000000000000000000000000000004"; if (id === "user3") return "0000000000000000000000000000000000000000000000000000000000000005"; return undefined; }; const filter: NDKFilter = { // @ts-expect-error - This is what would happen in real code authors: userIds.map(getUserPubkey), kinds: [NDKKind.Text, NDKKind.Article], }; const relaySet = new NDKRelaySet(new Set([mockRelay as unknown as NDKRelay]), ndk); const sub = new NDKSubscription(ndk, filter, { subId: "real-world-test", relaySet, }); sub.start(); await new Promise((resolve) => setTimeout(resolve, 10)); const sentMessages = mockRelay.messageLog.filter((m) => m.direction === "out"); const sentMessage = JSON.parse(sentMessages[0].message); const sentFilter = sentMessage[2]; // Should have removed the undefined value expect(sentFilter.authors).toEqual([ "0000000000000000000000000000000000000000000000000000000000000004", "0000000000000000000000000000000000000000000000000000000000000005", ]); sub.stop(); }); }); describe("AI Guardrails - Bech32 in filter arrays", () => { beforeEach(() => { // Enable AI guardrails for these tests ndk = new NDK({ explicitRelayUrls: ["wss://relay.test.com"], filterValidationMode: "validate", aiGuardrails: true, }); // @ts-expect-error - We're intentionally replacing the pool for testing ndk.pool = pool; mockRelay = pool.addMockRelay("wss://relay.test.com"); }); describe("bech32 in ids array", () => { it("should throw fatal error when ids contains note1 bech32", () => { const badFilter: NDKFilter = { ids: ["note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsq8l0j"], kinds: [1], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/ids\[0\] contains bech32/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/Use filterFromId\(\)/); }); it("should throw fatal error when ids contains nevent1 bech32", () => { const badFilter: NDKFilter = { ids: ["nevent1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs9p2gz3"], kinds: [1], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/ids\[0\] contains bech32/); }); it("should fall back to standard validation when trying to skip the check", () => { // Reinitialize with skip set ndk = new NDK({ explicitRelayUrls: ["wss://relay.test.com"], filterValidationMode: "validate", aiGuardrails: { skip: new Set(["filter-bech32-in-array"]), }, }); // @ts-expect-error ndk.pool = pool; const badFilter: NDKFilter = { ids: ["note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsq8l0j"], kinds: [1], }; // Should still throw, but from standard filter validation (not AI guardrails) // because bech32 is not a valid 64-char hex string expect(() => { ndk.subscribe(badFilter); }).toThrow(/Invalid filter\(s\) detected/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/is not a valid 64-char hex string/); }); it("should allow valid hex ids", () => { const goodFilter: NDKFilter = { ids: [validEventId1, validEventId2], kinds: [1], }; expect(() => { const sub = ndk.subscribe(goodFilter); sub.stop(); }).not.toThrow(); }); }); describe("bech32 in authors array", () => { it("should throw fatal error when authors contains npub bech32", () => { const badFilter: NDKFilter = { authors: ["npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwv37l"], kinds: [1], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/authors\[0\] contains bech32/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/Use ndkUser\.pubkey instead/); }); it("should throw fatal error when authors contains nprofile bech32", () => { const badFilter: NDKFilter = { authors: ["nprofile1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs0enayy"], kinds: [1], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/authors\[0\] contains bech32/); }); it("should throw fatal error for multiple authors with one being bech32", () => { const badFilter: NDKFilter = { authors: [ validPubkey1, "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwv37l", validPubkey2, ], kinds: [1], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/authors\[1\] contains bech32/); }); it("should allow valid hex pubkeys", () => { const goodFilter: NDKFilter = { authors: [validPubkey1, validPubkey2, validPubkey3], kinds: [1], }; expect(() => { const sub = ndk.subscribe(goodFilter); sub.stop(); }).not.toThrow(); }); }); describe("bech32 in tag filters", () => { it("should throw fatal error when #e tag contains note1 bech32", () => { const badFilter: NDKFilter = { kinds: [1], "#e": ["note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsq8l0j"], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/#e\[0\] contains bech32/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/Use filterFromId\(\) or nip19\.decode\(\)/); }); it("should throw fatal error when #p tag contains npub bech32", () => { const badFilter: NDKFilter = { kinds: [1], "#p": ["npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwv37l"], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/#p\[0\] contains bech32/); }); it("should throw fatal error for naddr in #a tag filter", () => { const badFilter: NDKFilter = { kinds: [1], "#a": ["naddr1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsdtpwtk"], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/#a\[0\] has invalid format/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/Must be "kind:pubkey:d-tag"/); }); it("should throw fatal error for non-addressable kind in #a tag filter", () => { const badFilter: NDKFilter = { kinds: [1], "#a": ["20:fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52:undefined"], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/#a\[0\] uses non-addressable kind 20/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/#a filters are only for addressable events \(kinds 30000-39999\)/); }); it("should throw for kind 1 in #a tag", () => { const badFilter: NDKFilter = { kinds: [1], "#a": ["1:fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52:test"], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/uses non-addressable kind 1/); }); it("should throw for kind 29999 (just below addressable range) in #a tag", () => { const badFilter: NDKFilter = { kinds: [1], "#a": ["29999:fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52:test"], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/uses non-addressable kind 29999/); }); it("should throw for kind 40000 (just above addressable range) in #a tag", () => { const badFilter: NDKFilter = { kinds: [1], "#a": ["40000:fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52:test"], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/uses non-addressable kind 40000/); }); it("should allow valid addressable kind 30000 in #a tag", () => { const goodFilter: NDKFilter = { kinds: [1], "#a": ["30000:fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52:test"], }; expect(() => { const sub = ndk.subscribe(goodFilter); sub.stop(); }).not.toThrow(); }); it("should allow valid addressable kind 30023 (article) in #a tag", () => { const goodFilter: NDKFilter = { kinds: [1], "#a": ["30023:fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52:my-article"], }; expect(() => { const sub = ndk.subscribe(goodFilter); sub.stop(); }).not.toThrow(); }); it("should allow valid addressable kind 39999 (max range) in #a tag", () => { const goodFilter: NDKFilter = { kinds: [1], "#a": ["39999:fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52:test"], }; expect(() => { const sub = ndk.subscribe(goodFilter); sub.stop(); }).not.toThrow(); }); it("should allow valid hex values in #e and #p tags", () => { const goodFilter: NDKFilter = { kinds: [1], "#e": [validEventId1, validEventId2], "#p": [validPubkey1, validPubkey2], }; expect(() => { const sub = ndk.subscribe(goodFilter); sub.stop(); }).not.toThrow(); }); it("should allow non-hex strings in custom tag filters", () => { const goodFilter: NDKFilter = { kinds: [1], "#t": ["bitcoin", "nostr", "programming"], "#d": ["my-article-identifier"], }; expect(() => { const sub = ndk.subscribe(goodFilter); sub.stop(); }).not.toThrow(); }); }); describe("all bech32 formats", () => { const bech32Formats = [ { prefix: "note1", description: "note (event ID)" }, { prefix: "npub1", description: "npub (public key)" }, { prefix: "naddr1", description: "naddr (addressable event)" }, { prefix: "nevent1", description: "nevent (event with relay hints)" }, { prefix: "nprofile1", description: "nprofile (profile with relay hints)" }, ]; bech32Formats.forEach(({ prefix, description }) => { it(`should detect and reject ${description}`, () => { const badFilter: NDKFilter = { authors: [`${prefix}qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq`], kinds: [1], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/contains bech32/); }); }); }); describe("hashtag filters with # prefix", () => { it("should throw fatal error when #t filter contains hashtag with # prefix", () => { const badFilter: NDKFilter = { kinds: [1], "#t": ["#nostr"], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/#t\[0\] contains hashtag with # prefix/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/"#nostr"/); }); it("should throw fatal error for multiple hashtags with # prefix", () => { const badFilter: NDKFilter = { kinds: [1], "#t": ["#bitcoin", "#nostr", "programming"], }; expect(() => { ndk.subscribe(badFilter); }).toThrow(/AI_GUARDRAILS/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/#t\[0\] contains hashtag with # prefix/); expect(() => { ndk.subscribe(badFilter); }).toThrow(/"#bitcoin"/); }); it("should allow valid hashtags without # prefix", () => { const goodFilter: NDKFilter = { kinds: [1], "#t": ["bitcoin", "nostr", "programming"], }; expect(() => { const sub = ndk.subscribe(goodFilter); sub.stop(); }).not.toThrow(); }); it("should provide helpful hint about removing # prefix", () => { const badFilter: NDKFilter = { kinds: [1], "#t": ["#nostr"], }; try { ndk.subscribe(badFilter); expect.fail("Should have thrown"); } catch (error: any) { expect(error.message).toContain("Remove the # prefix from hashtag filters"); expect(error.message).toContain('✅ { "#t": ["nostr"] }'); expect(error.message).toContain('❌ { "#t": ["#nostr"] }'); } }); }); describe("error messages and hints", () => { it("should provide helpful hint for ids with bech32", () => { const badFilter: NDKFilter = { ids: ["note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsq8l0j"], kinds: [1], }; try { ndk.subscribe(badFilter); expect.fail("Should have thrown"); } catch (error: any) { expect(error.message).toContain("IDs must be hex, not bech32"); expect(error.message).toContain("Use filterFromId() to decode bech32 first"); expect(error.message).toContain('@nostr-dev-kit/ndk"'); } }); it("should provide helpful hint for authors with bech32", () => { const badFilter: NDKFilter = { authors: ["npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwv37l"], kinds: [1], }; try { ndk.subscribe(badFilter); expect.fail("Should have thrown"); } catch (error: any) { expect(error.message).toContain("Authors must be hex pubkeys, not npub"); expect(error.message).toContain("Use ndkUser.pubkey instead"); expect(error.message).toContain("{ authors: [ndkUser.pubkey] }"); } }); it("should provide helpful hint for tag filters with bech32", () => { const badFilter: NDKFilter = { kinds: [1], "#e": ["note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsq8l0j"], }; try { ndk.subscribe(badFilter); expect.fail("Should have thrown"); } catch (error: any) { expect(error.message).toContain("Tag values must be decoded"); expect(error.message).toContain("Use filterFromId() or nip19.decode()"); } }); it("should catch invalid pubkeys from kind:3 follow lists", () => { const badFilter: NDKFilter = { authors: [ validPubkey1, "Follow List", // Invalid entry from corrupted follow list validPubkey2, "highlig", // Another invalid entry ], kinds: [1], }; try { ndk.subscribe(badFilter); expect.fail("Should have thrown"); } catch (error: any) { expect(error.message).toContain("AI_GUARDRAILS ERROR"); expect(error.message).toContain("is not a valid 64-char hex pubkey"); expect(error.message).toContain('"Follow List"'); expect(error.message).toContain("Kind:3 follow lists can contain invalid entries"); expect(error.message).toContain('labels ("Follow List")'); expect(error.message).toContain('partial strings ("highlig")'); expect(error.message).toContain("You MUST validate all pubkeys"); expect(error.message).toContain( "const validPubkeys = pubkeys.filter(p => /^[0-9a-f]{64}$/i.test(p));", ); } }); }); describe("multiple bech32 errors in one filter", () => { it("should throw on first bech32 error encountered (ids checked first)", () => { const badFilter: NDKFilter = { ids: ["note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsq8l0j"], authors: ["npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwv37l"], "#e": ["nevent1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs9p2gz3"], kinds: [1], }; try { ndk.subscribe(badFilter); expect.fail("Should have thrown"); } catch (error: any) { // Guardrails throw on first error, so we only see the ids error expect(error.message).toContain("ids[0] contains bech32"); expect(error.message).toContain("AI_GUARDRAILS ERROR"); } }); }); }); });