Couverture des processors (RefreshToken, RequestPasswordReset, ResetPassword, SwitchRole, UpdateUserRoles), des query handlers (HasGradesInPeriod, HasStudentsInClass), des messaging handlers (SendActivationConfirmation, SendPasswordResetEmail), et côté frontend des modules auth, roles, monitoring, types et E2E tokens.
477 lines
14 KiB
TypeScript
477 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
/**
|
|
* Unit tests for the Web Vitals monitoring module (webVitals.ts).
|
|
*
|
|
* Tests metric collection initialization, rating calculations against
|
|
* Core Web Vitals 2024 thresholds, and the default reporter behavior
|
|
* (console logging in debug mode, sendBeacon/fetch in production).
|
|
*/
|
|
|
|
// Capture the callbacks registered by initWebVitals
|
|
const mockOnLCP = vi.fn();
|
|
const mockOnCLS = vi.fn();
|
|
const mockOnINP = vi.fn();
|
|
const mockOnFCP = vi.fn();
|
|
const mockOnTTFB = vi.fn();
|
|
|
|
vi.mock('web-vitals', () => ({
|
|
onLCP: mockOnLCP,
|
|
onCLS: mockOnCLS,
|
|
onINP: mockOnINP,
|
|
onFCP: mockOnFCP,
|
|
onTTFB: mockOnTTFB
|
|
}));
|
|
|
|
describe('webVitals monitoring', () => {
|
|
let webVitalsModule: typeof import('$lib/monitoring/webVitals');
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
vi.resetModules();
|
|
webVitalsModule = await import('$lib/monitoring/webVitals');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ==========================================================================
|
|
// initWebVitals
|
|
// ==========================================================================
|
|
describe('initWebVitals', () => {
|
|
it('should register callbacks for all five web vitals', () => {
|
|
const reporter = vi.fn();
|
|
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
expect(mockOnLCP).toHaveBeenCalledOnce();
|
|
expect(mockOnCLS).toHaveBeenCalledOnce();
|
|
expect(mockOnINP).toHaveBeenCalledOnce();
|
|
expect(mockOnFCP).toHaveBeenCalledOnce();
|
|
expect(mockOnTTFB).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('should pass metrics to the reporter when LCP fires', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
// Simulate LCP metric firing
|
|
const lcpCallback = mockOnLCP.mock.calls[0]![0];
|
|
lcpCallback({ name: 'LCP', value: 2000, delta: 2000, id: 'v1-lcp' });
|
|
|
|
expect(reporter).toHaveBeenCalledOnce();
|
|
expect(reporter).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'LCP',
|
|
value: 2000,
|
|
rating: 'good',
|
|
delta: 2000,
|
|
id: 'v1-lcp'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should pass metrics to the reporter when CLS fires', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const clsCallback = mockOnCLS.mock.calls[0]![0];
|
|
clsCallback({ name: 'CLS', value: 0.05, delta: 0.05, id: 'v1-cls' });
|
|
|
|
expect(reporter).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'CLS',
|
|
value: 0.05,
|
|
rating: 'good',
|
|
delta: 0.05,
|
|
id: 'v1-cls'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should pass metrics to the reporter when INP fires', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const inpCallback = mockOnINP.mock.calls[0]![0];
|
|
inpCallback({ name: 'INP', value: 150, delta: 150, id: 'v1-inp' });
|
|
|
|
expect(reporter).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'INP',
|
|
value: 150,
|
|
rating: 'good'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should pass metrics to the reporter when FCP fires', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const fcpCallback = mockOnFCP.mock.calls[0]![0];
|
|
fcpCallback({ name: 'FCP', value: 1500, delta: 1500, id: 'v1-fcp' });
|
|
|
|
expect(reporter).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'FCP',
|
|
value: 1500,
|
|
rating: 'good'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should pass metrics to the reporter when TTFB fires', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const ttfbCallback = mockOnTTFB.mock.calls[0]![0];
|
|
ttfbCallback({ name: 'TTFB', value: 600, delta: 600, id: 'v1-ttfb' });
|
|
|
|
expect(reporter).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: 'TTFB',
|
|
value: 600,
|
|
rating: 'good'
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Rating calculations (via initWebVitals callback pipeline)
|
|
// ==========================================================================
|
|
describe('rating calculations', () => {
|
|
// LCP thresholds: good <= 2500, needs-improvement <= 4000, poor > 4000
|
|
describe('LCP ratings', () => {
|
|
it('should rate LCP as good when value <= 2500', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const lcpCallback = mockOnLCP.mock.calls[0]![0];
|
|
lcpCallback({ name: 'LCP', value: 2500, delta: 2500, id: 'lcp-1' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('good');
|
|
});
|
|
|
|
it('should rate LCP as needs-improvement when 2500 < value <= 4000', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const lcpCallback = mockOnLCP.mock.calls[0]![0];
|
|
lcpCallback({ name: 'LCP', value: 3000, delta: 3000, id: 'lcp-2' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement');
|
|
});
|
|
|
|
it('should rate LCP as poor when value > 4000', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const lcpCallback = mockOnLCP.mock.calls[0]![0];
|
|
lcpCallback({ name: 'LCP', value: 5000, delta: 5000, id: 'lcp-3' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('poor');
|
|
});
|
|
});
|
|
|
|
// FCP thresholds: good <= 1800, needs-improvement <= 3000, poor > 3000
|
|
describe('FCP ratings', () => {
|
|
it('should rate FCP as good when value <= 1800', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const fcpCallback = mockOnFCP.mock.calls[0]![0];
|
|
fcpCallback({ name: 'FCP', value: 1800, delta: 1800, id: 'fcp-1' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('good');
|
|
});
|
|
|
|
it('should rate FCP as needs-improvement when 1800 < value <= 3000', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const fcpCallback = mockOnFCP.mock.calls[0]![0];
|
|
fcpCallback({ name: 'FCP', value: 2500, delta: 2500, id: 'fcp-2' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement');
|
|
});
|
|
|
|
it('should rate FCP as poor when value > 3000', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const fcpCallback = mockOnFCP.mock.calls[0]![0];
|
|
fcpCallback({ name: 'FCP', value: 4000, delta: 4000, id: 'fcp-3' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('poor');
|
|
});
|
|
});
|
|
|
|
// INP thresholds: good <= 200, needs-improvement <= 500, poor > 500
|
|
describe('INP ratings', () => {
|
|
it('should rate INP as good when value <= 200', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const inpCallback = mockOnINP.mock.calls[0]![0];
|
|
inpCallback({ name: 'INP', value: 200, delta: 200, id: 'inp-1' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('good');
|
|
});
|
|
|
|
it('should rate INP as needs-improvement when 200 < value <= 500', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const inpCallback = mockOnINP.mock.calls[0]![0];
|
|
inpCallback({ name: 'INP', value: 350, delta: 350, id: 'inp-2' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement');
|
|
});
|
|
|
|
it('should rate INP as poor when value > 500', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const inpCallback = mockOnINP.mock.calls[0]![0];
|
|
inpCallback({ name: 'INP', value: 600, delta: 600, id: 'inp-3' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('poor');
|
|
});
|
|
});
|
|
|
|
// CLS thresholds: good <= 0.1, needs-improvement <= 0.25, poor > 0.25
|
|
describe('CLS ratings', () => {
|
|
it('should rate CLS as good when value <= 0.1', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const clsCallback = mockOnCLS.mock.calls[0]![0];
|
|
clsCallback({ name: 'CLS', value: 0.1, delta: 0.1, id: 'cls-1' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('good');
|
|
});
|
|
|
|
it('should rate CLS as needs-improvement when 0.1 < value <= 0.25', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const clsCallback = mockOnCLS.mock.calls[0]![0];
|
|
clsCallback({ name: 'CLS', value: 0.15, delta: 0.15, id: 'cls-2' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement');
|
|
});
|
|
|
|
it('should rate CLS as poor when value > 0.25', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const clsCallback = mockOnCLS.mock.calls[0]![0];
|
|
clsCallback({ name: 'CLS', value: 0.5, delta: 0.5, id: 'cls-3' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('poor');
|
|
});
|
|
});
|
|
|
|
// TTFB thresholds: good <= 800, needs-improvement <= 1800, poor > 1800
|
|
describe('TTFB ratings', () => {
|
|
it('should rate TTFB as good when value <= 800', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const ttfbCallback = mockOnTTFB.mock.calls[0]![0];
|
|
ttfbCallback({ name: 'TTFB', value: 800, delta: 800, id: 'ttfb-1' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('good');
|
|
});
|
|
|
|
it('should rate TTFB as needs-improvement when 800 < value <= 1800', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const ttfbCallback = mockOnTTFB.mock.calls[0]![0];
|
|
ttfbCallback({ name: 'TTFB', value: 1200, delta: 1200, id: 'ttfb-2' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement');
|
|
});
|
|
|
|
it('should rate TTFB as poor when value > 1800', () => {
|
|
const reporter = vi.fn();
|
|
webVitalsModule.initWebVitals(reporter);
|
|
|
|
const ttfbCallback = mockOnTTFB.mock.calls[0]![0];
|
|
ttfbCallback({ name: 'TTFB', value: 2500, delta: 2500, id: 'ttfb-3' });
|
|
|
|
expect(reporter.mock.calls[0]![0].rating).toBe('poor');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// createDefaultReporter
|
|
// ==========================================================================
|
|
describe('createDefaultReporter', () => {
|
|
it('should return a function', () => {
|
|
const reporter = webVitalsModule.createDefaultReporter({});
|
|
|
|
expect(typeof reporter).toBe('function');
|
|
});
|
|
|
|
it('should log to console when debug is true', () => {
|
|
const reporter = webVitalsModule.createDefaultReporter({ debug: true });
|
|
|
|
reporter({
|
|
name: 'LCP',
|
|
value: 2100.5,
|
|
rating: 'good',
|
|
delta: 2100.5,
|
|
id: 'v1-lcp'
|
|
});
|
|
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
'[WebVitals] LCP: 2100.50 (good)'
|
|
);
|
|
});
|
|
|
|
it('should not log to console when debug is false', () => {
|
|
const reporter = webVitalsModule.createDefaultReporter({ debug: false });
|
|
|
|
reporter({
|
|
name: 'LCP',
|
|
value: 2100,
|
|
rating: 'good',
|
|
delta: 2100,
|
|
id: 'v1-lcp'
|
|
});
|
|
|
|
expect(console.log).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not log to console when debug is not set', () => {
|
|
const reporter = webVitalsModule.createDefaultReporter({});
|
|
|
|
reporter({
|
|
name: 'FCP',
|
|
value: 1500,
|
|
rating: 'good',
|
|
delta: 1500,
|
|
id: 'v1-fcp'
|
|
});
|
|
|
|
expect(console.log).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should use sendBeacon when endpoint is set and sendBeacon is available', () => {
|
|
const mockSendBeacon = vi.fn().mockReturnValue(true);
|
|
vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon });
|
|
|
|
const reporter = webVitalsModule.createDefaultReporter({
|
|
endpoint: 'https://analytics.classeo.fr/vitals'
|
|
});
|
|
|
|
const metric = {
|
|
name: 'CLS' as const,
|
|
value: 0.05,
|
|
rating: 'good' as const,
|
|
delta: 0.05,
|
|
id: 'v1-cls'
|
|
};
|
|
|
|
reporter(metric);
|
|
|
|
expect(mockSendBeacon).toHaveBeenCalledWith(
|
|
'https://analytics.classeo.fr/vitals',
|
|
expect.any(String)
|
|
);
|
|
|
|
// Verify the payload structure
|
|
const payload = JSON.parse(mockSendBeacon.mock.calls[0]![1]);
|
|
expect(payload).toEqual(
|
|
expect.objectContaining({
|
|
metric: 'CLS',
|
|
value: 0.05,
|
|
rating: 'good',
|
|
delta: 0.05,
|
|
id: 'v1-cls'
|
|
})
|
|
);
|
|
expect(payload.timestamp).toEqual(expect.any(Number));
|
|
expect(payload.url).toEqual(expect.any(String));
|
|
});
|
|
|
|
it('should fall back to fetch when sendBeacon is not available', () => {
|
|
vi.stubGlobal('navigator', { sendBeacon: undefined });
|
|
const mockFetch = vi.fn().mockResolvedValue({});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
const reporter = webVitalsModule.createDefaultReporter({
|
|
endpoint: 'https://analytics.classeo.fr/vitals'
|
|
});
|
|
|
|
reporter({
|
|
name: 'LCP',
|
|
value: 2000,
|
|
rating: 'good',
|
|
delta: 2000,
|
|
id: 'v1-lcp'
|
|
});
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://analytics.classeo.fr/vitals',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
keepalive: true
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should not send data when no endpoint is configured', () => {
|
|
const mockSendBeacon = vi.fn();
|
|
vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon });
|
|
const mockFetch = vi.fn();
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
const reporter = webVitalsModule.createDefaultReporter({});
|
|
|
|
reporter({
|
|
name: 'INP',
|
|
value: 100,
|
|
rating: 'good',
|
|
delta: 100,
|
|
id: 'v1-inp'
|
|
});
|
|
|
|
expect(mockSendBeacon).not.toHaveBeenCalled();
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should support both debug and endpoint at the same time', () => {
|
|
const mockSendBeacon = vi.fn().mockReturnValue(true);
|
|
vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon });
|
|
|
|
const reporter = webVitalsModule.createDefaultReporter({
|
|
debug: true,
|
|
endpoint: 'https://analytics.classeo.fr/vitals'
|
|
});
|
|
|
|
reporter({
|
|
name: 'TTFB',
|
|
value: 500.123,
|
|
rating: 'good',
|
|
delta: 500.123,
|
|
id: 'v1-ttfb'
|
|
});
|
|
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
'[WebVitals] TTFB: 500.12 (good)'
|
|
);
|
|
expect(mockSendBeacon).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|