Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, expect, it } from "vite-plus/test";
import type { NotificationResponse } from "expo-notifications";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";

import {
extractAgentNotificationDeepLink,
Expand All @@ -18,6 +21,76 @@ function responseWithData(data: Record<string, unknown>, identifier = "notificat
};
}

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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Notifications from "expo-notifications";
import { useRouter } from "expo-router";

import { routeAgentNotificationResponseOnce } from "./notificationPayload";
import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";

export function useAgentNotificationNavigation(): void {
const router = useRouter();
Expand All @@ -18,15 +19,11 @@ export function useAgentNotificationNavigation(): void {
};

const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse);
void Notifications.getLastNotificationResponseAsync()
.then((response) => {
if (response) {
handleResponse(response);
return Notifications.clearLastNotificationResponseAsync();
}
return undefined;
})
.catch(() => undefined);
void consumeLastAgentNotificationResponse({
getLastResponse: () => Notifications.getLastNotificationResponseAsync(),
clearLastResponse: () => Notifications.clearLastNotificationResponseAsync(),
handleResponse,
});

return () => {
subscription.remove();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { NotificationResponse } from "expo-notifications";
import * as Schema from "effect/Schema";

export class NotificationNavigationError extends Schema.TaggedErrorClass<NotificationNavigationError>()(
"NotificationNavigationError",
{
operation: Schema.Literals(["read", "route", "clear"]),
notificationId: Schema.optional(Schema.String),
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to ${this.operation} the last notification response.`;
}
}

export async function consumeLastAgentNotificationResponse(input: {
readonly getLastResponse: () => Promise<NotificationResponse | null>;
readonly clearLastResponse: () => Promise<void>;
readonly handleResponse: (response: NotificationResponse) => void;
}): Promise<void> {
let response: NotificationResponse | null;
try {
response = await input.getLastResponse();
} catch (cause) {
console.error(new NotificationNavigationError({ operation: "read", cause }));
return;
}

if (!response) {
return;
}

try {
input.handleResponse(response);
} catch (cause) {
console.error(
new NotificationNavigationError({
operation: "route",
notificationId: response.notification.request.identifier,
cause,
}),
);
return;
}

try {
await input.clearLastResponse();
} catch (cause) {
console.error(
new NotificationNavigationError({
operation: "clear",
notificationId: response.notification.request.identifier,
cause,
}),
);
}
}
Loading