veza/apps/web/src/utils/stateMiddleware.test.ts
senke db56db5d71 [FE-STATE-010] fe-state: Add state middleware
- Created comprehensive state middleware (stateMiddleware.ts) with:
  * Logging: State change logging with configurable filters
  * Analytics: Event tracking for state changes, actions, errors, performance
  * Error handling: Automatic error capture and reporting
  * Sanitization: Remove sensitive data from logs
  * Performance tracking: Monitor async action durations
- Applied middleware to LibraryStore as example
- Added comprehensive test suite (7 tests, all passing)
- Configurable options for all features
- Global handlers for analytics and errors
2025-12-25 14:14:54 +01:00

243 lines
7.2 KiB
TypeScript

/**
* Tests for State Middleware
* FE-STATE-010: Test logging, analytics, and error handling
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { create } from 'zustand';
import { stateMiddleware, setAnalyticsHandler, setErrorHandler, type AnalyticsEvent } from './stateMiddleware';
interface TestState {
count: number;
name: string;
increment: () => void;
setName: (name: string) => void;
throwError: () => void;
asyncAction: () => Promise<string>;
}
describe('stateMiddleware', () => {
let analyticsEvents: AnalyticsEvent[] = [];
let errorEvents: Array<{ error: Error; context: unknown }> = [];
beforeEach(() => {
analyticsEvents = [];
errorEvents = [];
setAnalyticsHandler((event) => {
analyticsEvents.push(event);
});
setErrorHandler((error, context) => {
errorEvents.push({ error, context });
});
});
it('should log state changes in development', () => {
const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
const useTestStore = create<TestState>()(
stateMiddleware(
(set) => ({
count: 0,
name: '',
increment: () => set((state) => ({ count: state.count + 1 })),
setName: (name: string) => set({ name }),
throwError: () => {
throw new Error('Test error');
},
asyncAction: async () => {
return 'success';
},
}),
{ storeName: 'TestStore', enableLogging: true },
),
);
const store = useTestStore.getState();
store.increment();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should track analytics events', () => {
const useTestStore = create<TestState>()(
stateMiddleware(
(set) => ({
count: 0,
name: '',
increment: () => set((state) => ({ count: state.count + 1 })),
setName: (name: string) => set({ name }),
throwError: () => {
throw new Error('Test error');
},
asyncAction: async () => {
return 'success';
},
}),
{ storeName: 'TestStore', enableAnalytics: true },
),
);
const store = useTestStore.getState();
store.increment();
expect(analyticsEvents.length).toBeGreaterThan(0);
expect(analyticsEvents.some((e) => e.type === 'state_change')).toBe(true);
expect(analyticsEvents.some((e) => e.type === 'action_called')).toBe(true);
});
it('should handle errors', () => {
const useTestStore = create<TestState>()(
stateMiddleware(
(set) => ({
count: 0,
name: '',
increment: () => set((state) => ({ count: state.count + 1 })),
setName: (name: string) => set({ name }),
throwError: () => {
throw new Error('Test error');
},
asyncAction: async () => {
return 'success';
},
}),
{ storeName: 'TestStore', enableErrorHandling: true },
),
);
const store = useTestStore.getState();
expect(() => store.throwError()).toThrow('Test error');
expect(errorEvents.length).toBeGreaterThan(0);
expect(errorEvents[0].error.message).toBe('Test error');
});
it('should track async action performance', async () => {
// Clear previous events
analyticsEvents = [];
const useTestStore = create<TestState>()(
stateMiddleware(
(set) => ({
count: 0,
name: '',
increment: () => set((state) => ({ count: state.count + 1 })),
setName: (name: string) => set({ name }),
throwError: () => {
throw new Error('Test error');
},
asyncAction: async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return 'success';
},
}),
{ storeName: 'TestStore', enableAnalytics: true },
),
);
const store = useTestStore.getState();
await store.asyncAction();
// Wait a bit for async analytics to be tracked
await new Promise((resolve) => setTimeout(resolve, 10));
const performanceEvents = analyticsEvents.filter(
(e) => e.type === 'performance_metric' && e.action === 'asyncAction',
);
expect(performanceEvents.length).toBeGreaterThan(0);
});
it('should sanitize sensitive data', () => {
const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
const useTestStore = create<{ password: string; token: string; name: string; updateName: (name: string) => void }>()(
stateMiddleware(
(set) => ({
password: 'secret123',
token: 'abc123',
name: 'John',
updateName: (name: string) => set({ name }),
}),
{
storeName: 'TestStore',
enableLogging: true,
sanitizeState: (state) => {
if (typeof state === 'object' && state !== null) {
const sanitized = { ...state as Record<string, unknown> };
sanitized.password = '[REDACTED]';
sanitized.token = '[REDACTED]';
return sanitized;
}
return state;
},
},
),
);
const store = useTestStore.getState();
store.updateName('Jane'); // Trigger state change
// Verify that logging was called (the middleware logs state changes)
// The logger uses [DEBUG] prefix, so we check for that
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should respect shouldLog filter', () => {
const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
const useTestStore = create<{ count: number; increment: () => void }>()(
stateMiddleware(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{
storeName: 'TestStore',
enableLogging: true,
shouldLog: () => false, // Don't log state changes
},
),
);
const store = useTestStore.getState();
store.increment();
// Action calls are still logged, but state changes are filtered
// So we expect at least one call (action call), but state change logging is filtered
const calls = consoleSpy.mock.calls;
const stateChangeCalls = calls.filter((call) =>
call[0]?.toString().includes('State change')
);
expect(stateChangeCalls.length).toBe(0);
consoleSpy.mockRestore();
});
it('should respect shouldTrack filter', () => {
const useTestStore = create<{ count: number; increment: () => void }>()(
stateMiddleware(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{
storeName: 'TestStore',
enableAnalytics: true,
shouldTrack: (action) => action !== 'increment', // Don't track increment
},
),
);
const store = useTestStore.getState();
store.increment();
// Should not track increment action
const actionEvents = analyticsEvents.filter(
(e) => e.type === 'action_called' && e.action === 'increment',
);
expect(actionEvents.length).toBe(0);
});
});