Skip to content

Commit b5b095d

Browse files
authored
Merge pull request #25 from objectstack-ai/copilot/refactor-views-api-permissions-workflow
2 parents 97cb508 + e1cd7ba commit b5b095d

20 files changed

Lines changed: 2253 additions & 132 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ node_modules/
66
# pnpm
77
.pnpm-store/
88

9+
# npm (project uses pnpm)
10+
package-lock.json
11+
912
# Expo
1013
.expo/
1114
dist/
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
/**
2+
* Tests for useNotifications – validates notification listing,
3+
* read/unread management, and device registration.
4+
*/
5+
import { renderHook, act, waitFor } from "@testing-library/react-native";
6+
7+
/* ---- Mock useClient from SDK ---- */
8+
const mockList = jest.fn();
9+
const mockMarkRead = jest.fn();
10+
const mockMarkAllRead = jest.fn();
11+
const mockRegisterDevice = jest.fn();
12+
const mockGetPreferences = jest.fn();
13+
const mockUpdatePreferences = jest.fn();
14+
15+
const mockClient = {
16+
notifications: {
17+
list: mockList,
18+
markRead: mockMarkRead,
19+
markAllRead: mockMarkAllRead,
20+
registerDevice: mockRegisterDevice,
21+
getPreferences: mockGetPreferences,
22+
updatePreferences: mockUpdatePreferences,
23+
},
24+
};
25+
26+
jest.mock("@objectstack/client-react", () => ({
27+
useClient: () => mockClient,
28+
}));
29+
30+
import { useNotifications } from "~/hooks/useNotifications";
31+
32+
beforeEach(() => {
33+
mockList.mockReset();
34+
mockMarkRead.mockReset();
35+
mockMarkAllRead.mockReset();
36+
mockRegisterDevice.mockReset();
37+
mockGetPreferences.mockReset();
38+
mockUpdatePreferences.mockReset();
39+
});
40+
41+
const mockNotificationsResponse = {
42+
notifications: [
43+
{
44+
id: "n1",
45+
type: "record_update",
46+
title: "Task Updated",
47+
body: "Task #42 was updated",
48+
read: false,
49+
createdAt: "2026-01-01T00:00:00Z",
50+
actionUrl: "/app/tasks/42",
51+
},
52+
{
53+
id: "n2",
54+
type: "mention",
55+
title: "You were mentioned",
56+
body: "Alice mentioned you in a comment",
57+
read: true,
58+
createdAt: "2025-12-31T00:00:00Z",
59+
},
60+
],
61+
unreadCount: 1,
62+
cursor: undefined,
63+
};
64+
65+
describe("useNotifications", () => {
66+
it("fetches notifications on mount", async () => {
67+
mockList.mockResolvedValue(mockNotificationsResponse);
68+
69+
const { result } = renderHook(() => useNotifications());
70+
71+
await waitFor(() => {
72+
expect(result.current.isLoading).toBe(false);
73+
});
74+
75+
expect(result.current.notifications).toHaveLength(2);
76+
expect(result.current.notifications[0].title).toBe("Task Updated");
77+
expect(result.current.notifications[0].read).toBe(false);
78+
expect(result.current.notifications[1].read).toBe(true);
79+
expect(result.current.unreadCount).toBe(1);
80+
expect(result.current.hasMore).toBe(false);
81+
expect(result.current.error).toBeNull();
82+
});
83+
84+
it("handles error when fetching notifications", async () => {
85+
mockList.mockRejectedValue(new Error("Server error"));
86+
87+
const { result } = renderHook(() => useNotifications());
88+
89+
await waitFor(() => {
90+
expect(result.current.isLoading).toBe(false);
91+
});
92+
93+
expect(result.current.notifications).toHaveLength(0);
94+
expect(result.current.error?.message).toBe("Server error");
95+
});
96+
97+
it("marks specific notifications as read", async () => {
98+
mockList.mockResolvedValue(mockNotificationsResponse);
99+
mockMarkRead.mockResolvedValue({ success: true, readCount: 1 });
100+
101+
const { result } = renderHook(() => useNotifications());
102+
103+
await waitFor(() => {
104+
expect(result.current.isLoading).toBe(false);
105+
});
106+
107+
await act(async () => {
108+
await result.current.markRead(["n1"]);
109+
});
110+
111+
expect(mockMarkRead).toHaveBeenCalledWith(["n1"]);
112+
// Optimistic update: n1 should now be read
113+
expect(result.current.notifications[0].read).toBe(true);
114+
expect(result.current.unreadCount).toBe(0);
115+
});
116+
117+
it("marks all notifications as read", async () => {
118+
mockList.mockResolvedValue(mockNotificationsResponse);
119+
mockMarkAllRead.mockResolvedValue({ success: true, readCount: 1 });
120+
121+
const { result } = renderHook(() => useNotifications());
122+
123+
await waitFor(() => {
124+
expect(result.current.isLoading).toBe(false);
125+
});
126+
127+
await act(async () => {
128+
await result.current.markAllRead();
129+
});
130+
131+
expect(mockMarkAllRead).toHaveBeenCalled();
132+
expect(result.current.unreadCount).toBe(0);
133+
expect(result.current.notifications.every((n) => n.read)).toBe(true);
134+
});
135+
136+
it("registers device for push notifications", async () => {
137+
mockList.mockResolvedValue({ notifications: [], unreadCount: 0 });
138+
mockRegisterDevice.mockResolvedValue({
139+
deviceId: "device-123",
140+
success: true,
141+
});
142+
143+
const { result } = renderHook(() => useNotifications());
144+
145+
await waitFor(() => {
146+
expect(result.current.isLoading).toBe(false);
147+
});
148+
149+
let response: { deviceId: string; success: boolean };
150+
await act(async () => {
151+
response = await result.current.registerDevice(
152+
"expo-push-token-abc",
153+
"ios",
154+
);
155+
});
156+
157+
expect(mockRegisterDevice).toHaveBeenCalledWith({
158+
token: "expo-push-token-abc",
159+
platform: "ios",
160+
});
161+
expect(response!.deviceId).toBe("device-123");
162+
});
163+
164+
it("fetches notification preferences", async () => {
165+
mockList.mockResolvedValue({ notifications: [], unreadCount: 0 });
166+
mockGetPreferences.mockResolvedValue({
167+
preferences: {
168+
email: true,
169+
push: true,
170+
inApp: true,
171+
digest: "daily",
172+
},
173+
});
174+
175+
const { result } = renderHook(() => useNotifications());
176+
177+
await waitFor(() => {
178+
expect(result.current.isLoading).toBe(false);
179+
});
180+
181+
let prefs: Record<string, unknown> | null;
182+
await act(async () => {
183+
prefs = await result.current.getPreferences();
184+
});
185+
186+
expect(prefs!).toEqual({
187+
email: true,
188+
push: true,
189+
inApp: true,
190+
digest: "daily",
191+
});
192+
});
193+
194+
it("updates notification preferences", async () => {
195+
mockList.mockResolvedValue({ notifications: [], unreadCount: 0 });
196+
mockUpdatePreferences.mockResolvedValue({
197+
preferences: {
198+
email: false,
199+
push: true,
200+
inApp: true,
201+
digest: "weekly",
202+
},
203+
});
204+
205+
const { result } = renderHook(() => useNotifications());
206+
207+
await waitFor(() => {
208+
expect(result.current.isLoading).toBe(false);
209+
});
210+
211+
await act(async () => {
212+
await result.current.updatePreferences({ email: false, digest: "weekly" });
213+
});
214+
215+
expect(mockUpdatePreferences).toHaveBeenCalledWith({
216+
email: false,
217+
digest: "weekly",
218+
});
219+
});
220+
221+
it("supports cursor-based pagination", async () => {
222+
mockList.mockResolvedValueOnce({
223+
notifications: [
224+
{
225+
id: "n1",
226+
type: "info",
227+
title: "First",
228+
body: "Body",
229+
read: false,
230+
createdAt: "2026-01-01T00:00:00Z",
231+
},
232+
],
233+
unreadCount: 2,
234+
cursor: "cursor-abc",
235+
});
236+
237+
const { result } = renderHook(() => useNotifications());
238+
239+
await waitFor(() => {
240+
expect(result.current.isLoading).toBe(false);
241+
});
242+
243+
expect(result.current.notifications).toHaveLength(1);
244+
expect(result.current.hasMore).toBe(true);
245+
});
246+
});

0 commit comments

Comments
 (0)