Skip to content
Source
Sign inSign up

notificationNavigation.test.ts

179 lines5.8 KB

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 };
23​
24afterEach(() => {
25 vi.restoreAllMocks();
26});
27​
28describe("consumeLastAgentNotificationResponse", () => {
29 it("reports routing failures before clearing the response", async () => {
30 const cause = new Error("notification routing unavailable");
31 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
32 const response = responseWithData({}, "notification-route") as NotificationResponse;
33 const clearLastResponse = vi.fn(() => Promise.resolve());
34​
35 await consumeLastAgentNotificationResponse({
36 getLastResponse: () => Promise.resolve(response),
37 clearLastResponse,
38 handleResponse: () => {
39 throw cause;
40 },
41 });
42​
43 expect(clearLastResponse).not.toHaveBeenCalled();
44 expect(consoleError).toHaveBeenCalledWith(
45 expect.objectContaining({
46 _tag: "NotificationNavigationError",
47 operation: "route",
48 notificationId: "notification-route",
49 }),
50 );
51 });
52});
53​
54describe("extractAgentNotificationDeepLink", () => {
55 it("uses explicit deep links from APNs payload data", () => {
56 expect(
57 extractAgentNotificationDeepLink(
58 responseWithData({
59 deepLink: "/threads/env/thread",
60 environmentId: "ignored",
61 threadId: "ignored",
62 }),
63 ),
64 ).toBe("/threads/env/thread");
65 });
66​
67 it("falls back to the thread route from environment and thread ids", () => {
68 expect(
69 extractAgentNotificationDeepLink(
70 responseWithData({
71 environmentId: "env 1",
72 threadId: "thread/2",
73 }),
74 ),
75 ).toBe("/threads/env%201/thread%2F2");
76 });
77​
78 it("falls back to ids when explicit deep link is not an agent thread route", () => {
79 expect(
80 extractAgentNotificationDeepLink(
81 responseWithData({
82 deepLink: "/",
83 environmentId: "env",
84 threadId: "thread",
85 }),
86 ),
87 ).toBe("/threads/env/thread");
88 });
89​
90 it("ignores malformed or external links", () => {
91 expect(
92 extractAgentNotificationDeepLink(responseWithData({ deepLink: "https://example.com" })),
93 ).toBeNull();
94 expect(
95 extractAgentNotificationDeepLink(responseWithData({ deepLink: "/settings" })),
96 ).toBeNull();
97 expect(
98 extractAgentNotificationDeepLink(responseWithData({ deepLink: "//example.com" })),
99 ).toBeNull();
100 expect(
101 extractAgentNotificationDeepLink(responseWithData({ deepLink: "/threads/env/thread?x=1" })),
102 ).toBeNull();
103 expect(extractAgentNotificationDeepLink({})).toBeNull();
104 });
105});
106​
107describe("routeAgentNotificationResponseOnce", () => {
108 it("retries a failed initial response and deduplicates it after navigation succeeds", async () => {
109 vi.spyOn(console, "error").mockImplementation(() => undefined);
110 const response = responseWithData({ deepLink: "/threads/env/thread" }) as NotificationResponse;
111 const handledResponseIds = new Set<string>();
112 const navigate = vi.fn().mockImplementationOnce(() => {
113 throw new Error("navigation not ready");
114 });
115 const clearLastResponse = vi.fn(() => Promise.resolve());
116 const consume = () =>
117 consumeLastAgentNotificationResponse({
118 getLastResponse: () => Promise.resolve(response),
119 clearLastResponse,
120 handleResponse: (response) =>
121 routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }),
122 });
123​
124 await consume();
125 expect(handledResponseIds.size).toBe(0);
126 expect(clearLastResponse).not.toHaveBeenCalled();
127​
128 await consume();
129 expect(navigate).toHaveBeenCalledTimes(2);
130 expect(navigate).toHaveBeenLastCalledWith("/threads/env/thread");
131 expect(clearLastResponse).toHaveBeenCalledOnce();
132​
133 routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate });
134 expect(navigate).toHaveBeenCalledTimes(2);
135 });
136​
137 it("discards malformed destination ids without leaving the response pending", async () => {
138 const response = responseWithData({
139 environmentId: "env",
140 threadId: "\ud800",
141 }) as NotificationResponse;
142 const handledResponseIds = new Set<string>();
143 const navigate = vi.fn();
144 const clearLastResponse = vi.fn(() => Promise.resolve());
145​
146 await consumeLastAgentNotificationResponse({
147 getLastResponse: () => Promise.resolve(response),
148 clearLastResponse,
149 handleResponse: (response) =>
150 routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }),
151 });
152​
153 expect(navigate).not.toHaveBeenCalled();
154 expect(clearLastResponse).toHaveBeenCalledOnce();
155 expect(handledResponseIds.has("notification-1")).toBe(true);
156 });
157​
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});