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
202 changes: 202 additions & 0 deletions __tests__/components/notifications/NotificationsContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe("NotificationsContext initialization", () => {
PushNotifications.addListener.mockClear();
PushNotifications.register.mockClear();
sentry.captureException.mockClear();
sentry.addBreadcrumb.mockClear();
});

it("does not initialize when isActive is false", async () => {
Expand Down Expand Up @@ -185,6 +186,207 @@ describe("NotificationsContext initialization", () => {
});
});

describe("push registration behavior", () => {
const setupRegistrationCallback = async () => {
const { PushNotifications } = require("@capacitor/push-notifications");

let registrationCallback:
| ((token: { value: string }) => Promise<void>)
| null = null;

PushNotifications.addListener.mockImplementation(
(event: string, callback: (arg: unknown) => Promise<void>) => {
if (event === "registration") {
registrationCallback = callback as (token: {
value: string;
}) => Promise<void>;
}
return Promise.resolve();
}
);

renderHook(() => useNotificationsContext(), { wrapper });

await waitFor(() => {
expect(PushNotifications.addListener).toHaveBeenCalled();
});

await waitFor(() => {
expect(registrationCallback).not.toBeNull();
});

return {
registrationCallback: registrationCallback as (token: {
value: string;
}) => Promise<void>,
};
};

beforeEach(() => {
const { PushNotifications } = require("@capacitor/push-notifications");
const { commonApiPost } = require("@/services/api/common-api");
const sentry = require("@sentry/nextjs");

jest.clearAllMocks();
PushNotifications.addListener.mockClear();
commonApiPost.mockReset();
commonApiPost.mockResolvedValue({});
sentry.captureException.mockClear();
sentry.addBreadcrumb.mockClear();
});

it("retries on rate limit and does not capture exception", async () => {
const { commonApiPost } = require("@/services/api/common-api");
const sentry = require("@sentry/nextjs");
const rateLimitError = new Error("Rate limit exceeded. Try again in 1 sec");

commonApiPost.mockRejectedValue(rateLimitError);
const { registrationCallback } = await setupRegistrationCallback();

const setTimeoutSpy = jest.spyOn(global, "setTimeout").mockImplementation(((
handler: TimerHandler
) => {
if (typeof handler === "function") {
handler();
}
return 0 as unknown as NodeJS.Timeout;
}) as typeof global.setTimeout);

try {
await act(async () => {
await registrationCallback({ value: "test-token" });
});
} finally {
setTimeoutSpy.mockRestore();
}

expect(commonApiPost).toHaveBeenCalledTimes(3);
expect(sentry.captureException).not.toHaveBeenCalled();
expect(sentry.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
message: "Push registration rate limited.",
})
);
});

it("parses milliseconds retry hints as milliseconds", async () => {
const { PushNotifications } = require("@capacitor/push-notifications");
const { commonApiPost } = require("@/services/api/common-api");
const sentry = require("@sentry/nextjs");
const rateLimitError = new Error(
"Rate limit exceeded. Try again in 500 milliseconds"
);

PushNotifications.requestPermissions.mockResolvedValueOnce({
receive: "denied",
});
commonApiPost
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce({});

const { registrationCallback } = await setupRegistrationCallback();

await act(async () => {
await registrationCallback({ value: "test-token" });
});

expect(commonApiPost).toHaveBeenCalledTimes(2);
expect(sentry.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
message: "Push registration attempt failed. Retrying.",
data: expect.objectContaining({
delay_ms: 500,
rate_limited: true,
}),
})
);
expect(sentry.captureException).not.toHaveBeenCalled();
});

it("uses retry-after header metadata from structured API errors", async () => {
const { commonApiPost } = require("@/services/api/common-api");
const sentry = require("@sentry/nextjs");
const rateLimitHeaders = new Headers({ "Retry-After": "2" });
const rateLimitError = Object.assign(new Error("Too Many Requests"), {
status: 429,
headers: rateLimitHeaders,
response: {
status: 429,
headers: rateLimitHeaders,
},
});

commonApiPost
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce({});

const { registrationCallback } = await setupRegistrationCallback();

await act(async () => {
await registrationCallback({ value: "test-token" });
});

expect(commonApiPost).toHaveBeenCalledTimes(2);
expect(commonApiPost).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ errorMode: "structured" })
);
expect(sentry.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
message: "Push registration attempt failed. Retrying.",
data: expect.objectContaining({
delay_ms: 2000,
status_code: 429,
rate_limited: true,
}),
})
);
expect(sentry.captureException).not.toHaveBeenCalled();
});

it("skips duplicate registration for identical fingerprint", async () => {
const { commonApiPost } = require("@/services/api/common-api");
const sentry = require("@sentry/nextjs");
const { registrationCallback } = await setupRegistrationCallback();

await act(async () => {
await registrationCallback({ value: "test-token" });
await registrationCallback({ value: "test-token" });
});

expect(commonApiPost).toHaveBeenCalledTimes(1);
expect(sentry.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({
message: "Push registration skipped (already registered in session).",
})
);
});

it("captures non-rate-limit push registration errors", async () => {
const { commonApiPost } = require("@/services/api/common-api");
const sentry = require("@sentry/nextjs");
const fatalError = new Error("fatal push registration failure");
commonApiPost.mockRejectedValue(fatalError);

const { registrationCallback } = await setupRegistrationCallback();

await act(async () => {
await registrationCallback({ value: "test-token" });
});

expect(commonApiPost).toHaveBeenCalledTimes(1);
expect(sentry.captureException).toHaveBeenCalledWith(
fatalError,
expect.objectContaining({
tags: expect.objectContaining({
component: "NotificationsProvider",
operation: "registerPushNotification",
}),
})
);
});
});

it("removes notifications when functions called", async () => {
const { PushNotifications } = require("@capacitor/push-notifications");

Expand Down
112 changes: 89 additions & 23 deletions __tests__/components/waves/drops/WaveDropsReverseContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { WaveDropsReverseContainer } from '@/components/waves/drops/WaveDropsReverseContainer';
import { useIntersectionObserver } from '@/hooks/scroll/useIntersectionObserver';
import { editSlice } from '@/store/editSlice';
import React from "react";
import { render, fireEvent } from "@testing-library/react";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import { WaveDropsReverseContainer } from "@/components/waves/drops/WaveDropsReverseContainer";
import { useIntersectionObserver } from "@/hooks/scroll/useIntersectionObserver";
import { editSlice } from "@/store/editSlice";

jest.mock('@/hooks/scroll/useIntersectionObserver');
jest.mock("@/hooks/scroll/useIntersectionObserver");

const mockUseIntersectionObserver = useIntersectionObserver as jest.Mock;

const createTestStore = () => configureStore({
reducer: {
edit: editSlice.reducer,
},
});
const createTestStore = () =>
configureStore({
reducer: {
edit: editSlice.reducer,
},
});

function setup(props?: any) {
const onTopIntersection = jest.fn();
const onUserScroll = jest.fn();
const store = createTestStore();

const utils = render(
<Provider store={store}>
<WaveDropsReverseContainer
Expand All @@ -37,27 +38,92 @@ function setup(props?: any) {
return { ...utils, onTopIntersection, onUserScroll };
}

describe('WaveDropsReverseContainer', () => {
describe("WaveDropsReverseContainer", () => {
beforeEach(() => {
mockUseIntersectionObserver.mockImplementation((ref, opts, cb) => {
cb({ isIntersecting: true } as any);
});
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: any) => {
cb();
return 1 as any;
});
jest
.spyOn(window, "requestAnimationFrame")
.mockImplementation((cb: any) => {
cb();
return 1 as any;
});
});

it('calls onTopIntersection when sentinel visible', () => {
it("calls onTopIntersection when sentinel visible", () => {
const { onTopIntersection } = setup();
expect(onTopIntersection).toHaveBeenCalled();
});

it('invokes onUserScroll on scroll', () => {
it("invokes onUserScroll on scroll", () => {
const { container, onUserScroll } = setup();
const scrollDiv = container.firstChild as HTMLElement;
Object.defineProperty(scrollDiv, 'scrollTop', { value: -10, writable: true });
Object.defineProperty(scrollDiv, "scrollTop", {
value: -10,
writable: true,
});
fireEvent.scroll(scrollDiv);
expect(onUserScroll).toHaveBeenCalledWith('up', false);
expect(onUserScroll).toHaveBeenCalledWith("up", false);
});

it("keeps callback ref stable across rerenders and only detaches on unmount", () => {
const store = createTestStore();
const onTopIntersection = jest.fn();
const callbackRef = jest.fn();

const { rerender, unmount } = render(
<Provider store={store}>
<WaveDropsReverseContainer
ref={callbackRef}
onTopIntersection={onTopIntersection}
isFetchingNextPage={false}
hasNextPage={true}
>
<div>child</div>
</WaveDropsReverseContainer>
</Provider>
);

const attachedCalls = () =>
callbackRef.mock.calls.filter(([instance]) => instance !== null);
const detachedCalls = () =>
callbackRef.mock.calls.filter(([instance]) => instance === null);

expect(attachedCalls()).toHaveLength(1);
expect(detachedCalls()).toHaveLength(0);

rerender(
<Provider store={store}>
<WaveDropsReverseContainer
ref={callbackRef}
onTopIntersection={onTopIntersection}
isFetchingNextPage={true}
hasNextPage={true}
>
<div>child</div>
</WaveDropsReverseContainer>
</Provider>
);

rerender(
<Provider store={store}>
<WaveDropsReverseContainer
ref={callbackRef}
onTopIntersection={onTopIntersection}
isFetchingNextPage={false}
hasNextPage={true}
>
<div>child</div>
</WaveDropsReverseContainer>
</Provider>
);

expect(attachedCalls()).toHaveLength(1);
expect(detachedCalls()).toHaveLength(0);

unmount();

expect(detachedCalls()).toHaveLength(1);
});
});
Loading