feat(FN-813): add project-aware ntfy deep links

- Thread real project context (projectName, projectId) into ntfy notification deep links
- Dashboard deep-link URL now honors project context with /project/:name/task/:id pattern
- Update App.tsx routing to handle project-scoped deep link navigation
- Add project info to NtfyNotifier types and wire through notification payload
- Add changeset for published @gsxdsm/fusion package
- Add tests for notifier project context and dashboard routing
This commit is contained in:
gsxdsm
2026-04-03 21:34:39 -07:00
parent 2322a06a53
commit 448762d1f6
7 changed files with 227 additions and 8 deletions

View File

@@ -800,6 +800,103 @@ describe("NtfyNotifier", () => {
});
});
describe("dashboard runtime wiring", () => {
/**
* These tests simulate the pattern used in packages/cli/src/commands/dashboard.ts
* where the NtfyNotifier is constructed with an optional projectId resolved
* from the central project registry. When a registered project is found,
* deep links include ?project=...&task=...; when no project is registered
* (legacy / single-project mode), links fall back to ?task=... only.
*/
beforeEach(() => {
fetchMock.mockResolvedValue({ ok: true });
});
it("produces project-aware deep links when constructed with registered project ID", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "http://localhost:3000",
});
// Simulates: const notifier = new NtfyNotifier(store, { projectId: registered.id });
notifier = new NtfyNotifier(store, { projectId: "proj_abc123" });
await notifier.start();
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "http://localhost:3000/?project=proj_abc123&task=FN-001",
}),
}),
);
});
it("produces task-only deep links when no project ID is available (legacy mode)", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "http://localhost:3000",
});
// Simulates: const notifier = new NtfyNotifier(store); // no projectId
notifier = new NtfyNotifier(store);
await notifier.start();
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "http://localhost:3000/?task=FN-001",
}),
}),
);
// Verify no "project=" in the URL
const callArgs = fetchMock.mock.calls[0][1] as { headers: Record<string, string> };
expect(callArgs.headers["Click"]).not.toContain("project=");
});
it("produces project-aware deep links for all notification event types", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "https://fusion.example.com",
});
notifier = new NtfyNotifier(store, { projectId: "proj_xyz" });
await notifier.start();
// in-review event
store.triggerTaskMoved(createTask("FN-001", "Task A"), "in-progress", "in-review");
await flushAsyncWork();
// merged event
const mergeResult: MergeResult = {
task: createTask("FN-001", "Task A"),
branch: "fusion/fn-001",
merged: true,
worktreeRemoved: true,
branchDeleted: true,
};
store.triggerTaskMerged(mergeResult);
await flushAsyncWork();
// Verify both calls include project
const calls = fetchMock.mock.calls;
for (const call of calls) {
const headers = call[1].headers as Record<string, string>;
expect(headers["Click"]).toContain("project=proj_xyz");
}
});
});
describe("custom base URL", () => {
it("uses custom ntfy base URL when provided", async () => {
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });