import { describe, it, expect } from "vitest"; import { encodeSpell, decodeSpell } from "./spell-conversion"; import type { SpellEvent } from "@/types/spell"; describe("Spell Conversion", () => { describe("encodeSpell", () => { it("should encode a simple REQ command with kinds", () => { const result = encodeSpell({ command: "req -k 1,3,7", description: "Test spell", }); expect(result.tags).toContainEqual(["cmd", "REQ"]); expect(result.tags).toContainEqual(["client", "grimoire"]); expect(result.tags).toContainEqual(["k", "1"]); expect(result.tags).toContainEqual(["k", "3"]); expect(result.tags).toContainEqual(["k", "7"]); expect(result.filter.kinds).toEqual([1, 3, 7]); expect(result.content).toBe("Test spell"); }); it("should encode with optional description", () => { const result = encodeSpell({ command: "req -k 1", description: "Test spell", }); expect(result.tags).toContainEqual(["cmd", "REQ"]); expect(result.content).toBe("Test spell"); }); it("should encode with empty description", () => { const result = encodeSpell({ command: "req -k 1", }); expect(result.tags).toContainEqual(["cmd", "REQ"]); expect(result.content).toBe(""); }); it("should encode optional name tag", () => { const result = encodeSpell({ command: "req -k 1", name: "Bitcoin Feed", }); expect(result.tags).toContainEqual(["name", "Bitcoin Feed"]); expect(result.tags).toContainEqual(["cmd", "REQ"]); }); it("should skip name tag if not provided", () => { const result = encodeSpell({ command: "req -k 1", }); const nameTag = result.tags.find((t) => t[0] === "name"); expect(nameTag).toBeUndefined(); }); it("should trim and skip empty name", () => { const result = encodeSpell({ command: "req -k 1", name: " ", }); const nameTag = result.tags.find((t) => t[0] === "name"); expect(nameTag).toBeUndefined(); }); it("should encode both name and description", () => { const result = encodeSpell({ command: "req -k 1", name: "Bitcoin Feed", description: "Notes about Bitcoin", }); expect(result.tags).toContainEqual(["name", "Bitcoin Feed"]); expect(result.content).toBe("Notes about Bitcoin"); }); it("should encode authors as array tag", () => { const hex1 = "a".repeat(64); const hex2 = "b".repeat(64); const result = encodeSpell({ command: `req -k 1 -a ${hex1},${hex2}`, description: "Author spell", }); const authorsTag = result.tags.find((t) => t[0] === "authors"); expect(authorsTag).toEqual(["authors", hex1, hex2]); expect(result.filter.authors).toEqual([hex1, hex2]); }); it("should encode limit, since, until", () => { const result = encodeSpell({ command: "req -k 1 -l 50 --since 7d --until now", description: "Time spell", }); expect(result.tags).toContainEqual(["limit", "50"]); expect(result.tags).toContainEqual(["since", "7d"]); expect(result.tags).toContainEqual(["until", "now"]); expect(result.filter.limit).toBe(50); }); it("should encode tag filters with new format", () => { const hex = "c".repeat(64); const result = encodeSpell({ command: `req -k 1 -t bitcoin,nostr -p ${hex} -d article1`, description: "Tag spell", }); expect(result.tags).toContainEqual(["tag", "t", "bitcoin", "nostr"]); expect(result.tags).toContainEqual(["tag", "p", hex]); expect(result.tags).toContainEqual(["tag", "d", "article1"]); expect(result.filter["#t"]).toEqual(["bitcoin", "nostr"]); expect(result.filter["#p"]).toEqual([hex]); expect(result.filter["#d"]).toEqual(["article1"]); }); it("should encode search query", () => { const result = encodeSpell({ command: 'req -k 1 --search "bitcoin price"', description: "Search spell", }); expect(result.tags).toContainEqual(["search", "bitcoin price"]); expect(result.filter.search).toBe("bitcoin price"); }); it("should encode relays", () => { const result = encodeSpell({ command: "req -k 1 wss://relay1.com wss://relay2.com", description: "Relay spell", }); const relaysTag = result.tags.find((t) => t[0] === "relays"); expect(relaysTag).toEqual([ "relays", "wss://relay1.com/", "wss://relay2.com/", ]); expect(result.relays).toEqual(["wss://relay1.com/", "wss://relay2.com/"]); }); it("should encode close-on-eose flag", () => { const result = encodeSpell({ command: "req -k 1 --close-on-eose", description: "Close spell", }); const closeTag = result.tags.find((t) => t[0] === "close-on-eose"); expect(closeTag).toBeDefined(); expect(result.closeOnEose).toBe(true); }); it("should add topic tags", () => { const result = encodeSpell({ command: "req -k 1", description: "A test spell", topics: ["bitcoin", "news"], }); expect(result.tags).toContainEqual([ "alt", "Grimoire REQ spell: A test spell", ]); expect(result.tags).toContainEqual(["t", "bitcoin"]); expect(result.tags).toContainEqual(["t", "news"]); expect(result.content).toBe("A test spell"); }); it("should add fork provenance", () => { const result = encodeSpell({ command: "req -k 1", description: "Forked spell", forkedFrom: "abc123def456", }); expect(result.tags).toContainEqual(["e", "abc123def456"]); }); it("should handle special aliases $me and $contacts", () => { const result = encodeSpell({ command: "req -k 1 -a $me,$contacts", description: "Alias spell", }); const authorsTag = result.tags.find((t) => t[0] === "authors"); expect(authorsTag).toEqual(["authors", "$me", "$contacts"]); expect(result.filter.authors).toEqual(["$me", "$contacts"]); }); it("should handle uppercase P tag separately from lowercase p", () => { const hex1 = "d".repeat(64); const hex2 = "e".repeat(64); const result = encodeSpell({ command: `req -k 9735 -p ${hex1} -P ${hex2}`, description: "P tag spell", }); expect(result.tags).toContainEqual(["tag", "p", hex1]); expect(result.tags).toContainEqual(["tag", "P", hex2]); expect(result.filter["#p"]).toEqual([hex1]); expect(result.filter["#P"]).toEqual([hex2]); }); it("should handle generic tags with -T flag", () => { const result = encodeSpell({ command: "req -k 1 -T x value1,value2", description: "Generic tag spell", }); expect(result.tags).toContainEqual(["tag", "x", "value1", "value2"]); expect(result.filter["#x"]).toEqual(["value1", "value2"]); }); it("should handle complex command with multiple filters", () => { const hex1 = "f".repeat(64); const hex2 = "0".repeat(64); const result = encodeSpell({ command: `req -k 1,3,30023 -a ${hex1},${hex2} -l 100 -t bitcoin,nostr --since 7d --search crypto wss://relay.com --close-on-eose`, description: "Multi-filter spell", topics: ["test"], }); // Verify all components are present expect(result.tags).toContainEqual(["k", "1"]); expect(result.tags).toContainEqual(["k", "3"]); expect(result.tags).toContainEqual(["k", "30023"]); expect(result.tags).toContainEqual(["authors", hex1, hex2]); expect(result.tags).toContainEqual(["limit", "100"]); expect(result.tags).toContainEqual(["tag", "t", "bitcoin", "nostr"]); expect(result.tags).toContainEqual(["since", "7d"]); expect(result.tags).toContainEqual(["search", "crypto"]); expect(result.tags).toContainEqual(["relays", "wss://relay.com/"]); expect(result.tags).toContainEqual(["close-on-eose", ""]); expect(result.tags).toContainEqual(["t", "test"]); // Verify filter expect(result.filter.kinds).toEqual([1, 3, 30023]); expect(result.filter.authors).toEqual([hex1, hex2]); expect(result.filter.limit).toBe(100); expect(result.filter["#t"]).toEqual(["bitcoin", "nostr"]); expect(result.filter.search).toBe("crypto"); }); }); describe("decodeSpell", () => { it("should decode a simple spell back to command", () => { const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: [ ["cmd", "REQ"], ["client", "grimoire"], ["k", "1"], ["k", "3"], ], content: "Test spell", sig: "test-sig", }; const parsed = decodeSpell(event); expect(parsed.description).toBe("Test spell"); expect(parsed.filter.kinds).toEqual([1, 3]); expect(parsed.command).toContain("-k 1,3"); }); it("should decode description from content", () => { const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: [ ["cmd", "REQ"], ["k", "1"], ], content: "Test spell description", sig: "test-sig", }; const parsed = decodeSpell(event); expect(parsed.description).toBe("Test spell description"); expect(parsed.filter.kinds).toEqual([1]); }); it("should handle empty content", () => { const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: [ ["cmd", "REQ"], ["k", "1"], ], content: "", sig: "test-sig", }; const parsed = decodeSpell(event); expect(parsed.description).toBeUndefined(); expect(parsed.filter.kinds).toEqual([1]); }); it("should decode name from tags", () => { const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: [ ["cmd", "REQ"], ["name", "Bitcoin Feed"], ["k", "1"], ], content: "Notes about Bitcoin", sig: "test-sig", }; const parsed = decodeSpell(event); expect(parsed.name).toBe("Bitcoin Feed"); expect(parsed.description).toBe("Notes about Bitcoin"); expect(parsed.filter.kinds).toEqual([1]); }); it("should handle missing name tag", () => { const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: [ ["cmd", "REQ"], ["k", "1"], ], content: "Test", sig: "test-sig", }; const parsed = decodeSpell(event); expect(parsed.name).toBeUndefined(); expect(parsed.description).toBe("Test"); }); it("should decode authors", () => { const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: [ ["cmd", "REQ"], ["client", "grimoire"], ["k", "1"], ["authors", "abc123", "def456"], ], content: "Author spell", sig: "test-sig", }; const parsed = decodeSpell(event); expect(parsed.filter.authors).toEqual(["abc123", "def456"]); expect(parsed.command).toContain("-a abc123,def456"); }); it("should decode tag filters with new format", () => { const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: [ ["cmd", "REQ"], ["client", "grimoire"], ["k", "1"], ["tag", "t", "bitcoin", "nostr"], ["tag", "p", "abc123"], ["tag", "P", "def456"], ], content: "Tag spell", sig: "test-sig", }; const parsed = decodeSpell(event); expect(parsed.filter["#t"]).toEqual(["bitcoin", "nostr"]); expect(parsed.filter["#p"]).toEqual(["abc123"]); expect(parsed.filter["#P"]).toEqual(["def456"]); expect(parsed.command).toContain("-t bitcoin,nostr"); expect(parsed.command).toContain("-p abc123"); expect(parsed.command).toContain("-P def456"); }); it("should decode time bounds with relative format", () => { const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: [ ["cmd", "REQ"], ["client", "grimoire"], ["k", "1"], ["since", "7d"], ["until", "now"], ], content: "Time spell", sig: "test-sig", }; const parsed = decodeSpell(event); expect(parsed.command).toContain("--since 7d"); expect(parsed.command).toContain("--until now"); }); it("should decode topics", () => { const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: [ ["cmd", "REQ"], ["client", "grimoire"], ["k", "1"], ["t", "bitcoin"], ["t", "news"], ], content: "A test spell", sig: "test-sig", }; const parsed = decodeSpell(event); expect(parsed.description).toBe("A test spell"); expect(parsed.topics).toEqual(["bitcoin", "news"]); }); it("should decode fork provenance", () => { const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: [ ["cmd", "REQ"], ["client", "grimoire"], ["k", "1"], ["e", "abc123def456"], ], content: "Forked spell", sig: "test-sig", }; const parsed = decodeSpell(event); expect(parsed.forkedFrom).toBe("abc123def456"); }); it("should throw error if cmd is not REQ", () => { const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: [["cmd", "INVALID"]], content: "Test", sig: "test-sig", }; expect(() => decodeSpell(event)).toThrow( "Invalid spell command type: INVALID", ); }); }); describe("Round-trip conversion", () => { it("should preserve filter semantics through encode → decode", () => { const hex1 = "1".repeat(64); const hex2 = "2".repeat(64); const original = { command: `req -k 1,3,7 -a ${hex1},${hex2} -l 50 -t bitcoin,nostr --since 7d --search crypto`, description: "Testing round-trip conversion", topics: ["test"], }; // Encode const encoded = encodeSpell(original); // Create event const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: encoded.tags, content: encoded.content, sig: "test-sig", }; // Decode const decoded = decodeSpell(event); // Verify filter semantics are preserved expect(decoded.filter.kinds).toEqual([1, 3, 7]); expect(decoded.filter.authors).toEqual([hex1, hex2]); expect(decoded.filter.limit).toBe(50); expect(decoded.filter["#t"]).toEqual(["bitcoin", "nostr"]); expect(decoded.filter.search).toBe("crypto"); // Verify metadata expect(decoded.description).toBe("Testing round-trip conversion"); expect(decoded.topics).toEqual(["test"]); // Verify command contains key components (order may differ) expect(decoded.command).toContain("-k 1,3,7"); expect(decoded.command).toContain(`-a ${hex1},${hex2}`); expect(decoded.command).toContain("-l 50"); expect(decoded.command).toContain("-t bitcoin,nostr"); expect(decoded.command).toContain("--since 7d"); expect(decoded.command).toContain("--search"); }); it("should handle minimal spell without description", () => { const original = { command: "req -k 1", }; const encoded = encodeSpell(original); const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: encoded.tags, content: encoded.content, sig: "test-sig", }; const decoded = decodeSpell(event); expect(decoded.description).toBeUndefined(); expect(decoded.filter.kinds).toEqual([1]); expect(decoded.command).toBe("req -k 1"); }); it("should round-trip with name", () => { const original = { command: "req -k 1", name: "Bitcoin Feed", description: "Notes about Bitcoin", }; const encoded = encodeSpell(original); const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: encoded.tags, content: encoded.content, sig: "test-sig", }; const decoded = decodeSpell(event); expect(decoded.name).toBe("Bitcoin Feed"); expect(decoded.description).toBe("Notes about Bitcoin"); expect(decoded.filter.kinds).toEqual([1]); }); it("should preserve special aliases through round-trip", () => { const original = { command: "req -k 1 -a $me,$contacts -p $me", description: "Alias spell", }; const encoded = encodeSpell(original); // Debug: Check what was encoded const authorsTag = encoded.tags.find((t) => t[0] === "authors"); const pTag = encoded.tags.find((t) => t[0] === "tag" && t[1] === "p"); // Verify encoding worked expect(authorsTag).toEqual(["authors", "$me", "$contacts"]); expect(pTag).toEqual(["tag", "p", "$me"]); const event: SpellEvent = { id: "test-id", pubkey: "test-pubkey", created_at: 1234567890, kind: 777, tags: encoded.tags, content: encoded.content, sig: "test-sig", }; const decoded = decodeSpell(event); expect(decoded.filter.authors).toEqual(["$me", "$contacts"]); expect(decoded.filter["#p"]).toEqual(["$me"]); expect(decoded.command).toContain("-a $me,$contacts"); expect(decoded.command).toContain("-p $me"); }); }); describe("Validation and edge cases", () => { it("should throw error for empty command", () => { expect(() => encodeSpell({ command: "", }), ).toThrow("Spell command is required"); }); it("should throw error for whitespace-only command", () => { expect(() => encodeSpell({ command: " ", }), ).toThrow("Spell command is required"); }); it("should throw error for 'req' with no filters", () => { expect(() => encodeSpell({ command: "req", }), ).toThrow(); // Will throw either empty tokens or no constraints error }); it("should throw error for command with no valid filters", () => { expect(() => encodeSpell({ command: "req --invalid-flag", }), ).toThrow( "Spell command must specify at least one filter (kinds, authors, tags, time bounds, search, or limit)", ); }); it("should throw error for command with only invalid values", () => { expect(() => encodeSpell({ command: "req -k invalid", }), ).toThrow( "Spell command must specify at least one filter (kinds, authors, tags, time bounds, search, or limit)", ); }); it("should handle malformed author values gracefully", () => { // Invalid hex should be ignored, not cause errors const result = encodeSpell({ command: "req -k 1 -a invalid", }); // Should have kinds but no authors expect(result.filter.kinds).toEqual([1]); expect(result.filter.authors).toBeUndefined(); }); it("should handle mixed valid and invalid values", () => { const hex = "a".repeat(64); const result = encodeSpell({ command: `req -k 1,invalid,3 -a ${hex},invalid`, }); // Should keep only valid values expect(result.filter.kinds).toEqual([1, 3]); expect(result.filter.authors).toEqual([hex]); }); it("should handle quotes in search query", () => { const result = encodeSpell({ command: 'req -k 1 --search "quoted text"', }); expect(result.filter.search).toBe("quoted text"); }); it("should handle single quotes in search query", () => { const result = encodeSpell({ command: "req -k 1 --search 'single quoted'", }); expect(result.filter.search).toBe("single quoted"); }); it("should handle special characters in search", () => { const result = encodeSpell({ command: "req -k 1 --search 'text with #hashtag @mention'", }); expect(result.filter.search).toBe("text with #hashtag @mention"); }); it("should accept commands with only limit", () => { const result = encodeSpell({ command: "req -l 50", }); expect(result.filter.limit).toBe(50); }); it("should accept commands with only time bounds", () => { const result = encodeSpell({ command: "req --since 7d", }); expect(result.filter.since).toBeDefined(); }); it("should accept commands with only search", () => { const result = encodeSpell({ command: "req --search bitcoin", }); expect(result.filter.search).toBe("bitcoin"); }); it("should handle very long commands", () => { // Generate 10 different hex values to test long author lists const hexValues = Array(10) .fill(0) .map((_, i) => i.toString(16).repeat(64).slice(0, 64)) .join(","); const result = encodeSpell({ command: `req -k 1 -a ${hexValues}`, }); expect(result.filter.authors).toBeDefined(); expect(result.filter.authors!.length).toBe(10); }); it("should handle Unicode in descriptions", () => { const result = encodeSpell({ command: "req -k 1", description: "Testing with emoji 🎨 and unicode 你好", }); expect(result.content).toBe("Testing with emoji 🎨 and unicode 你好"); }); it("should preserve exact capitalization in $me/$contacts", () => { const result = encodeSpell({ command: "req -k 1 -a $Me,$CONTACTS", }); // Parser normalizes to lowercase const authorsTag = result.tags.find((t) => t[0] === "authors"); expect(authorsTag).toEqual(["authors", "$me", "$contacts"]); }); }); });