1import type { NotificationResponse } from "expo-notifications";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";
5
6import {
7 extractAgentNotificationDeepLink,
8 routeAgentNotificationResponseOnce,
9} from "./notificationPayload";
10
11function responseWithData(data: Record<string, unknown>, identifier = "notification-1") {
12 return {
13 notification: {
14 request: {
15 identifier,
16 content: {
17 data,
18 },
19 },
20 },
21 };
22}
23
24afterEach(() => {
25 vi.restoreAllMocks();
26});
27
28describe("consumeLastAgentNotificationResponse", () => {
29 it("reports which initial-response operation failed", async () => {
30 const cause = new Error("notification lookup unavailable");
31 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
32
33 await consumeLastAgentNotificationResponse({
34 getLastResponse: () => Promise.reject(cause),
35 clearLastResponse: () => Promise.resolve(),
36 handleResponse: vi.fn(),
37 });
38
39 expect(consoleError).toHaveBeenCalledWith(
40 expect.objectContaining({
41 _tag: "NotificationNavigationError",
42 operation: "read",
43 }),
44 );
45 });
46
47 it("routes a response before reporting a clear failure", async () => {
48 const cause = new Error("notification clear unavailable");
49 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
50 const response = responseWithData({}, "notification-clear") as NotificationResponse;
51 const handleResponse = vi.fn();
52
53 await consumeLastAgentNotificationResponse({
54 getLastResponse: () => Promise.resolve(response),
55 clearLastResponse: () => Promise.reject(cause),
56 handleResponse,
57 });
58
59 expect(handleResponse).toHaveBeenCalledWith(response);
60 expect(consoleError).toHaveBeenCalledWith(
61 expect.objectContaining({
62 _tag: "NotificationNavigationError",
63 operation: "clear",
64 notificationId: "notification-clear",
65 }),
66 );
67 });
68
69 it("reports routing failures before clearing the response", async () => {
70 const cause = new Error("notification routing unavailable");
71 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
72 const response = responseWithData({}, "notification-route") as NotificationResponse;
73 const clearLastResponse = vi.fn(() => Promise.resolve());
74
75 await consumeLastAgentNotificationResponse({
76 getLastResponse: () => Promise.resolve(response),
77 clearLastResponse,
78 handleResponse: () => {
79 throw cause;
80 },
81 });
82
83 expect(clearLastResponse).not.toHaveBeenCalled();
84 expect(consoleError).toHaveBeenCalledWith(
85 expect.objectContaining({
86 _tag: "NotificationNavigationError",
87 operation: "route",
88 notificationId: "notification-route",
89 }),
90 );
91 });
92});
93
94describe("extractAgentNotificationDeepLink", () => {
95 it("uses explicit deep links from APNs payload data", () => {
96 expect(
97 extractAgentNotificationDeepLink(
98 responseWithData({
99 deepLink: "/threads/env/thread",
100 environmentId: "ignored",
101 threadId: "ignored",
102 }),
103 ),
104 ).toBe("/threads/env/thread");
105 });
106
107 it("normalizes explicit thread deep links from APNs payload data", () => {
108 expect(
109 extractAgentNotificationDeepLink(
110 responseWithData({
111 deepLink: "/threads/env%201/thread%2F2",
112 }),
113 ),
114 ).toBe("/threads/env%201/thread%2F2");
115 });
116
117 it("falls back to the thread route from environment and thread ids", () => {
118 expect(
119 extractAgentNotificationDeepLink(
120 responseWithData({
121 environmentId: "env 1",
122 threadId: "thread/2",
123 }),
124 ),
125 ).toBe("/threads/env%201/thread%2F2");
126 });
127
128 it("falls back to ids when explicit deep link is not an agent thread route", () => {
129 expect(
130 extractAgentNotificationDeepLink(
131 responseWithData({
132 deepLink: "/",
133 environmentId: "env",
134 threadId: "thread",
135 }),
136 ),
137 ).toBe("/threads/env/thread");
138 });
139
140 it("ignores malformed or external links", () => {
141 expect(
142 extractAgentNotificationDeepLink(responseWithData({ deepLink: "https://example.com" })),
143 ).toBeNull();
144 expect(
145 extractAgentNotificationDeepLink(responseWithData({ deepLink: "/settings" })),
146 ).toBeNull();
147 expect(
148 extractAgentNotificationDeepLink(responseWithData({ deepLink: "//example.com" })),
149 ).toBeNull();
150 expect(
151 extractAgentNotificationDeepLink(responseWithData({ deepLink: "/threads/env/thread?x=1" })),
152 ).toBeNull();
153 expect(extractAgentNotificationDeepLink({})).toBeNull();
154 });
155});
156
157describe("routeAgentNotificationResponseOnce", () => {
158 it("does not navigate twice when the initial and listener responses refer to one notification", () => {
159 const handledResponseIds = new Set<string>();
160 const navigations: Array<string> = [];
161 const response = responseWithData({
162 environmentId: "env",
163 threadId: "thread",
164 });
165
166 routeAgentNotificationResponseOnce({
167 handledResponseIds,
168 response,
169 navigate: (deepLink) => navigations.push(deepLink),
170 });
171 routeAgentNotificationResponseOnce({
172 handledResponseIds,
173 response,
174 navigate: (deepLink) => navigations.push(deepLink),
175 });
176
177 expect(navigations).toEqual(["/threads/env/thread"]);
178 });
179});