feat(FN-693): add projectId to NtfyNotifier for multi-project deep links

- Add projectId parameter to NtfyNotifier constructor and sendMessage method
- Include projectId in notification deep links for multi-project context
- Add unit tests for deep link URL construction with projectId in App.tsx
- Add unit tests verifying project ID handling in notifier
This commit is contained in:
gsxdsm
2026-04-02 10:27:49 -07:00
parent 6daf95bbb3
commit ac0e96e491
4 changed files with 287 additions and 9 deletions

View File

@@ -183,12 +183,29 @@ function AppInner() {
.catch(() => {/* keep empty array on failure */});
}, []);
// Handle deep link to task on mount
// Handle deep link to task on mount (with optional project context)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const projectParam = params.get("project");
const taskId = params.get("task");
// If no task to load, nothing to do
if (!taskId) return;
// If project param is present, try to switch to that project first
if (projectParam) {
const matchingProject = projects.find((p) => p.id === projectParam);
if (!matchingProject) {
addToast(`Project '${projectParam}' not found`, "error");
return;
}
// Switch to the project if it's different from the current one
if (currentProject?.id !== matchingProject.id) {
setCurrentProject(matchingProject);
}
}
// After project context is resolved (or if no project param), fetch the task
fetchTaskDetail(taskId)
.then((detail) => {
setDetailTask(detail);
@@ -196,7 +213,7 @@ function AppInner() {
.catch(() => {
addToast(`Task ${taskId} not found`, "error");
});
}, [addToast]);
}, [addToast, projects, currentProject, setCurrentProject]);
// View change handlers
const handleChangeTaskView = useCallback((newView: TaskView) => {

View File

@@ -57,9 +57,21 @@ vi.mock("../../hooks/useTasks", () => ({
useTasks: () => mockUseTasks(),
}));
// Mock state holders for dynamic mocking
const mockProjectsState = {
projects: [] as any[],
};
const mockCurrentProjectState = {
currentProject: { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
setCurrentProject: vi.fn(),
clearCurrentProject: vi.fn(),
loading: false,
};
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({
projects: [],
projects: mockProjectsState.projects,
loading: false,
error: null,
refresh: vi.fn(),
@@ -70,12 +82,7 @@ vi.mock("../../hooks/useProjects", () => ({
}));
vi.mock("../../hooks/useCurrentProject", () => ({
useCurrentProject: () => ({
currentProject: { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
setCurrentProject: vi.fn(),
clearCurrentProject: vi.fn(),
loading: false,
}),
useCurrentProject: () => mockCurrentProjectState,
}));
import { App } from "../../App";
@@ -97,6 +104,11 @@ beforeEach(() => {
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
}));
// Reset mock states
mockProjectsState.projects = [];
mockCurrentProjectState.currentProject = { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" };
mockCurrentProjectState.setCurrentProject.mockClear();
mockCurrentProjectState.clearCurrentProject.mockClear();
});
describe("App deep link handling", () => {
@@ -166,6 +178,100 @@ describe("App deep link handling", () => {
expect(fetchTaskDetail).not.toHaveBeenCalled();
expect(window.history.replaceState).not.toHaveBeenCalled();
});
it("switches project and opens task when both project and task params are present", async () => {
const project1 = { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
const project2 = { id: "proj_456", name: "Other Project", path: "/other", status: "active", isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
mockProjectsState.projects = [project1, project2];
mockCurrentProjectState.currentProject = project1;
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?project=proj_456&task=FN-789"),
});
render(<App />);
await waitFor(() => {
expect(mockCurrentProjectState.setCurrentProject).toHaveBeenCalledWith(project2);
});
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-789");
});
await waitFor(() => {
expect(screen.getByText("Task FN-789")).toBeTruthy();
});
});
it("shows error toast when project param references non-existent project", async () => {
mockProjectsState.projects = [];
mockCurrentProjectState.currentProject = null;
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?project=nonexistent&task=FN-123"),
});
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
// Should show error toast for project not found
await waitFor(() => {
expect(screen.getByText("Project 'nonexistent' not found")).toBeTruthy();
});
// Should NOT fetch the task since project wasn't found
expect(fetchTaskDetail).not.toHaveBeenCalled();
});
it("does not call setCurrentProject when project param matches current project", async () => {
const project = { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
mockProjectsState.projects = [project];
mockCurrentProjectState.currentProject = project;
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?project=proj_123&task=FN-123"),
});
render(<App />);
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123");
});
// setCurrentProject should NOT be called since we're already on this project
expect(mockCurrentProjectState.setCurrentProject).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByText("Task FN-123")).toBeTruthy();
});
});
it("works without project param for backward compatibility", async () => {
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?task=FN-123"),
});
render(<App />);
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123");
});
await waitFor(() => {
expect(screen.getByText("Task FN-123")).toBeTruthy();
});
// setCurrentProject should NOT be called when no project param
expect(mockCurrentProjectState.setCurrentProject).not.toHaveBeenCalled();
});
});
describe("App auto-open Settings on unauthenticated", () => {

View File

@@ -443,6 +443,152 @@ describe("NtfyNotifier", () => {
});
});
describe("project ID in URLs", () => {
beforeEach(() => {
fetchMock.mockResolvedValue({ ok: true });
});
it("includes projectId in Click URL when configured for in-review notifications", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "http://localhost:3000",
});
notifier = new NtfyNotifier(store, { projectId: "proj_123" });
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_123&task=FN-001",
}),
})
);
});
it("includes projectId in Click URL when configured for failed task notifications", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "https://fusion.example.com",
});
notifier = new NtfyNotifier(store, { projectId: "my-project" });
await notifier.start();
const failedTask = createTask("FN-001", "Test Task", "failed");
store.triggerTaskUpdated(failedTask);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "https://fusion.example.com/?project=my-project&task=FN-001",
}),
})
);
});
it("includes projectId in Click URL when configured for merged task notifications", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "https://fusion.example.com",
});
notifier = new NtfyNotifier(store, { projectId: "another-project" });
await notifier.start();
const mergeResult: MergeResult = {
task: createTask("FN-001", "Test Task"),
branch: "fusion/fn-001",
merged: true,
worktreeRemoved: true,
branchDeleted: true,
};
store.triggerTaskMerged(mergeResult);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "https://fusion.example.com/?project=another-project&task=FN-001",
}),
})
);
});
it("falls back to task-only URL when projectId not configured", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "http://localhost:3000",
});
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",
}),
})
);
});
it("encodes special characters in projectId", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "http://localhost:3000",
});
notifier = new NtfyNotifier(store, { projectId: "proj/abc" });
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%2Fabc&task=FN-001",
}),
})
);
});
it("handles projectId with spaces and special characters", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "http://localhost:3000",
});
notifier = new NtfyNotifier(store, { projectId: "my project test" });
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=my%20project%20test&task=FN-001",
}),
})
);
});
});
describe("runtime reconfiguration", () => {
it("starts sending notifications when enabled at runtime", async () => {
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });

View File

@@ -5,6 +5,8 @@ import { schedulerLog } from "./logger.js";
export interface NtfyNotifierOptions {
/** Base URL for ntfy.sh. Default: https://ntfy.sh */
ntfyBaseUrl?: string;
/** Project identifier for deep links in notifications */
projectId?: string;
}
/**
@@ -53,6 +55,8 @@ type NotificationEventType = "in-review" | "merged" | "failed";
export class NtfyNotifier {
private config: NtfyConfig = { enabled: false, topic: undefined, dashboardHost: undefined };
private ntfyBaseUrl: string;
/** Project identifier for deep links in notifications */
private projectId?: string;
/** Tracks which (taskId, eventType) pairs have been notified to prevent duplicates */
private notifiedEvents: Set<string> = new Set();
/** AbortController for in-flight requests during shutdown */
@@ -63,6 +67,7 @@ export class NtfyNotifier {
options: NtfyNotifierOptions = {},
) {
this.ntfyBaseUrl = options.ntfyBaseUrl ?? "https://ntfy.sh";
this.projectId = options.projectId;
}
/**
@@ -204,6 +209,7 @@ export class NtfyNotifier {
/**
* Build a dashboard URL for deep linking to a task.
* Returns undefined if dashboardHost is not configured.
* Includes projectId in the URL when configured for multi-project support.
*/
private buildTaskUrl(taskId: string): string | undefined {
if (!this.config.dashboardHost) {
@@ -211,6 +217,9 @@ export class NtfyNotifier {
}
// Strip trailing slash from hostname if present
const host = this.config.dashboardHost.replace(/\/$/, "");
if (this.projectId) {
return `${host}/?project=${encodeURIComponent(this.projectId)}&task=${encodeURIComponent(taskId)}`;
}
return `${host}/?task=${encodeURIComponent(taskId)}`;
}