This commit is contained in:
Alejandro Gómez
2025-12-21 15:37:01 +01:00
parent 6ebc501309
commit 21335a5849
7 changed files with 133 additions and 160 deletions

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PublishSpellbook } from "./publish-spellbook";
import type { ActionHub } from "applesauce-actions";
import type { ActionContext } from "applesauce-actions";
import type { GrimoireState } from "@/types/app";
import type { NostrEvent } from "nostr-tools/core";
@@ -35,9 +35,11 @@ const mockFactory = {
})),
};
const mockHub: ActionHub = {
factory: mockFactory,
} as any;
const mockContext: ActionContext = {
factory: mockFactory as any,
events: {} as any,
self: "test-pubkey",
};
const mockState: GrimoireState = {
windows: {
@@ -62,6 +64,18 @@ const mockState: GrimoireState = {
workspaceOrder: ["ws-1"],
} as any;
// Helper to run action with context
async function runAction(
options: Parameters<typeof PublishSpellbook>[0],
): Promise<NostrEvent[]> {
const events: NostrEvent[] = [];
const action = PublishSpellbook(options);
for await (const event of action(mockContext)) {
events.push(event);
}
return events;
}
describe("PublishSpellbook action", () => {
beforeEach(async () => {
vi.clearAllMocks();
@@ -73,39 +87,33 @@ describe("PublishSpellbook action", () => {
describe("validation", () => {
it("should throw error if title is empty", async () => {
await expect(async () => {
for await (const event of PublishSpellbook(mockHub, {
await expect(
runAction({
state: mockState,
title: "",
})) {
// Should not reach here
}
}).rejects.toThrow("Title is required");
}),
).rejects.toThrow("Title is required");
});
it("should throw error if title is only whitespace", async () => {
await expect(async () => {
for await (const event of PublishSpellbook(mockHub, {
await expect(
runAction({
state: mockState,
title: " ",
})) {
// Should not reach here
}
}).rejects.toThrow("Title is required");
}),
).rejects.toThrow("Title is required");
});
it("should throw error if no active account", async () => {
const accountManager = await import("@/services/accounts");
(accountManager.default as any).active = null;
await expect(async () => {
for await (const event of PublishSpellbook(mockHub, {
await expect(
runAction({
state: mockState,
title: "Test Spellbook",
})) {
// Should not reach here
}
}).rejects.toThrow("No active account");
}),
).rejects.toThrow("No active account");
// Restore for other tests
(accountManager.default as any).active = mockAccount;
@@ -116,14 +124,12 @@ describe("PublishSpellbook action", () => {
const accountWithoutSigner = { ...mockAccount, signer: null };
(accountManager.default as any).active = accountWithoutSigner;
await expect(async () => {
for await (const event of PublishSpellbook(mockHub, {
await expect(
runAction({
state: mockState,
title: "Test Spellbook",
})) {
// Should not reach here
}
}).rejects.toThrow("No signer available");
}),
).rejects.toThrow("No signer available");
// Restore for other tests
(accountManager.default as any).active = mockAccount;
@@ -132,15 +138,11 @@ describe("PublishSpellbook action", () => {
describe("event creation", () => {
it("should yield properly formatted spellbook event", async () => {
const events: NostrEvent[] = [];
for await (const event of PublishSpellbook(mockHub, {
const events = await runAction({
state: mockState,
title: "Test Spellbook",
description: "Test description",
})) {
events.push(event);
}
});
expect(events).toHaveLength(1);
const event = events[0];
@@ -170,14 +172,10 @@ describe("PublishSpellbook action", () => {
});
it("should create event from state when no content provided", async () => {
const events: NostrEvent[] = [];
for await (const event of PublishSpellbook(mockHub, {
const events = await runAction({
state: mockState,
title: "My Dashboard",
})) {
events.push(event);
}
});
expect(events).toHaveLength(1);
const event = events[0];
@@ -197,15 +195,11 @@ describe("PublishSpellbook action", () => {
windows: { "custom-win": {} as any },
};
const events: NostrEvent[] = [];
for await (const event of PublishSpellbook(mockHub, {
const events = await runAction({
state: mockState,
title: "Custom Spellbook",
content: explicitContent,
})) {
events.push(event);
}
});
expect(events).toHaveLength(1);
const event = events[0];
@@ -215,14 +209,10 @@ describe("PublishSpellbook action", () => {
});
it("should not include description tag when description is empty", async () => {
const events: NostrEvent[] = [];
for await (const event of PublishSpellbook(mockHub, {
const events = await runAction({
state: mockState,
title: "No Description",
})) {
events.push(event);
}
});
const event = events[0];
const tags = event.tags as [string, string, ...string[]][];
@@ -234,42 +224,36 @@ describe("PublishSpellbook action", () => {
describe("slug generation", () => {
it("should generate slug from title", async () => {
const events: NostrEvent[] = [];
for await (const event of PublishSpellbook(mockHub, {
const events = await runAction({
state: mockState,
title: "My Awesome Dashboard!",
})) {
events.push(event);
}
});
const dTag = (events[0].tags as [string, string][]).find((t) => t[0] === "d");
const dTag = (events[0].tags as [string, string][]).find(
(t) => t[0] === "d",
);
expect(dTag?.[1]).toBe("my-awesome-dashboard");
});
it("should handle special characters in title", async () => {
const events: NostrEvent[] = [];
for await (const event of PublishSpellbook(mockHub, {
const events = await runAction({
state: mockState,
title: "Test@123#Special$Characters",
})) {
events.push(event);
}
});
const dTag = (events[0].tags as [string, string][]).find((t) => t[0] === "d");
const dTag = (events[0].tags as [string, string][]).find(
(t) => t[0] === "d",
);
expect(dTag?.[1]).toMatch(/^test123specialcharacters$/);
});
});
describe("factory integration", () => {
it("should call factory.build with correct props", async () => {
for await (const event of PublishSpellbook(mockHub, {
await runAction({
state: mockState,
title: "Test",
})) {
// Event yielded
}
});
expect(mockFactory.build).toHaveBeenCalledWith(
expect.objectContaining({
@@ -279,24 +263,20 @@ describe("PublishSpellbook action", () => {
["d", expect.any(String)],
["title", "Test"],
]),
signer: mockSigner,
})
}),
);
});
it("should call factory.sign with draft and signer", async () => {
for await (const event of PublishSpellbook(mockHub, {
it("should call factory.sign with draft", async () => {
await runAction({
state: mockState,
title: "Test",
})) {
// Event yielded
}
});
expect(mockFactory.sign).toHaveBeenCalledWith(
expect.objectContaining({
kind: 30777,
}),
mockSigner
);
});
});
@@ -317,14 +297,10 @@ describe("PublishSpellbook action", () => {
},
};
const events: NostrEvent[] = [];
for await (const event of PublishSpellbook(mockHub, {
const events = await runAction({
state: multiWorkspaceState,
title: "Multi Workspace",
})) {
events.push(event);
}
});
const content = JSON.parse(events[0].content);
expect(Object.keys(content.workspaces).length).toBe(2);
@@ -345,15 +321,11 @@ describe("PublishSpellbook action", () => {
},
};
const events: NostrEvent[] = [];
for await (const event of PublishSpellbook(mockHub, {
const events = await runAction({
state: multiWorkspaceState,
title: "Single Workspace",
workspaceIds: ["ws-1"],
})) {
events.push(event);
}
});
const content = JSON.parse(events[0].content);
expect(Object.keys(content.workspaces).length).toBe(1);

View File

@@ -4,7 +4,7 @@ import { SpellbookEvent } from "@/types/spell";
import { GrimoireState } from "@/types/app";
import { SpellbookContent } from "@/types/spell";
import accountManager from "@/services/accounts";
import type { ActionHub } from "applesauce-actions";
import type { ActionContext } from "applesauce-actions";
import type { NostrEvent } from "nostr-tools/core";
export interface PublishSpellbookOptions {
@@ -26,9 +26,8 @@ export interface PublishSpellbookOptions {
* 4. Yields the signed event (ActionHub handles publishing)
* 5. Marks local spellbook as published if localId provided
*
* @param hub - The action hub instance
* @param options - Spellbook publishing options
* @yields Signed spellbook event ready for publishing
* @returns Action generator for ActionHub
*
* @throws Error if title is empty, no active account, or no signer available
*
@@ -43,73 +42,73 @@ export interface PublishSpellbookOptions {
* });
* ```
*/
export async function* PublishSpellbook(
hub: ActionHub,
options: PublishSpellbookOptions
): AsyncGenerator<NostrEvent> {
export function PublishSpellbook(options: PublishSpellbookOptions) {
const { state, title, description, workspaceIds, localId, content } = options;
// 1. Validate inputs
if (!title || !title.trim()) {
throw new Error("Title is required");
}
const account = accountManager.active;
if (!account) {
throw new Error("No active account. Please log in first.");
}
const signer = account.signer;
if (!signer) {
throw new Error("No signer available. Please connect a signer.");
}
// 2. Create event props from state or use provided content
let eventProps;
if (content) {
// Use provided content directly
eventProps = {
kind: 30777,
content: JSON.stringify(content),
tags: [
["d", slugify(title)],
["title", title],
["client", "grimoire"],
] as [string, string, ...string[]][],
};
if (description) {
eventProps.tags.push(["description", description]);
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
} else {
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
return async function* ({
factory,
}: ActionContext): AsyncGenerator<NostrEvent> {
// 1. Validate inputs
if (!title || !title.trim()) {
throw new Error("Title is required");
}
} else {
// Create from state
const encoded = createSpellbook({
state,
title,
description,
workspaceIds,
const account = accountManager.active;
if (!account) {
throw new Error("No active account. Please log in first.");
}
const signer = account.signer;
if (!signer) {
throw new Error("No signer available. Please connect a signer.");
}
// 2. Create event props from state or use provided content
let eventProps;
if (content) {
// Use provided content directly
eventProps = {
kind: 30777,
content: JSON.stringify(content),
tags: [
["d", slugify(title)],
["title", title],
["client", "grimoire"],
] as [string, string, ...string[]][],
};
if (description) {
eventProps.tags.push(["description", description]);
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
} else {
eventProps.tags.push(["alt", `Grimoire Spellbook: ${title}`]);
}
} else {
// Create from state
const encoded = createSpellbook({
state,
title,
description,
workspaceIds,
});
eventProps = encoded.eventProps;
}
// 3. Build draft using factory from context
const draft = await factory.build({
kind: eventProps.kind,
content: eventProps.content,
tags: eventProps.tags,
});
eventProps = encoded.eventProps;
}
// 3. Build draft using hub's factory
const draft = await hub.factory.build({
kind: eventProps.kind,
content: eventProps.content,
tags: eventProps.tags,
signer,
});
// 4. Sign the event
const event = (await factory.sign(draft)) as SpellbookEvent;
// 4. Sign the event
const event = (await hub.factory.sign(draft, signer)) as SpellbookEvent;
// 5. Mark as published in local DB (before yielding for better UX)
if (localId) {
await markSpellbookPublished(localId, event);
}
// 5. Mark as published in local DB (before yielding for better UX)
if (localId) {
await markSpellbookPublished(localId, event);
}
// 6. Yield signed event - ActionHub's publishEvent will handle relay selection and publishing
yield event;
// 6. Yield signed event - ActionHub's publishEvent will handle relay selection and publishing
yield event;
};
}

View File

@@ -48,13 +48,13 @@ export function ShareSpellbookDialog({
id: "web",
label: "Web Link",
description: "Share as a web URL that anyone can open",
getValue: (e, s, a) => `${window.location.origin}/preview/${a}/${s.slug}`,
getValue: (_e, s, a) => `${window.location.origin}/preview/${a}/${s.slug}`,
},
{
id: "naddr",
label: "Nostr Address (naddr)",
description: "NIP-19 address pointer for Nostr clients",
getValue: (e, s) => {
getValue: (e, _s) => {
const dTag = e.tags.find((t) => t[0] === "d")?.[1];
if (!dTag) return "";
return nip19.naddrEncode({

View File

@@ -9,7 +9,6 @@ import { SpellbookEvent, ParsedSpellbook } from "@/types/spell";
import { NostrEvent } from "@/types/nostr";
import { Layout, ExternalLink, Eye, Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useProfile } from "@/hooks/useProfile";
import { nip19 } from "nostr-tools";
import { useNavigate } from "react-router";
@@ -283,7 +282,6 @@ export function SpellbookRenderer({ event }: BaseEventProps) {
* Shows detailed workspace information with preview and sharing options
*/
export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
const profile = useProfile(event.pubkey);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const spellbook = useMemo(() => {

View File

@@ -353,6 +353,7 @@ export function loadSpellbook(
id: spellbook.event?.id || uuidv4(), // Fallback to uuid if local
slug: spellbook.slug,
title: spellbook.title,
description: spellbook.description,
pubkey: spellbook.event?.pubkey,
},
};

View File

@@ -63,7 +63,9 @@ export async function saveSpellbook(
/**
* Get a spellbook by ID
*/
export async function getSpellbook(id: string): Promise<LocalSpellbook | undefined> {
export async function getSpellbook(
id: string,
): Promise<LocalSpellbook | undefined> {
return db.spellbooks.get(id);
}

View File

@@ -99,6 +99,7 @@ export interface GrimoireState {
id: string; // event id or local uuid
slug: string; // d-tag
title: string;
description?: string;
pubkey?: string; // owner
};
}