Spaces:
Sleeping
Sleeping
| import { describe, it, expect, beforeEach } from 'vitest'; | |
| import { http, HttpResponse } from 'msw'; | |
| import { server } from '../../helpers/msw/server'; | |
| import { useInAppNotificationStore } from '../../../src/store/inAppNotificationStore'; | |
| import { resetAllStores } from '../../helpers/store'; | |
| // Raw notification factory matching the server shape (is_read as 0/1, params as strings) | |
| function buildRawNotif(overrides: Record<string, unknown> = {}) { | |
| const id = Math.floor(Math.random() * 100000); | |
| return { | |
| id, | |
| type: 'simple', | |
| scope: 'trip', | |
| target: 1, | |
| sender_id: 2, | |
| sender_username: 'alice', | |
| sender_avatar: null, | |
| recipient_id: 1, | |
| title_key: 'notif.title', | |
| title_params: '{}', | |
| text_key: 'notif.text', | |
| text_params: '{}', | |
| positive_text_key: null, | |
| negative_text_key: null, | |
| response: null, | |
| navigate_text_key: null, | |
| navigate_target: null, | |
| is_read: 0, | |
| created_at: '2025-01-01T00:00:00.000Z', | |
| ...overrides, | |
| }; | |
| } | |
| beforeEach(() => { | |
| resetAllStores(); | |
| }); | |
| describe('inAppNotificationStore', () => { | |
| describe('FE-NOTIF-001: fetchNotifications() loads first page', () => { | |
| it('populates notifications, total, and unreadCount', async () => { | |
| await useInAppNotificationStore.getState().fetchNotifications(); | |
| const state = useInAppNotificationStore.getState(); | |
| expect(state.notifications.length).toBeGreaterThan(0); | |
| expect(state.total).toBeGreaterThan(0); | |
| expect(state.unreadCount).toBe(5); | |
| expect(state.isLoading).toBe(false); | |
| }); | |
| }); | |
| describe('FE-NOTIF-002: Pagination — loading more appends to list', () => { | |
| it('appends additional notifications when fetchNotifications is called again', async () => { | |
| // First page | |
| await useInAppNotificationStore.getState().fetchNotifications(true); | |
| const firstPageCount = useInAppNotificationStore.getState().notifications.length; | |
| const total = useInAppNotificationStore.getState().total; | |
| // Only test pagination if there are more items | |
| if (firstPageCount < total) { | |
| await useInAppNotificationStore.getState().fetchNotifications(); | |
| const state = useInAppNotificationStore.getState(); | |
| expect(state.notifications.length).toBeGreaterThan(firstPageCount); | |
| } else { | |
| // All notifications fit in one page | |
| expect(firstPageCount).toBe(total); | |
| } | |
| }); | |
| }); | |
| describe('FE-NOTIF-003: markRead(id)', () => { | |
| it('updates is_read to true for the notification', async () => { | |
| // Seed with an unread notification | |
| const unread = buildRawNotif({ id: 42, is_read: 0 }); | |
| useInAppNotificationStore.setState({ | |
| notifications: [{ ...unread, title_params: {}, text_params: {}, is_read: false }] as never, | |
| unreadCount: 1, | |
| }); | |
| await useInAppNotificationStore.getState().markRead(42); | |
| const state = useInAppNotificationStore.getState(); | |
| const notif = state.notifications.find((n) => n.id === 42); | |
| expect(notif?.is_read).toBe(true); | |
| expect(state.unreadCount).toBe(0); | |
| }); | |
| }); | |
| describe('FE-NOTIF-004: handleNewNotification() prepends to list', () => { | |
| it('adds a new notification at the start of the list', () => { | |
| // Seed existing notifications | |
| useInAppNotificationStore.setState({ | |
| notifications: [{ ...buildRawNotif({ id: 1 }), title_params: {}, text_params: {}, is_read: false }] as never, | |
| total: 1, | |
| unreadCount: 1, | |
| }); | |
| const newRaw = buildRawNotif({ id: 99 }); | |
| useInAppNotificationStore.getState().handleNewNotification(newRaw as never); | |
| const state = useInAppNotificationStore.getState(); | |
| expect(state.notifications[0].id).toBe(99); | |
| expect(state.notifications.length).toBe(2); | |
| expect(state.total).toBe(2); | |
| expect(state.unreadCount).toBe(2); | |
| }); | |
| }); | |
| describe('FE-NOTIF-005: handleUpdatedNotification() updates existing notification', () => { | |
| it('replaces the notification in the list', () => { | |
| useInAppNotificationStore.setState({ | |
| notifications: [{ ...buildRawNotif({ id: 7, is_read: 0 }), title_params: {}, text_params: {}, is_read: false }] as never, | |
| total: 1, | |
| unreadCount: 1, | |
| }); | |
| const updated = buildRawNotif({ id: 7, is_read: 1 }); | |
| useInAppNotificationStore.getState().handleUpdatedNotification(updated as never); | |
| const state = useInAppNotificationStore.getState(); | |
| const notif = state.notifications.find((n) => n.id === 7); | |
| expect(notif?.is_read).toBe(true); | |
| }); | |
| }); | |
| describe('FE-NOTIF-006: Unread count is correct', () => { | |
| it('unreadCount matches the number of unread notifications', async () => { | |
| await useInAppNotificationStore.getState().fetchNotifications(true); | |
| const state = useInAppNotificationStore.getState(); | |
| // The mock returns 5 unread from the server | |
| expect(state.unreadCount).toBe(5); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-007: fetchNotifications early-return when already loading', () => { | |
| it('does not fetch when isLoading is true', async () => { | |
| useInAppNotificationStore.setState({ isLoading: true }); | |
| await useInAppNotificationStore.getState().fetchNotifications(); | |
| const state = useInAppNotificationStore.getState(); | |
| expect(state.notifications).toEqual([]); | |
| expect(state.isLoading).toBe(true); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-008: fetchNotifications(reset=true) resets existing list', () => { | |
| it('replaces seeded notifications with fresh data', async () => { | |
| // Seed store with 3 notifications | |
| useInAppNotificationStore.setState({ | |
| notifications: [ | |
| { ...buildRawNotif({ id: 901 }), title_params: {}, text_params: {}, is_read: false }, | |
| { ...buildRawNotif({ id: 902 }), title_params: {}, text_params: {}, is_read: false }, | |
| { ...buildRawNotif({ id: 903 }), title_params: {}, text_params: {}, is_read: false }, | |
| ] as never, | |
| total: 3, | |
| }); | |
| await useInAppNotificationStore.getState().fetchNotifications(true); | |
| const state = useInAppNotificationStore.getState(); | |
| // Should not contain seeded IDs | |
| expect(state.notifications.find(n => n.id === 901)).toBeUndefined(); | |
| expect(state.notifications.find(n => n.id === 902)).toBeUndefined(); | |
| expect(state.notifications.find(n => n.id === 903)).toBeUndefined(); | |
| // Should contain data from MSW (IDs 1-20) | |
| expect(state.notifications.length).toBe(20); | |
| expect(state.isLoading).toBe(false); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-009: hasMore is set correctly', () => { | |
| it('hasMore is true when more items exist, false when all loaded', async () => { | |
| // Default MSW returns 25 total, 20 per page | |
| await useInAppNotificationStore.getState().fetchNotifications(true); | |
| expect(useInAppNotificationStore.getState().hasMore).toBe(true); | |
| // Second page: offset=20, returns 5 items, total=25 => 25 >= 25 => hasMore=false | |
| await useInAppNotificationStore.getState().fetchNotifications(); | |
| expect(useInAppNotificationStore.getState().hasMore).toBe(false); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-010: fetchUnreadCount updates unreadCount', () => { | |
| it('sets unreadCount from server response', async () => { | |
| useInAppNotificationStore.setState({ unreadCount: 0 }); | |
| await useInAppNotificationStore.getState().fetchUnreadCount(); | |
| expect(useInAppNotificationStore.getState().unreadCount).toBe(5); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-011: markUnread(id)', () => { | |
| it('sets is_read to false and increments unreadCount', async () => { | |
| useInAppNotificationStore.setState({ | |
| notifications: [{ ...buildRawNotif({ id: 50, is_read: 1 }), title_params: {}, text_params: {}, is_read: true }] as never, | |
| unreadCount: 0, | |
| }); | |
| await useInAppNotificationStore.getState().markUnread(50); | |
| const state = useInAppNotificationStore.getState(); | |
| expect(state.notifications.find(n => n.id === 50)?.is_read).toBe(false); | |
| expect(state.unreadCount).toBe(1); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-012: markAllRead()', () => { | |
| it('marks all notifications as read and sets unreadCount to 0', async () => { | |
| useInAppNotificationStore.setState({ | |
| notifications: [ | |
| { ...buildRawNotif({ id: 60 }), title_params: {}, text_params: {}, is_read: false }, | |
| { ...buildRawNotif({ id: 61 }), title_params: {}, text_params: {}, is_read: false }, | |
| { ...buildRawNotif({ id: 62 }), title_params: {}, text_params: {}, is_read: false }, | |
| ] as never, | |
| unreadCount: 3, | |
| }); | |
| await useInAppNotificationStore.getState().markAllRead(); | |
| const state = useInAppNotificationStore.getState(); | |
| expect(state.notifications.every(n => n.is_read === true)).toBe(true); | |
| expect(state.unreadCount).toBe(0); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-013: deleteNotification removes unread item and decrements counts', () => { | |
| it('removes notification and decrements total and unreadCount', async () => { | |
| useInAppNotificationStore.setState({ | |
| notifications: [{ ...buildRawNotif({ id: 5 }), title_params: {}, text_params: {}, is_read: false }] as never, | |
| total: 3, | |
| unreadCount: 1, | |
| }); | |
| await useInAppNotificationStore.getState().deleteNotification(5); | |
| const state = useInAppNotificationStore.getState(); | |
| expect(state.notifications.find(n => n.id === 5)).toBeUndefined(); | |
| expect(state.total).toBe(2); | |
| expect(state.unreadCount).toBe(0); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-014: deleteNotification on read item does not decrement unreadCount', () => { | |
| it('decrements total but not unreadCount', async () => { | |
| useInAppNotificationStore.setState({ | |
| notifications: [{ ...buildRawNotif({ id: 6, is_read: 1 }), title_params: {}, text_params: {}, is_read: true }] as never, | |
| total: 2, | |
| unreadCount: 0, | |
| }); | |
| await useInAppNotificationStore.getState().deleteNotification(6); | |
| const state = useInAppNotificationStore.getState(); | |
| expect(state.total).toBe(1); | |
| expect(state.unreadCount).toBe(0); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-015: deleteAll clears all state', () => { | |
| it('resets notifications, total, unreadCount, and hasMore', async () => { | |
| useInAppNotificationStore.setState({ | |
| notifications: [ | |
| { ...buildRawNotif({ id: 70 }), title_params: {}, text_params: {}, is_read: false }, | |
| { ...buildRawNotif({ id: 71 }), title_params: {}, text_params: {}, is_read: false }, | |
| ] as never, | |
| total: 2, | |
| unreadCount: 2, | |
| hasMore: true, | |
| }); | |
| await useInAppNotificationStore.getState().deleteAll(); | |
| const state = useInAppNotificationStore.getState(); | |
| expect(state.notifications).toEqual([]); | |
| expect(state.total).toBe(0); | |
| expect(state.unreadCount).toBe(0); | |
| expect(state.hasMore).toBe(false); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-016: respondToBoolean updates notification', () => { | |
| it('updates response and is_read from server', async () => { | |
| useInAppNotificationStore.setState({ | |
| notifications: [{ | |
| ...buildRawNotif({ id: 10, type: 'boolean' }), | |
| title_params: {}, | |
| text_params: {}, | |
| is_read: false, | |
| }] as never, | |
| unreadCount: 1, | |
| }); | |
| await useInAppNotificationStore.getState().respondToBoolean(10, 'positive'); | |
| const state = useInAppNotificationStore.getState(); | |
| const notif = state.notifications.find(n => n.id === 10); | |
| expect(notif?.response).toBe('positive'); | |
| expect(notif?.is_read).toBe(true); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-017: normalizeNotification coerces stringified params', () => { | |
| it('parses JSON string params into objects', () => { | |
| const raw = buildRawNotif({ | |
| id: 200, | |
| title_params: '{"trip":"Rome"}', | |
| text_params: '{"user":"alice"}', | |
| }); | |
| useInAppNotificationStore.getState().handleNewNotification(raw as never); | |
| const notif = useInAppNotificationStore.getState().notifications.find(n => n.id === 200); | |
| expect(notif?.title_params).toEqual({ trip: 'Rome' }); | |
| expect(notif?.text_params).toEqual({ user: 'alice' }); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-018: normalizeNotification handles already-parsed params', () => { | |
| it('stores object params without error', () => { | |
| const raw = buildRawNotif({ | |
| id: 201, | |
| title_params: {}, | |
| text_params: { key: 'value' }, | |
| }); | |
| expect(() => { | |
| useInAppNotificationStore.getState().handleNewNotification(raw as never); | |
| }).not.toThrow(); | |
| const notif = useInAppNotificationStore.getState().notifications.find(n => n.id === 201); | |
| expect(notif?.title_params).toEqual({}); | |
| expect(notif?.text_params).toEqual({ key: 'value' }); | |
| }); | |
| }); | |
| describe('FE-STORE-NOTIF-019: fetchUnreadCount is best-effort', () => { | |
| it('does not throw on server error and preserves state', async () => { | |
| useInAppNotificationStore.setState({ unreadCount: 3 }); | |
| server.use( | |
| http.get('/api/notifications/in-app/unread-count', () => { | |
| return new HttpResponse(null, { status: 500 }); | |
| }), | |
| ); | |
| await expect(useInAppNotificationStore.getState().fetchUnreadCount()).resolves.not.toThrow(); | |
| expect(useInAppNotificationStore.getState().unreadCount).toBe(3); | |
| }); | |
| }); | |
| }); | |