Files
grimoire/src/lib/spell-conversion.test.ts
Alejandro Gómez 2987a37e65 feat: spells
2025-12-20 14:25:40 +01:00

769 lines
23 KiB
TypeScript

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"]);
});
});
});