import type { NotificationResponse } from "expo-notifications";import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; import { extractAgentNotificationDeepLink, routeAgentNotificationResponseOnce,} from "./notificationPayload"; function responseWithData(data: Record<string, unknown>, identifier = "notification-1") { return { notification: { request: { identifier, content: { data, }, }, }, };} afterEach(() => { vi.restoreAllMocks();}); describe("consumeLastAgentNotificationResponse", () => { it("reports which initial-response operation failed", async () => { const cause = new Error("notification lookup unavailable"); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); await consumeLastAgentNotificationResponse({ getLastResponse: () => Promise.reject(cause), clearLastResponse: () => Promise.resolve(), handleResponse: vi.fn(), }); expect(consoleError).toHaveBeenCalledWith( expect.objectContaining({ _tag: "NotificationNavigationError", operation: "read", }), ); }); it("routes a response before reporting a clear failure", async () => { const cause = new Error("notification clear unavailable"); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const response = responseWithData({}, "notification-clear") as NotificationResponse; const handleResponse = vi.fn(); await consumeLastAgentNotificationResponse({ getLastResponse: () => Promise.resolve(response), clearLastResponse: () => Promise.reject(cause), handleResponse, }); expect(handleResponse).toHaveBeenCalledWith(response); expect(consoleError).toHaveBeenCalledWith( expect.objectContaining({ _tag: "NotificationNavigationError", operation: "clear", notificationId: "notification-clear", }), ); }); it("reports routing failures before clearing the response", async () => { const cause = new Error("notification routing unavailable"); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const response = responseWithData({}, "notification-route") as NotificationResponse; const clearLastResponse = vi.fn(() => Promise.resolve()); await consumeLastAgentNotificationResponse({ getLastResponse: () => Promise.resolve(response), clearLastResponse, handleResponse: () => { throw cause; }, }); expect(clearLastResponse).not.toHaveBeenCalled(); expect(consoleError).toHaveBeenCalledWith( expect.objectContaining({ _tag: "NotificationNavigationError", operation: "route", notificationId: "notification-route", }), ); });}); describe("extractAgentNotificationDeepLink", () => { it("uses explicit deep links from APNs payload data", () => { expect( extractAgentNotificationDeepLink( responseWithData({ deepLink: "/threads/env/thread", environmentId: "ignored", threadId: "ignored", }), ), ).toBe("/threads/env/thread"); }); it("normalizes explicit thread deep links from APNs payload data", () => { expect( extractAgentNotificationDeepLink( responseWithData({ deepLink: "/threads/env%201/thread%2F2", }), ), ).toBe("/threads/env%201/thread%2F2"); }); it("falls back to the thread route from environment and thread ids", () => { expect( extractAgentNotificationDeepLink( responseWithData({ environmentId: "env 1", threadId: "thread/2", }), ), ).toBe("/threads/env%201/thread%2F2"); }); it("falls back to ids when explicit deep link is not an agent thread route", () => { expect( extractAgentNotificationDeepLink( responseWithData({ deepLink: "/", environmentId: "env", threadId: "thread", }), ), ).toBe("/threads/env/thread"); }); it("ignores malformed or external links", () => { expect( extractAgentNotificationDeepLink(responseWithData({ deepLink: "https://example.com" })), ).toBeNull(); expect( extractAgentNotificationDeepLink(responseWithData({ deepLink: "/settings" })), ).toBeNull(); expect( extractAgentNotificationDeepLink(responseWithData({ deepLink: "//example.com" })), ).toBeNull(); expect( extractAgentNotificationDeepLink(responseWithData({ deepLink: "/threads/env/thread?x=1" })), ).toBeNull(); expect(extractAgentNotificationDeepLink({})).toBeNull(); });}); describe("routeAgentNotificationResponseOnce", () => { it("does not navigate twice when the initial and listener responses refer to one notification", () => { const handledResponseIds = new Set<string>(); const navigations: Array<string> = []; const response = responseWithData({ environmentId: "env", threadId: "thread", }); routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate: (deepLink) => navigations.push(deepLink), }); routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate: (deepLink) => navigations.push(deepLink), }); expect(navigations).toEqual(["/threads/env/thread"]); });});
import type { NotificationResponse } from "expo-notifications";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";
import {
extractAgentNotificationDeepLink,
routeAgentNotificationResponseOnce,
} from "./notificationPayload";
function responseWithData(data: Record<string, unknown>, identifier = "notification-1") {
return {
notification: {
request: {
identifier,
content: {
data,
},
},
},
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("consumeLastAgentNotificationResponse", () => {
it("reports which initial-response operation failed", async () => {
const cause = new Error("notification lookup unavailable");
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
await consumeLastAgentNotificationResponse({
getLastResponse: () => Promise.reject(cause),
clearLastResponse: () => Promise.resolve(),
handleResponse: vi.fn(),
});
expect(consoleError).toHaveBeenCalledWith(
expect.objectContaining({
_tag: "NotificationNavigationError",
operation: "read",
}),
);
});
it("routes a response before reporting a clear failure", async () => {
const cause = new Error("notification clear unavailable");
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const response = responseWithData({}, "notification-clear") as NotificationResponse;
const handleResponse = vi.fn();
await consumeLastAgentNotificationResponse({
getLastResponse: () => Promise.resolve(response),
clearLastResponse: () => Promise.reject(cause),
handleResponse,
});
expect(handleResponse).toHaveBeenCalledWith(response);
expect(consoleError).toHaveBeenCalledWith(
expect.objectContaining({
_tag: "NotificationNavigationError",
operation: "clear",
notificationId: "notification-clear",
}),
);
});
it("reports routing failures before clearing the response", async () => {
const cause = new Error("notification routing unavailable");
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const response = responseWithData({}, "notification-route") as NotificationResponse;
const clearLastResponse = vi.fn(() => Promise.resolve());
await consumeLastAgentNotificationResponse({
getLastResponse: () => Promise.resolve(response),
clearLastResponse,
handleResponse: () => {
throw cause;
},
});
expect(clearLastResponse).not.toHaveBeenCalled();
expect(consoleError).toHaveBeenCalledWith(
expect.objectContaining({
_tag: "NotificationNavigationError",
operation: "route",
notificationId: "notification-route",
}),
);
});
});
describe("extractAgentNotificationDeepLink", () => {
it("uses explicit deep links from APNs payload data", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
deepLink: "/threads/env/thread",
environmentId: "ignored",
threadId: "ignored",
}),
),
).toBe("/threads/env/thread");
});
it("normalizes explicit thread deep links from APNs payload data", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
deepLink: "/threads/env%201/thread%2F2",
}),
),
).toBe("/threads/env%201/thread%2F2");
});
it("falls back to the thread route from environment and thread ids", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
environmentId: "env 1",
threadId: "thread/2",
}),
),
).toBe("/threads/env%201/thread%2F2");
});
it("falls back to ids when explicit deep link is not an agent thread route", () => {
expect(
extractAgentNotificationDeepLink(
responseWithData({
deepLink: "/",
environmentId: "env",
threadId: "thread",
}),
),
).toBe("/threads/env/thread");
});
it("ignores malformed or external links", () => {
expect(
extractAgentNotificationDeepLink(responseWithData({ deepLink: "https://example.com" })),
).toBeNull();
expect(
extractAgentNotificationDeepLink(responseWithData({ deepLink: "/settings" })),
).toBeNull();
expect(
extractAgentNotificationDeepLink(responseWithData({ deepLink: "//example.com" })),
).toBeNull();
expect(
extractAgentNotificationDeepLink(responseWithData({ deepLink: "/threads/env/thread?x=1" })),
).toBeNull();
expect(extractAgentNotificationDeepLink({})).toBeNull();
});
});
describe("routeAgentNotificationResponseOnce", () => {
it("does not navigate twice when the initial and listener responses refer to one notification", () => {
const handledResponseIds = new Set<string>();
const navigations: Array<string> = [];
const response = responseWithData({
environmentId: "env",
threadId: "thread",
});
routeAgentNotificationResponseOnce({
handledResponseIds,
response,
navigate: (deepLink) => navigations.push(deepLink),
});
routeAgentNotificationResponseOnce({
handledResponseIds,
response,
navigate: (deepLink) => navigations.push(deepLink),
});
expect(navigations).toEqual(["/threads/env/thread"]);
});
});