Trek_CN/client/tests/unit/stores/inAppNotificationStore.test.ts

352 lines
13 KiB
TypeScript
Raw Normal View History

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);
});
});
});