From 411a401d464f2c3e12ba44f17c355b89dfdfd0e9 Mon Sep 17 00:00:00 2001 From: AxelleDev <139721736+AxelleDev@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:37:44 +0200 Subject: [PATCH 1/4] raised coverage with error-path batteries and ratcheted the thresholds --- backend/package.json | 8 +- .../tests/unit/auth.controller.errors.test.js | 128 +++++++++++ .../unit/projects.controller.errors.test.js | 92 ++++++++ .../tests/unit/user.service.identity.test.js | 91 ++++++++ backend/tests/unit/utils.misc.test.js | 100 +++++++++ frontend/tests/unit/useClipboard.test.jsx | 73 +++++++ frontend/tests/unit/useNormActions.test.jsx | 199 ++++++++++++++++++ .../tests/unit/usePaletteActions.test.jsx | 157 ++++++++++++++ frontend/vite.config.js | 8 +- 9 files changed, 848 insertions(+), 8 deletions(-) create mode 100644 backend/tests/unit/auth.controller.errors.test.js create mode 100644 backend/tests/unit/projects.controller.errors.test.js create mode 100644 backend/tests/unit/user.service.identity.test.js create mode 100644 backend/tests/unit/utils.misc.test.js create mode 100644 frontend/tests/unit/useClipboard.test.jsx create mode 100644 frontend/tests/unit/useNormActions.test.jsx create mode 100644 frontend/tests/unit/usePaletteActions.test.jsx diff --git a/backend/package.json b/backend/package.json index d3c8f57..e8dbcb4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,10 +35,10 @@ "jest": { "coverageThreshold": { "global": { - "statements": 84, - "branches": 70, - "functions": 90, - "lines": 85 + "statements": 89, + "branches": 74, + "functions": 91, + "lines": 89 } } }, diff --git a/backend/tests/unit/auth.controller.errors.test.js b/backend/tests/unit/auth.controller.errors.test.js new file mode 100644 index 0000000..ab8fa11 --- /dev/null +++ b/backend/tests/unit/auth.controller.errors.test.js @@ -0,0 +1,128 @@ +/** + * Error-path battery for the auth controller: the anti-enumeration behaviors + * (duplicate email indistinguishable from success shape, generic 500s) and + * the clean mapping of unexpected failures on every endpoint — the raw + * database error must never reach a client. + */ +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test_jwt_secret'; +process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test_jwt_refresh_secret'; +process.env.TOTP_ENCRYPTION_KEY = + process.env.TOTP_ENCRYPTION_KEY || + '20f766230f5b4740f5b620d2dde09488b110435c13395edb10e1fdcd5ddf2098'; +process.env.MAIL_HOST = process.env.MAIL_HOST || 'smtp.test.local'; +process.env.MAIL_PORT = process.env.MAIL_PORT || '465'; +process.env.MAIL_SECURE = process.env.MAIL_SECURE || 'true'; +process.env.MAIL_USER = process.env.MAIL_USER || 'mail@test.local'; +process.env.MAIL_PASS = process.env.MAIL_PASS || 'test_mail_password'; + +jest.mock('../../src/services/mail.service'); +jest.mock('../../src/database'); +jest.mock('../../src/services/token.service'); + +const authController = require('../../src/controllers/auth.controller'); +const db = require('../../src/database'); +const mailService = require('../../src/services/mail.service'); + +const makeRes = () => ({ + json: jest.fn(), + status: jest.fn().mockReturnThis(), + cookie: jest.fn(), + clearCookie: jest.fn(), +}); + +describe('auth controller error paths', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('register', () => { + const body = { name: 'Axelle', email: 'axelle@example.com', password: 'Sup3rSecret!' }; + + it('answers a duplicate email with a GENERIC message (no account enumeration)', async () => { + const dup = new Error('dup'); + dup.code = 'ER_DUP_ENTRY'; + db.query.mockRejectedValueOnce(dup); + const res = makeRes(); + + await authController.register({ body, id: 'req-1' }, res); + + expect(res.status).toHaveBeenCalledWith(400); + const payload = res.json.mock.calls.at(-1)[0]; + expect(payload.error).not.toMatch(/already|exist|taken|duplicate/i); + }); + + it('still succeeds when the verification mail fails to send (resend covers it)', async () => { + db.query.mockResolvedValueOnce([{ insertId: 7 }]); + mailService.sendMail.mockRejectedValueOnce(new Error('smtp down')); + const res = makeRes(); + + await authController.register({ body, id: 'req-1' }, res); + await new Promise((resolve) => setImmediate(resolve)); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true })); + }); + + it('maps an unexpected failure to a generic 500', async () => { + db.query.mockRejectedValueOnce(new Error('connection lost')); + const res = makeRes(); + + await authController.register({ body, id: 'req-1' }, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Server error.' }); + }); + }); + + // Endpoints whose unexpected-failure contract is a clean 500 with no detail. + const FIVE_HUNDREDS = [ + ['login', { body: { email: 'a@b.com', password: 'Password1' } }], + ['verify', { body: { email: 'a@b.com', code: '123456' } }], + ['resendCode', { body: { email: 'a@b.com' } }], + ['forgotPassword', { body: { email: 'a@b.com' } }], + ['resetPassword', { body: { email: 'a@b.com', code: '123456', newPassword: 'N3wPassword!' } }], + ]; + + describe.each(FIVE_HUNDREDS)('%s', (name, baseReq) => { + it('maps an unexpected database failure to a clean 500', async () => { + db.query.mockRejectedValue(new Error('connection lost')); + const res = makeRes(); + + await authController[name]({ ...baseReq, id: 'req-1' }, res); + + expect(res.status).toHaveBeenCalledWith(500); + const payload = res.json.mock.calls.at(-1)[0]; + expect(JSON.stringify(payload)).not.toContain('connection lost'); + }); + }); + + describe('refresh', () => { + it('rejects a request with no refresh cookie at all', async () => { + const res = makeRes(); + await authController.refresh({ headers: {}, id: 'req-1' }, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Missing refresh token.' }); + }); + }); + + describe('demoLogin', () => { + it('answers 503 when no demo account is seeded', async () => { + db.query.mockResolvedValueOnce([[]]); + const res = makeRes(); + + await authController.demoLogin({ id: 'req-1' }, res); + + expect(res.status).toHaveBeenCalledWith(503); + }); + + it('maps an unexpected failure to a clean 500', async () => { + db.query.mockRejectedValueOnce(new Error('connection lost')); + const res = makeRes(); + + await authController.demoLogin({ id: 'req-1' }, res); + + expect(res.status).toHaveBeenCalledWith(500); + }); + }); +}); diff --git a/backend/tests/unit/projects.controller.errors.test.js b/backend/tests/unit/projects.controller.errors.test.js new file mode 100644 index 0000000..92e76bb --- /dev/null +++ b/backend/tests/unit/projects.controller.errors.test.js @@ -0,0 +1,92 @@ +/** + * Systematic error-path battery for the projects controller: every handler + * must (1) reject an unauthenticated request with 401 before touching the + * database, and (2) map an unexpected database failure to a clean 500 — + * never leak the raw error. Table-driven so a future handler added to the + * controller without these guarantees fails loudly here. + */ +const projectsController = require('../../src/controllers/projects.controller'); +const db = require('../../src/database'); + +jest.mock('../../src/database'); + +const makeRes = () => ({ + json: jest.fn(), + status: jest.fn().mockReturnThis(), + set: jest.fn(), + send: jest.fn(), +}); + +// Handler name -> a request shaped for it (no `user` — added per test). +const AUTHENTICATED_HANDLERS = { + listProjects: { query: {} }, + getProject: { params: { id: '1' } }, + searchProjects: { query: { q: 'x' } }, + createProject: { body: { name: 'Projet' } }, + duplicateProject: { params: { id: '1' } }, + pinProject: { params: { id: '1' } }, + unpinProject: { params: { id: '1' } }, + reorderPinnedProjects: { body: [1] }, + enableSharing: { params: { id: '1' } }, + disableSharing: { params: { id: '1' } }, + updateProjectName: { params: { id: '1' }, body: { name: 'New name' } }, + deleteProject: { params: { id: '1' } }, + listTrashedProjects: { query: {} }, + restoreProject: { params: { id: '1' } }, + deleteProjectPermanently: { params: { id: '1' } }, + addBrushNorm: { params: { id: '1' }, body: { name: 'Line', value: '8', unit: 'px' } }, + addTypographyNorm: { + params: { id: '1' }, + body: { fontFamily: 'Figtree', fontWeight: '400', fontUsage: 'Body' }, + }, + updatePalette: { params: { id: '1' }, body: [{ name: 'Ink', hex: '#112233' }] }, + reorderBrushNorms: { params: { id: '1' }, body: { orderedIds: [1] } }, + reorderTypographyNorms: { params: { id: '1' }, body: { orderedIds: [1] } }, + deleteBrushNorm: { params: { id: '1', normId: '2' } }, + deleteTypographyNorm: { params: { id: '1', normId: '2' } }, + listTrashedBrushNorms: { params: { id: '1' } }, + restoreBrushNorm: { params: { id: '1', normId: '2' } }, + deleteBrushNormPermanently: { params: { id: '1', normId: '2' } }, + listTrashedTypographyNorms: { params: { id: '1' } }, + restoreTypographyNorm: { params: { id: '1', normId: '2' } }, + deleteTypographyNormPermanently: { params: { id: '1', normId: '2' } }, + deletePaletteColor: { params: { id: '1', colorId: '2' } }, + listTrashedPaletteColors: { params: { id: '1' } }, + restorePaletteColor: { params: { id: '1', colorId: '2' } }, + deletePaletteColorPermanently: { params: { id: '1', colorId: '2' } }, + updateBrushNorm: { params: { id: '1', normId: '2' }, body: { name: 'L', value: '9' } }, + updateTypographyNorm: { + params: { id: '1', normId: '2' }, + body: { fontFamily: 'Figtree', fontWeight: '400', fontUsage: 'Body' }, + }, +}; + +describe('projects controller error paths (table-driven)', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe.each(Object.entries(AUTHENTICATED_HANDLERS))('%s', (name, baseReq) => { + it('rejects an unauthenticated request with 401 without touching the database', async () => { + const res = makeRes(); + await projectsController[name]({ ...baseReq }, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'User not authenticated.' }); + expect(db.query).not.toHaveBeenCalled(); + }); + + it('maps an unexpected database failure to a clean 500', async () => { + db.query.mockRejectedValue(new Error('connection lost')); + db.getConnection.mockRejectedValue(new Error('connection lost')); + const res = makeRes(); + await projectsController[name]({ ...baseReq, user: { id: 1 } }, res); + + expect(res.status).toHaveBeenCalledWith(500); + // Whatever the handler's wording, the raw driver error never leaks. + const payload = res.json.mock.calls.at(-1)[0]; + expect(payload.error).toMatch(/error/i); + expect(JSON.stringify(payload)).not.toContain('connection lost'); + }); + }); +}); diff --git a/backend/tests/unit/user.service.identity.test.js b/backend/tests/unit/user.service.identity.test.js new file mode 100644 index 0000000..25b40a6 --- /dev/null +++ b/backend/tests/unit/user.service.identity.test.js @@ -0,0 +1,91 @@ +/** + * verifyUserIdentity — the re-authentication gate in front of every critical + * account action (email change, deletion, 2FA changes, recovery-code + * rotation). Each branch here is a security decision, so each one gets a + * test: password accounts, Google-only accounts, and the fail-closed default. + */ +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test_jwt_secret'; +process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test_jwt_refresh_secret'; + +const bcrypt = require('bcryptjs'); +const { verifyUserIdentity } = require('../../src/services/user.service'); +const { verifyGoogleIdToken } = require('../../src/services/googleIdentity.service'); + +jest.mock('../../src/database'); +jest.mock('../../src/services/mail.service'); +jest.mock('../../src/services/googleIdentity.service'); + +describe('verifyUserIdentity', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('password accounts', () => { + let userDb; + beforeAll(async () => { + userDb = { password: await bcrypt.hash('Correct1', 4), google_id: null }; + }); + + it('passes with the correct current password', async () => { + await expect( + verifyUserIdentity(userDb, { currentPassword: 'Correct1' }), + ).resolves.toBeUndefined(); + }); + + it('demands the password when it is missing or blank', async () => { + await expect(verifyUserIdentity(userDb, {})).rejects.toMatchObject({ + code: 'reauth_required', + }); + await expect(verifyUserIdentity(userDb, { currentPassword: ' ' })).rejects.toMatchObject({ + code: 'reauth_required', + }); + // A non-string (e.g. an object smuggled through JSON) is refused too. + await expect( + verifyUserIdentity(userDb, { currentPassword: { $ne: '' } }), + ).rejects.toMatchObject({ code: 'reauth_required' }); + }); + + it('rejects a wrong password', async () => { + await expect(verifyUserIdentity(userDb, { currentPassword: 'Wrong1' })).rejects.toMatchObject( + { code: 'invalid_current_password' }, + ); + }); + }); + + describe('Google-only accounts', () => { + const userDb = { password: null, google_id: 'google-uid-1' }; + + it('passes when Google confirms the same linked identity', async () => { + verifyGoogleIdToken.mockResolvedValueOnce({ status: 'ok', googleId: 'google-uid-1' }); + await expect( + verifyUserIdentity(userDb, { googleCredential: 'fresh-id-token' }), + ).resolves.toBeUndefined(); + }); + + it('demands a credential when none is provided', async () => { + await expect(verifyUserIdentity(userDb, {})).rejects.toMatchObject({ + code: 'reauth_required', + }); + }); + + it('rejects a credential for a DIFFERENT Google account (no account swapping)', async () => { + verifyGoogleIdToken.mockResolvedValueOnce({ status: 'ok', googleId: 'someone-else' }); + await expect( + verifyUserIdentity(userDb, { googleCredential: 'other-token' }), + ).rejects.toMatchObject({ code: 'reauth_failed' }); + }); + + it('rejects when Google itself refuses the token', async () => { + verifyGoogleIdToken.mockResolvedValueOnce({ status: 'invalid' }); + await expect( + verifyUserIdentity(userDb, { googleCredential: 'bad-token' }), + ).rejects.toMatchObject({ code: 'reauth_failed' }); + }); + }); + + it('fails closed for an account with neither password nor Google identity', async () => { + await expect( + verifyUserIdentity({ password: null, google_id: null }, { currentPassword: 'x' }), + ).rejects.toMatchObject({ code: 'reauth_required' }); + }); +}); diff --git a/backend/tests/unit/utils.misc.test.js b/backend/tests/unit/utils.misc.test.js new file mode 100644 index 0000000..a95a6fe --- /dev/null +++ b/backend/tests/unit/utils.misc.test.js @@ -0,0 +1,100 @@ +/** + * Edge cases of the small shared utilities: the 429 JSON handler, the + * constant-time OTP comparison, log-safe fingerprints, malformed-cookie + * decoding, logger serialization, and the SSE hub's subscriber cap. + */ +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test_jwt_secret'; +process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test_jwt_refresh_secret'; + +const { jsonLimitHandler } = require('../../src/utils/rateLimitHandler'); +const { hashOtp, safeOtpEqual } = require('../../src/utils/otp'); +const { getIdentifierFingerprint } = require('../../src/utils/auth.utils'); +const { getCookieValue } = require('../../src/utils/cookies.utils'); +const { logger } = require('../../src/utils/logger'); + +describe('rateLimitHandler', () => { + it('answers 429 with the limiter message as JSON (never plain text)', () => { + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + jsonLimitHandler('Too many attempts, try again in 10 minutes.')({}, res); + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith({ + error: 'Too many attempts, try again in 10 minutes.', + }); + }); +}); + +describe('safeOtpEqual edges', () => { + it('rejects empty candidates and empty stored hashes without throwing', () => { + expect(safeOtpEqual('', hashOtp('123456'))).toBe(false); + expect(safeOtpEqual('123456', '')).toBe(false); + expect(safeOtpEqual(null, null)).toBe(false); + }); + + it('rejects a stored value whose length does not match a real hash', () => { + expect(safeOtpEqual('123456', 'not-a-sha256-hash')).toBe(false); + }); + + it('accepts the matching code', () => { + expect(safeOtpEqual('123456', hashOtp('123456'))).toBe(true); + }); +}); + +describe('getIdentifierFingerprint', () => { + it('returns null for empty-ish identifiers', () => { + expect(getIdentifierFingerprint('')).toBeNull(); + expect(getIdentifierFingerprint(' ')).toBeNull(); + expect(getIdentifierFingerprint(undefined)).toBeNull(); + }); + + it('is case/whitespace-insensitive and 12 hex chars long', () => { + const a = getIdentifierFingerprint(' Axelle@Example.com '); + expect(a).toBe(getIdentifierFingerprint('axelle@example.com')); + expect(a).toMatch(/^[0-9a-f]{12}$/); + }); +}); + +describe('getCookieValue malformed encodings', () => { + it('falls back to the raw value when percent-decoding fails', () => { + const req = { headers: { cookie: 'broken=abc%zzdef' } }; + expect(getCookieValue(req, 'broken')).toBe('abc%zzdef'); + }); + + it('returns an empty string for a valueless cookie', () => { + const req = { headers: { cookie: 'empty=' } }; + expect(getCookieValue(req, 'empty')).toBe(''); + }); +}); + +describe('logger metadata sanitization', () => { + let errorSpy; + let warnSpy; + beforeEach(() => { + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + const lastLogged = () => JSON.parse(errorSpy.mock.calls.at(-1)[0]); + + it('serializes Errors (with code) and stringifies BigInt metadata', () => { + const error = new Error('boom'); + error.code = 'ER_TEST'; + logger.error('test.event', { error, big: 10n, skipped: undefined, fn: () => {} }); + + const entry = lastLogged(); + expect(entry.error).toEqual( + expect.objectContaining({ name: 'Error', message: 'boom', code: 'ER_TEST' }), + ); + expect(entry.big).toBe('10'); + expect(entry).not.toHaveProperty('skipped'); + expect(entry).not.toHaveProperty('fn'); + }); + + it('passes non-object metadata through as an empty payload instead of crashing', () => { + expect(() => logger.warn('test.event', 'not-an-object')).not.toThrow(); + expect(() => logger.warn('test.event', ['array'])).not.toThrow(); + }); +}); diff --git a/frontend/tests/unit/useClipboard.test.jsx b/frontend/tests/unit/useClipboard.test.jsx new file mode 100644 index 0000000..763440d --- /dev/null +++ b/frontend/tests/unit/useClipboard.test.jsx @@ -0,0 +1,73 @@ +// The copy-with-feedback hook: async Clipboard API path, the execCommand +// fallback for non-secure contexts, failure reporting, and the auto-clearing +// "copied" feedback window. +import { renderHook, act } from '@testing-library/react'; +import useClipboard from '../../src/hooks/useClipboard'; + +describe('useClipboard', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('copies via the async Clipboard API and exposes the copied value', async () => { + const writeText = vi.fn().mockResolvedValue(); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const { result } = renderHook(() => useClipboard()); + + let ok; + await act(async () => { + ok = await result.current.copy('#112233'); + }); + + expect(ok).toBe(true); + expect(writeText).toHaveBeenCalledWith('#112233'); + expect(result.current.copiedValue).toBe('#112233'); + }); + + it('clears the copied feedback after the timeout', async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const { result } = renderHook(() => useClipboard({ timeout: 500 })); + + await act(async () => { + await result.current.copy('#112233'); + }); + expect(result.current.copiedValue).toBe('#112233'); + + act(() => { + vi.advanceTimersByTime(600); + }); + expect(result.current.copiedValue).toBeNull(); + }); + + it('falls back to the hidden-textarea execCommand path without the API', async () => { + vi.stubGlobal('navigator', {}); + document.execCommand = vi.fn().mockReturnValue(true); + const { result } = renderHook(() => useClipboard()); + + let ok; + await act(async () => { + ok = await result.current.copy('fallback'); + }); + + expect(ok).toBe(true); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(result.current.copiedValue).toBe('fallback'); + }); + + it('reports failure (and keeps no feedback) when the clipboard write throws', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const { result } = renderHook(() => useClipboard()); + + let ok; + await act(async () => { + ok = await result.current.copy('nope'); + }); + + expect(ok).toBe(false); + expect(result.current.copiedValue).toBeNull(); + }); +}); diff --git a/frontend/tests/unit/useNormActions.test.jsx b/frontend/tests/unit/useNormActions.test.jsx new file mode 100644 index 0000000..7b4658d --- /dev/null +++ b/frontend/tests/unit/useNormActions.test.jsx @@ -0,0 +1,199 @@ +// The shared CRUD engine behind brush AND typography standards: server paths +// (optimistic state patch from the API response), demo-simulated paths (local +// state only, negative ids), and the error paths that must roll into the +// global error banner instead of throwing. +import { renderHook, act } from '@testing-library/react'; +import useNormActions from '../../src/hooks/useNormActions'; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, +})); +vi.mock('../../src/services/api', () => ({ default: apiMock })); + +const baseProject = (over = {}) => ({ + id: 1, + name: 'P', + brushNorms: [{ id: 10, name: 'Line', value: '8' }], + normsCount: 1, + ...over, +}); + +const setup = ({ isDemo = false, projects = [baseProject()] } = {}) => { + let state = { projects, trashed: [] }; + const setProjects = vi.fn((updater) => { + state.projects = typeof updater === 'function' ? updater(state.projects) : updater; + }); + const setTrashedItems = vi.fn((updater) => { + state.trashed = typeof updater === 'function' ? updater(state.trashed) : updater; + }); + const setGlobalError = vi.fn(); + let demoId = 0; + const { result } = renderHook(() => + useNormActions({ + kind: 'BrushNorm', + fieldName: 'brushNorms', + apiSegment: 'brush-norms', + isDemo, + projects: state.projects, + setProjects, + setGlobalError, + nextDemoId: () => --demoId, + trashedItems: state.trashed, + setTrashedItems, + trashedItemsRef: { current: state.trashed }, + }), + ); + return { result, state, setGlobalError }; +}; + +describe('useNormActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('adds a norm with the server-assigned id and bumps normsCount', async () => { + apiMock.post.mockResolvedValueOnce({ id: 42 }); + const { result, state } = setup(); + + let returned; + await act(async () => { + returned = await result.current.addNorm(1, { name: 'Shade', value: '4' }); + }); + + expect(returned).toEqual({ id: 42, name: 'Shade', value: '4' }); + expect(apiMock.post).toHaveBeenCalledWith( + '/projects/1/brush-norms', + { name: 'Shade', value: '4' }, + expect.any(Object), + ); + expect(state.projects[0].brushNorms).toHaveLength(2); + expect(state.projects[0].normsCount).toBe(2); + }); + + it('in demo mode, adds locally with a negative id and never calls the API', async () => { + const { result, state } = setup({ isDemo: true }); + + let returned; + await act(async () => { + returned = await result.current.addNorm(1, { name: 'Shade', value: '4' }); + }); + + expect(returned.id).toBeLessThan(0); + expect(apiMock.post).not.toHaveBeenCalled(); + expect(state.projects[0].brushNorms).toHaveLength(2); + }); + + it('surfaces an add failure in the global banner and returns null', async () => { + apiMock.post.mockRejectedValueOnce(new Error('boom')); + const { result, state, setGlobalError } = setup(); + + let returned; + await act(async () => { + returned = await result.current.addNorm(1, { name: 'Shade', value: '4' }); + }); + + expect(returned).toBeNull(); + expect(setGlobalError).toHaveBeenCalledWith('boom'); + expect(state.projects[0].brushNorms).toHaveLength(1); // untouched + }); + + it('trashes a norm server-side, removes it locally and refreshes the trash silently', async () => { + apiMock.delete.mockResolvedValueOnce({ success: true }); + apiMock.get.mockResolvedValueOnce({ norms: [{ id: 10, daysLeft: 30 }] }); + const { result, state } = setup(); + + let ok; + await act(async () => { + ok = await result.current.deleteNorm(1, 10); + }); + + expect(ok).toBe(true); + expect(state.projects[0].brushNorms).toHaveLength(0); + expect(state.projects[0].normsCount).toBe(0); + expect(apiMock.get).toHaveBeenCalledWith('/projects/1/brush-norms/trash', undefined); + }); + + it('in demo mode, trashing moves the norm to a simulated local trash', async () => { + const { result, state } = setup({ isDemo: true }); + + await act(async () => { + await result.current.deleteNorm(1, 10); + }); + + expect(apiMock.delete).not.toHaveBeenCalled(); + expect(state.projects[0].brushNorms).toHaveLength(0); + expect(state.trashed[0]).toEqual(expect.objectContaining({ id: 10, daysLeft: 30 })); + }); + + it('reports a delete failure and keeps the norm in place', async () => { + apiMock.delete.mockRejectedValueOnce(new Error('down')); + const { result, state, setGlobalError } = setup(); + + let ok; + await act(async () => { + ok = await result.current.deleteNorm(1, 10); + }); + + expect(ok).toBe(false); + expect(setGlobalError).toHaveBeenCalledWith('down'); + expect(state.projects[0].brushNorms).toHaveLength(1); + }); + + it('updates a norm in place from the server response', async () => { + apiMock.put.mockResolvedValueOnce({ success: true }); + const { result, state } = setup(); + + let ok; + await act(async () => { + ok = await result.current.updateNorm(1, 10, { name: 'Line thick', value: '12' }); + }); + + expect(ok).toBe(true); + expect(state.projects[0].brushNorms[0]).toEqual( + expect.objectContaining({ id: 10, name: 'Line thick', value: '12' }), + ); + }); + + it('restores a trashed norm back into the list', async () => { + apiMock.post.mockResolvedValueOnce({ success: true }); + apiMock.get.mockResolvedValueOnce({ norms: [] }); + const { result, state } = setup({ + projects: [baseProject({ brushNorms: [], normsCount: 0 })], + }); + state.trashed = [{ id: 10, name: 'Line', value: '8', daysLeft: 12 }]; + + await act(async () => { + await result.current.restoreNorm(1, 10); + }); + + expect(apiMock.post).toHaveBeenCalledWith( + '/projects/1/brush-norms/10/restore', + {}, + expect.any(Object), + ); + }); + + it('reorders norms optimistically and calls the reorder endpoint', async () => { + apiMock.post.mockResolvedValueOnce({ success: true }); + const { result } = setup({ + projects: [ + baseProject({ + brushNorms: [ + { id: 10, name: 'A' }, + { id: 11, name: 'B' }, + ], + }), + ], + }); + + await act(async () => { + await result.current.reorderNorms(1, [11, 10]); + }); + + expect(apiMock.post).toHaveBeenCalledWith( + '/projects/1/brush-norms/reorder', + [11, 10], + expect.any(Object), + ); + }); +}); diff --git a/frontend/tests/unit/usePaletteActions.test.jsx b/frontend/tests/unit/usePaletteActions.test.jsx new file mode 100644 index 0000000..ff8822ba --- /dev/null +++ b/frontend/tests/unit/usePaletteActions.test.jsx @@ -0,0 +1,157 @@ +// The palette lifecycle hook: bulk replace adopting the server's canonical +// palette, demo-simulated replace, and the trash/restore/permanent-delete +// paths with their error handling. +import { renderHook, act } from '@testing-library/react'; +import usePaletteActions from '../../src/hooks/usePaletteActions'; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, +})); +vi.mock('../../src/services/api', () => ({ default: apiMock })); + +const baseProject = (over = {}) => ({ + id: 1, + name: 'P', + palette: [{ id: 10, name: 'Ink', hex: '#112233' }], + ...over, +}); + +const setup = ({ isDemo = false, projects = [baseProject()] } = {}) => { + const state = { projects, trashed: [] }; + const setProjects = vi.fn((updater) => { + state.projects = typeof updater === 'function' ? updater(state.projects) : updater; + }); + const setTrashedPaletteColors = vi.fn((updater) => { + state.trashed = typeof updater === 'function' ? updater(state.trashed) : updater; + }); + const setGlobalError = vi.fn(); + let demoId = 0; + const { result } = renderHook(() => + usePaletteActions({ + isDemo, + projects: state.projects, + setProjects, + setGlobalError, + nextDemoId: () => --demoId, + trashedPaletteColors: state.trashed, + setTrashedPaletteColors, + trashedPaletteColorsRef: { current: state.trashed }, + }), + ); + return { result, state, setGlobalError }; +}; + +describe('usePaletteActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('bulk-saves the palette and adopts the canonical server response', async () => { + apiMock.post.mockResolvedValueOnce({ + palette: [{ id: 20, name: 'Blush', hex: '#FCBFC4' }], + }); + const { result, state } = setup(); + + let saved; + await act(async () => { + saved = await result.current.updateProjectPalette(1, [{ name: 'Blush', hex: '#FCBFC4' }]); + }); + + expect(saved).toEqual([{ id: 20, name: 'Blush', hex: '#FCBFC4' }]); + expect(state.projects[0].palette).toEqual(saved); + }); + + it('in demo mode, assigns negative ids locally and never calls the API', async () => { + const { result, state } = setup({ isDemo: true }); + + let saved; + await act(async () => { + saved = await result.current.updateProjectPalette(1, [ + { id: 10, name: 'Ink', hex: '#112233' }, + { name: 'New', hex: '#AB6C69' }, + ]); + }); + + expect(apiMock.post).not.toHaveBeenCalled(); + expect(saved[0].id).toBe(10); // existing id kept + expect(saved[1].id).toBeLessThan(0); // new color simulated + expect(state.projects[0].palette).toHaveLength(2); + }); + + it('surfaces a save failure in the banner and returns null, leaving state intact', async () => { + apiMock.post.mockRejectedValueOnce(new Error('quota')); + const { result, state, setGlobalError } = setup(); + + let saved; + await act(async () => { + saved = await result.current.updateProjectPalette(1, []); + }); + + expect(saved).toBeNull(); + expect(setGlobalError).toHaveBeenCalledWith('quota'); + expect(state.projects[0].palette).toHaveLength(1); + }); + + it('trashes a single color and refreshes the trash silently', async () => { + apiMock.delete.mockResolvedValueOnce({ success: true }); + apiMock.get.mockResolvedValueOnce({ colors: [{ id: 10, daysLeft: 30 }] }); + const { result, state } = setup(); + + let ok; + await act(async () => { + ok = await result.current.deleteColor(1, 10); + }); + + expect(ok).toBe(true); + expect(state.projects[0].palette).toHaveLength(0); + }); + + it('in demo mode, trashing a color feeds the simulated local trash', async () => { + const { result, state } = setup({ isDemo: true }); + + await act(async () => { + await result.current.deleteColor(1, 10); + }); + + expect(apiMock.delete).not.toHaveBeenCalled(); + expect(state.projects[0].palette).toHaveLength(0); + expect(state.trashed[0]).toEqual(expect.objectContaining({ id: 10 })); + }); + + it('restores a trashed color through the restore endpoint', async () => { + apiMock.post.mockResolvedValueOnce({ success: true }); + apiMock.get.mockResolvedValueOnce({ colors: [] }); + const { result, state } = setup({ projects: [baseProject({ palette: [] })] }); + state.trashed = [{ id: 10, name: 'Ink', hex: '#112233', daysLeft: 8 }]; + + await act(async () => { + await result.current.restoreColor(1, 10); + }); + + expect(apiMock.post).toHaveBeenCalledWith( + expect.stringContaining('/projects/1/palette/10/restore'), + expect.anything(), + expect.any(Object), + ); + }); + + it('permanently deletes a trashed color and reports failures cleanly', async () => { + apiMock.delete.mockResolvedValueOnce({ success: true }); + const { result } = setup(); + await act(async () => { + await result.current.deleteColorPermanently(1, 10); + }); + expect(apiMock.delete).toHaveBeenCalledWith( + expect.stringContaining('/projects/1/palette/10/permanent'), + null, + expect.any(Object), + ); + + apiMock.delete.mockRejectedValueOnce(new Error('gone wrong')); + let ok; + await act(async () => { + ok = await result.current.deleteColorPermanently(1, 10); + }); + expect(ok).toBe(false); + }); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 16f67d9..a555836 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -93,10 +93,10 @@ export default defineConfig({ // is the format Codecov's upload action expects. reporter: ['text', 'lcov'], thresholds: { - statements: 80, - branches: 68, - functions: 80, - lines: 82, + statements: 82, + branches: 70, + functions: 83, + lines: 84, }, }, }, From a8699e0d7472692fde111b7853cbd6166214e6ca Mon Sep 17 00:00:00 2001 From: AxelleDev <139721736+AxelleDev@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:53:01 +0200 Subject: [PATCH 2/4] extended coverage to validators, profile flows and ui primitives --- backend/package.json | 6 +- .../unit/projects.service.validation.test.js | 128 ++++++++++++++ .../tests/unit/user.service.profile.test.js | 156 ++++++++++++++++++ frontend/tests/unit/CardModal.test.jsx | 112 +++++++++++++ frontend/tests/unit/friendlyError.test.js | 14 ++ frontend/tests/unit/logger.test.js | 30 ++++ frontend/tests/unit/normFields.test.jsx | 58 +++++++ frontend/vite.config.js | 8 +- 8 files changed, 505 insertions(+), 7 deletions(-) create mode 100644 backend/tests/unit/projects.service.validation.test.js create mode 100644 backend/tests/unit/user.service.profile.test.js create mode 100644 frontend/tests/unit/CardModal.test.jsx create mode 100644 frontend/tests/unit/logger.test.js create mode 100644 frontend/tests/unit/normFields.test.jsx diff --git a/backend/package.json b/backend/package.json index e8dbcb4..bd63aa3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,10 +35,10 @@ "jest": { "coverageThreshold": { "global": { - "statements": 89, - "branches": 74, + "statements": 90, + "branches": 76, "functions": 91, - "lines": 89 + "lines": 90 } } }, diff --git a/backend/tests/unit/projects.service.validation.test.js b/backend/tests/unit/projects.service.validation.test.js new file mode 100644 index 0000000..467a64a --- /dev/null +++ b/backend/tests/unit/projects.service.validation.test.js @@ -0,0 +1,128 @@ +/** + * The projects service's input validators — every rejection branch is a + * user-facing 400 and a DB-integrity guarantee (nothing over-long, no alpha + * hex channels the exporters can't handle, no forged ids). + */ +const projectsService = require('../../src/services/projects.service'); + +jest.mock('../../src/database'); + +describe('validateProjectName', () => { + it('trims and accepts a normal name', () => { + expect(projectsService.validateProjectName(' Alyse ')).toBe('Alyse'); + }); + + it('rejects a missing/blank name with missing_name', () => { + for (const bad of [undefined, null, '', ' ']) { + expect(() => projectsService.validateProjectName(bad)).toThrow( + expect.objectContaining({ code: 'missing_name' }), + ); + } + }); + + it('treats a non-string as missing, and rejects out-of-bounds lengths as invalid', () => { + expect(() => projectsService.validateProjectName(42)).toThrow( + expect.objectContaining({ code: 'missing_name' }), + ); + // 2-50 chars: a single char and 51 chars both fail. + expect(() => projectsService.validateProjectName('x')).toThrow( + expect.objectContaining({ code: 'invalid_name' }), + ); + expect(() => projectsService.validateProjectName('x'.repeat(51))).toThrow( + expect.objectContaining({ code: 'invalid_name' }), + ); + }); +}); + +describe('validatePalettePayload', () => { + it('normalizes valid colors (ids kept, names trimmed, hex preserved)', () => { + const validated = projectsService.validatePalettePayload([ + { id: 7, name: ' Ink ', hex: '#112233' }, + { name: '', hex: '#abc' }, + ]); + + expect(validated).toEqual([ + { id: 7, name: 'Ink', hex: '#112233' }, + { id: null, name: null, hex: '#abc' }, + ]); + }); + + it('rejects a non-array payload', () => { + expect(() => projectsService.validatePalettePayload({ hex: '#112233' })).toThrow( + /must be an array/, + ); + }); + + it('rejects a palette over the 50-color cap', () => { + const tooMany = Array.from({ length: 51 }, (_, i) => ({ + hex: `#${String(100000 + i).slice(0, 6)}`, + })); + expect(() => projectsService.validatePalettePayload(tooMany)).toThrow(/cannot exceed 50/); + }); + + it('rejects alpha-channel and malformed hex values', () => { + for (const hex of ['#11223344', '112233', '#11223g', 'red', null]) { + expect(() => projectsService.validatePalettePayload([{ hex }])).toThrow(/Invalid color/); + } + }); + + it('rejects forged color ids (non-integer or non-positive)', () => { + for (const id of ['abc', -1, 0, 1.5]) { + expect(() => projectsService.validatePalettePayload([{ id, hex: '#112233' }])).toThrow( + /Invalid color identifier/, + ); + } + }); + + it('rejects an over-long color usage', () => { + expect(() => + projectsService.validatePalettePayload([{ hex: '#112233', name: 'x'.repeat(256) }]), + ).toThrow(/color usage is invalid/); + }); +}); + +describe('addBrushNormToProject validation branches', () => { + const db = require('../../src/database'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const expectRejected = async (payload, pattern) => { + await expect(projectsService.addBrushNormToProject(1, payload)).rejects.toThrow(pattern); + expect(db.query).not.toHaveBeenCalled(); + }; + + it('rejects a missing or blank usage name', async () => { + await expectRejected({ value: '8' }, /brush usage/); + await expectRejected({ name: ' ', value: '8' }, /brush usage/); + }); + + it('rejects a non-positive, non-numeric or oversized size', async () => { + await expectRejected({ name: 'Line', value: '0' }, /positive number/); + await expectRejected({ name: 'Line', value: 'huge' }, /positive number/); + await expectRejected({ name: 'Line', value: '1001' }, /positive number/); + await expectRejected({ name: 'Line', value: {} }, /positive number/); + }); + + it('rejects a unit with digits or over 20 chars', async () => { + await expectRejected({ name: 'Line', value: '8', unit: 'px2' }, /unit is invalid/); + await expectRejected({ name: 'Line', value: '8', unit: 'a'.repeat(21) }, /unit is invalid/); + }); + + it('rejects an out-of-range or non-numeric opacity', async () => { + await expectRejected({ name: 'Line', value: '8', opacity: '1.5' }, /between 0 and 1/); + await expectRejected({ name: 'Line', value: '8', opacity: [] }, /between 0 and 1/); + }); + + it('accepts the minimal valid payload, defaulting the unit to px', async () => { + db.query + .mockResolvedValueOnce([{ insertId: 9 }]) // INSERT + .mockResolvedValueOnce([{}]); // last_edited touch + + const result = await projectsService.addBrushNormToProject(1, { name: 'Line', value: '8' }); + + expect(result).toEqual({ success: true, id: 9 }); + expect(db.query.mock.calls[0][1]).toEqual(expect.arrayContaining(['px'])); + }); +}); diff --git a/backend/tests/unit/user.service.profile.test.js b/backend/tests/unit/user.service.profile.test.js new file mode 100644 index 0000000..f57aad4 --- /dev/null +++ b/backend/tests/unit/user.service.profile.test.js @@ -0,0 +1,156 @@ +/** + * user.service profile flows: update validations, the staged pending-email + * confirmation (expiry, wrong code, duplicate race) and the password-change + * policy — each branch is a 4xx contract the frontend relies on. + */ +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test_jwt_secret'; +process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test_jwt_refresh_secret'; + +const bcrypt = require('bcryptjs'); +const userService = require('../../src/services/user.service'); +const db = require('../../src/database'); +const { hashOtp } = require('../../src/utils/otp'); + +jest.mock('../../src/database'); +jest.mock('../../src/services/mail.service'); +jest.mock('../../src/services/googleIdentity.service'); + +describe('updateUserProfile validations', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const expectValidation = async (payload, message) => { + await expect(userService.updateUserProfile(1, payload)).rejects.toMatchObject({ + code: 'validation', + message, + }); + expect(db.query).not.toHaveBeenCalled(); + }; + + it('requires both name and email', async () => { + await expectValidation({ name: '', email: 'a@b.com' }, 'All fields are required.'); + await expectValidation({ name: 'Axelle', email: '' }, 'All fields are required.'); + }); + + it('caps the name at 255 chars and validates the email format', async () => { + await expectValidation({ name: 'x'.repeat(256), email: 'a@b.com' }, 'Name is too long.'); + await expectValidation({ name: 'Axelle', email: 'not-an-email' }, 'Invalid email.'); + }); + + it('throws not_found for an unknown user', async () => { + db.query.mockResolvedValueOnce([[]]); + await expect( + userService.updateUserProfile(999, { name: 'Axelle', email: 'a@b.com' }), + ).rejects.toMatchObject({ code: 'not_found' }); + }); + + it('rejects an email change when the target email already belongs to someone', async () => { + const hashed = await bcrypt.hash('Correct1', 4); + db.query + .mockResolvedValueOnce([ + [{ email: 'old@b.com', pending_email: null, password: hashed, google_id: null }], + ]) + .mockResolvedValueOnce([[{ id: 2 }]]); // someone else owns new@b.com + + await expect( + userService.updateUserProfile(1, { + name: 'Axelle', + email: 'new@b.com', + currentPassword: 'Correct1', + }), + ).rejects.toMatchObject({ code: 'email_in_use' }); + }); +}); + +describe('confirmPendingEmail', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const pendingRow = (over = {}) => ({ + id: 1, + name: 'Axelle', + email: 'old@b.com', + pending_email: 'new@b.com', + pending_email_code: hashOtp('123456'), + pending_email_expires: new Date(Date.now() + 60_000), + avatar_initials: 'A', + password_updated_at: null, + ...over, + }); + + it('throws no_pending when nothing is staged for this email', async () => { + db.query.mockResolvedValueOnce([[]]); + await expect( + userService.confirmPendingEmail(1, { email: 'new@b.com', code: '123456' }), + ).rejects.toMatchObject({ code: 'no_pending' }); + }); + + it('throws code_expired past the expiry timestamp', async () => { + db.query.mockResolvedValueOnce([ + [pendingRow({ pending_email_expires: new Date(Date.now() - 1000) })], + ]); + await expect( + userService.confirmPendingEmail(1, { email: 'new@b.com', code: '123456' }), + ).rejects.toMatchObject({ code: 'code_expired' }); + }); + + it('throws invalid_code for a wrong code', async () => { + db.query.mockResolvedValueOnce([[pendingRow()]]); + await expect( + userService.confirmPendingEmail(1, { email: 'new@b.com', code: '654321' }), + ).rejects.toMatchObject({ code: 'invalid_code' }); + }); + + it('maps a duplicate-key race on commit to email_in_use', async () => { + const dup = new Error('dup'); + dup.code = 'ER_DUP_ENTRY'; + db.query.mockResolvedValueOnce([[pendingRow()]]).mockRejectedValueOnce(dup); + + await expect( + userService.confirmPendingEmail(1, { email: 'new@b.com', code: '123456' }), + ).rejects.toMatchObject({ code: 'email_in_use' }); + }); +}); + +describe('changeUserPassword policy', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const expectValidation = async (payload, pattern) => { + await expect(userService.changeUserPassword(1, payload)).rejects.toMatchObject({ + code: 'validation', + message: expect.stringMatching(pattern), + }); + }; + + it('requires both passwords as strings', async () => { + await expectValidation({ currentPassword: '', newPassword: 'N3wPassword!' }, /Required/); + await expectValidation({ currentPassword: 'x', newPassword: null }, /Required/); + await expectValidation({ currentPassword: { a: 1 }, newPassword: 'N3wPassword!' }, /Required/); + }); + + it('enforces minimum length and complexity on the new password', async () => { + await expectValidation({ currentPassword: 'Old1', newPassword: 'Ab1' }, /too short/i); + await expectValidation( + { currentPassword: 'Old1', newPassword: 'alllowercase1' }, + /uppercase|complexity|letter/i, + ); + }); + + it('rejects a wrong current password without touching the stored hash', async () => { + const hashed = await bcrypt.hash('Correct1', 4); + db.query.mockResolvedValueOnce([[{ email: 'a@b.com', password: hashed }]]); + + await expect( + userService.changeUserPassword(1, { + currentPassword: 'Wrong1', + newPassword: 'N3wPassword!', + }), + ).rejects.toMatchObject({ code: 'invalid_current_password' }); + // Only the SELECT ran — no UPDATE. + expect(db.query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/tests/unit/CardModal.test.jsx b/frontend/tests/unit/CardModal.test.jsx new file mode 100644 index 0000000..4f5e0f8 --- /dev/null +++ b/frontend/tests/unit/CardModal.test.jsx @@ -0,0 +1,112 @@ +// Card's keyboard-button affordances and Modal's close/focus contracts — +// the two primitives every surface and dialog in the app sit on. +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Card from '../../src/components/Card'; +import Modal from '../../src/components/Modal'; + +describe('Card', () => { + it('renders a plain surface without button semantics when not clickable', () => { + render(content); + const el = screen.getByText('content'); + expect(el).not.toHaveAttribute('role'); + expect(el).not.toHaveAttribute('tabindex'); + }); + + it('becomes a keyboard-operable button when it carries an onClick', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + + open me + , + ); + const card = screen.getByRole('button', { name: 'open me' }); + + card.focus(); + await user.keyboard('{Enter}'); + await user.keyboard(' '); + expect(onClick).toHaveBeenCalledTimes(2); + }); + + it('respects an explicit role instead of forcing button semantics', () => { + render( + {}} role="listitem"> + row + , + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); + +describe('Modal', () => { + it('renders nothing while closed', () => { + render( + {}} title="Hidden"> +

secret

+
, + ); + expect(screen.queryByText('secret')).not.toBeInTheDocument(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render( + +

body

+
, + ); + + await user.keyboard('{Escape}'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('closes via the header close button when shown', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render( + +

body

+
, + ); + + await user.click(screen.getByRole('button', { name: /close/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('exposes dialog semantics with the title as accessible name', () => { + render( + {}} title="Save your codes"> +

body

+
, + ); + expect(screen.getByRole('dialog', { name: 'Save your codes' })).toBeInTheDocument(); + }); + + it('restores focus to the opener when it closes', async () => { + const Wrapper = () => { + const [open, setOpen] = React.useState(false); + return ( + <> + + setOpen(false)} title="Dialog"> +

body

+
+ + ); + }; + const user = userEvent.setup(); + render(); + + const opener = screen.getByRole('button', { name: 'opener' }); + await user.click(opener); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(opener).toHaveFocus(); + }); +}); diff --git a/frontend/tests/unit/friendlyError.test.js b/frontend/tests/unit/friendlyError.test.js index db1d809..c60d5a4 100644 --- a/frontend/tests/unit/friendlyError.test.js +++ b/frontend/tests/unit/friendlyError.test.js @@ -25,4 +25,18 @@ describe('getFriendlyMessage', () => { /something went wrong/i, ); }); + + it('handles a non-string input defensively', () => { + expect(getFriendlyMessage({ weird: true })).toBe('Something went wrong.'); + }); + + it.each([ + ['404 Not Found', /unavailable/i], + ['401 Unauthorized', /sign in again/i], + ['403 Forbidden', /access denied/i], + ['Request timed out', /taking too long/i], + ['Not Found', /unavailable/i], + ])('maps "%s" to friendly wording', (input, expected) => { + expect(getFriendlyMessage(input)).toMatch(expected); + }); }); diff --git a/frontend/tests/unit/logger.test.js b/frontend/tests/unit/logger.test.js new file mode 100644 index 0000000..37f8a19 --- /dev/null +++ b/frontend/tests/unit/logger.test.js @@ -0,0 +1,30 @@ +// The frontend logger's whole contract: forwards to the matching console +// method in development, and every level exists so call sites never crash. +import logger from '../../src/utils/logger'; + +describe('frontend logger', () => { + it('exposes the four standard levels as functions', () => { + for (const level of ['debug', 'info', 'warn', 'error']) { + expect(typeof logger[level]).toBe('function'); + } + }); + + it('forwards to the matching console method (vitest runs in dev mode)', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + logger.warn('event.name', { detail: 1 }); + expect(spy).toHaveBeenCalledWith('event.name', { detail: 1 }); + spy.mockRestore(); + }); + + it('falls back to console.log for levels the console lacks', () => { + const original = console.debug; + // eslint-disable-next-line no-console + console.debug = undefined; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + logger.debug('fallback.event'); + expect(logSpy).toHaveBeenCalledWith('fallback.event'); + logSpy.mockRestore(); + // eslint-disable-next-line no-console + console.debug = original; + }); +}); diff --git a/frontend/tests/unit/normFields.test.jsx b/frontend/tests/unit/normFields.test.jsx new file mode 100644 index 0000000..dfad765 --- /dev/null +++ b/frontend/tests/unit/normFields.test.jsx @@ -0,0 +1,58 @@ +// The two standards form-field groups (shared by the add AND edit modals): +// every field wires to setField, and validation hints appear exactly when a +// non-empty value is invalid. +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import BrushNormFields from '../../src/components/BrushNormFields'; +import TypographyNormFields from '../../src/components/TypographyNormFields'; + +vi.mock('../../src/hooks/useGoogleFonts', () => ({ + default: () => ({ fonts: [{ family: 'Figtree', variants: ['400', '700'] }], loading: false }), +})); + +describe('BrushNormFields', () => { + const baseForm = { usage: '', name: '', value: '', unit: 'px', opacity: '' }; + + it('wires every field to setField with its key', async () => { + const user = userEvent.setup(); + const setField = vi.fn(); + render(); + + await user.type(screen.getByLabelText(/usage/i), 'L'); + expect(setField).toHaveBeenCalledWith('usage', 'L'); + + await user.type(screen.getByLabelText(/size/i), '8'); + expect(setField).toHaveBeenCalledWith('value', '8'); + }); + + it('shows the size validation hint only for a non-empty invalid value', () => { + const { rerender } = render( + , + ); + // Empty value: no error yet. + expect(screen.queryByText(/positive number/i)).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText(/positive number/i)).toBeInTheDocument(); + }); +}); + +describe('TypographyNormFields', () => { + const baseForm = { fontUsage: '', fontFamily: '', fontWeight: '400', fontStyle: '' }; + + it('renders the usage field wired to setField', async () => { + const user = userEvent.setup(); + const setField = vi.fn(); + render(); + + await user.type(screen.getByLabelText(/usage/i), 'T'); + expect(setField).toHaveBeenCalledWith('fontUsage', 'T'); + }); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index a555836..eb509df 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -93,10 +93,10 @@ export default defineConfig({ // is the format Codecov's upload action expects. reporter: ['text', 'lcov'], thresholds: { - statements: 82, - branches: 70, - functions: 83, - lines: 84, + statements: 82.5, + branches: 70.5, + functions: 84, + lines: 84.5, }, }, }, From 266dba1f5e71b432cacb0f7f4ed2c4603804b666 Mon Sep 17 00:00:00 2001 From: AxelleDev <139721736+AxelleDev@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:07:39 +0200 Subject: [PATCH 3/4] covered the profile flows, register failures and the modal focus trap --- frontend/tests/integration/Profile.test.jsx | 95 +++++++++++++++++++- frontend/tests/integration/Register.test.jsx | 33 +++++++ frontend/tests/unit/CardModal.test.jsx | 22 +++++ frontend/vite.config.js | 8 +- 4 files changed, 153 insertions(+), 5 deletions(-) diff --git a/frontend/tests/integration/Profile.test.jsx b/frontend/tests/integration/Profile.test.jsx index 9420cb8..de3d880 100644 --- a/frontend/tests/integration/Profile.test.jsx +++ b/frontend/tests/integration/Profile.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, within, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; @@ -346,3 +346,96 @@ describe('Profile (demo account)', () => { expect(await screen.findByText(/you'll need to sign in again/i)).toBeInTheDocument(); }); }); + +describe('Profile — password change', () => { + beforeEach(() => { + mockNavigate.mockReset(); + Object.assign(authState, { + user: { + name: 'Jane Doe', + email: 'axelle@example.com', + avatarInitials: 'JD', + passwordUpdatedAt: null, + totpEnabled: false, + }, + updateUserProfile: vi.fn().mockResolvedValue({ success: true }), + logout: vi.fn().mockResolvedValue(), + changePassword: vi.fn().mockResolvedValue({ success: true }), + deleteAccount: vi.fn(), + setupTotp: vi.fn(), + confirmTotpSetup: vi.fn(), + disableTotp: vi.fn(), + }); + }); + + const openModal = async (user) => { + await user.click(screen.getByRole('button', { name: /change password/i })); + return screen.getByRole('dialog', { name: /change password/i }); + }; + + it('validates locally: empty fields, then mismatched confirmation', async () => { + const user = userEvent.setup(); + renderPage(); + const dialog = await openModal(user); + + await user.click(within(dialog).getByRole('button', { name: /^save$/i })); + expect(within(dialog).getByText(/fill in all fields/i)).toBeInTheDocument(); + expect(authState.changePassword).not.toHaveBeenCalled(); + + await user.type(within(dialog).getByLabelText('Current password'), 'Old1234!'); + await user.type(within(dialog).getByLabelText('New password'), 'NewPass1!'); + await user.type(within(dialog).getByLabelText('Confirm new password'), 'Different1!'); + await user.click(within(dialog).getByRole('button', { name: /^save$/i })); + expect(within(dialog).getByText(/don't match/i)).toBeInTheDocument(); + expect(authState.changePassword).not.toHaveBeenCalled(); + }); + + it('keeps the modal open with the inline business error on failure', async () => { + authState.changePassword = vi.fn().mockResolvedValue({ + success: false, + message: 'Current password is incorrect.', + }); + const user = userEvent.setup(); + renderPage(); + const dialog = await openModal(user); + + await user.type(within(dialog).getByLabelText('Current password'), 'Wrong1!'); + await user.type(within(dialog).getByLabelText('New password'), 'NewPass1!'); + await user.type(within(dialog).getByLabelText('Confirm new password'), 'NewPass1!'); + await user.click(within(dialog).getByRole('button', { name: /^save$/i })); + + expect(await within(dialog).findByText(/incorrect/i)).toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: /change password/i })).toBeInTheDocument(); + }); + + it('closes the modal and resets the form after a successful change', async () => { + const user = userEvent.setup(); + renderPage(); + const dialog = await openModal(user); + + await user.type(within(dialog).getByLabelText('Current password'), 'Old1234!'); + await user.type(within(dialog).getByLabelText('New password'), 'NewPass1!'); + await user.type(within(dialog).getByLabelText('Confirm new password'), 'NewPass1!'); + await user.click(within(dialog).getByRole('button', { name: /^save$/i })); + + await waitFor(() => + expect(screen.queryByRole('dialog', { name: /change password/i })).not.toBeInTheDocument(), + ); + expect(authState.changePassword).toHaveBeenCalledWith({ + currentPassword: 'Old1234!', + newPassword: 'NewPass1!', + }); + }); + + it('signs out after confirming, and navigates to the login page', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(screen.getByRole('button', { name: /sign out/i })); + const dialog = screen.getByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: /sign out/i })); + + await waitFor(() => expect(authState.logout).toHaveBeenCalledTimes(1)); + expect(mockNavigate).toHaveBeenCalledWith('/login'); + }); +}); diff --git a/frontend/tests/integration/Register.test.jsx b/frontend/tests/integration/Register.test.jsx index a26cfa3..4ec0191 100644 --- a/frontend/tests/integration/Register.test.jsx +++ b/frontend/tests/integration/Register.test.jsx @@ -133,3 +133,36 @@ describe('Register', () => { expect(screen.getByPlaceholderText('Your password')).toHaveValue('Pass1234'); }); }); + +describe('Register — failure and Google branches', () => { + beforeEach(() => { + mockNavigate.mockReset(); + mockRegister.mockReset(); + mockLoginAsDemo.mockReset(); + }); + + const fillBothSteps = async (user) => { + await user.type(screen.getByLabelText(/username/i), 'AxelleDev'); + await user.type(screen.getByPlaceholderText(/email@example.com/i), 'axelle@example.com'); + await user.click(screen.getByRole('button', { name: /continue/i })); + await user.type(screen.getByPlaceholderText('Your password'), 'Pass1234'); + await user.type(screen.getByPlaceholderText(/confirm your password/i), 'Pass1234'); + }; + + it('shows the API failure message at step 2 and stays there', async () => { + const user = userEvent.setup(); + mockRegister.mockResolvedValue({ + success: false, + message: 'Too many attempts, try again in 10 minutes.', + retryAfterSeconds: 600, + }); + renderPage(); + + await fillBothSteps(user); + await user.click(screen.getByRole('button', { name: /create account/i })); + + expect(await screen.findByText(/too many attempts/i)).toBeInTheDocument(); + expect(screen.getByText(/step 2 of 2/i)).toBeInTheDocument(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/unit/CardModal.test.jsx b/frontend/tests/unit/CardModal.test.jsx index 4f5e0f8..9c799b3 100644 --- a/frontend/tests/unit/CardModal.test.jsx +++ b/frontend/tests/unit/CardModal.test.jsx @@ -110,3 +110,25 @@ describe('Modal', () => { expect(opener).toHaveFocus(); }); }); + +describe('Modal focus trap', () => { + it('wraps Tab from the last focusable back to the first, and Shift+Tab the other way', async () => { + const user = userEvent.setup(); + render( + {}} title="Trap" showClose={false}> + + + , + ); + const first = screen.getByRole('button', { name: 'first' }); + const last = screen.getByRole('button', { name: 'last' }); + + last.focus(); + await user.tab(); + expect(first).toHaveFocus(); + + first.focus(); + await user.tab({ shift: true }); + expect(last).toHaveFocus(); + }); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index eb509df..4281c84 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -93,10 +93,10 @@ export default defineConfig({ // is the format Codecov's upload action expects. reporter: ['text', 'lcov'], thresholds: { - statements: 82.5, - branches: 70.5, - functions: 84, - lines: 84.5, + statements: 83.5, + branches: 71.5, + functions: 84.5, + lines: 85.5, }, }, }, From 63238ebb9dfb56225444fa37422e08bda8801d29 Mon Sep 17 00:00:00 2001 From: AxelleDev <139721736+AxelleDev@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:15:27 +0200 Subject: [PATCH 4/4] fixed a race in the shared page sse tests --- frontend/tests/integration/SharedProject.test.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/tests/integration/SharedProject.test.jsx b/frontend/tests/integration/SharedProject.test.jsx index bf06383..6609d15 100644 --- a/frontend/tests/integration/SharedProject.test.jsx +++ b/frontend/tests/integration/SharedProject.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, act } from '@testing-library/react'; +import { render, screen, act, waitFor } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; import SharedProject from '../../src/pages/SharedProject'; @@ -129,7 +129,7 @@ describe('SharedProject (public page)', () => { expect(await screen.findByText('Ink')).toBeInTheDocument(); // The stream targets the share events endpoint for this token. - expect(sources).toHaveLength(1); + await waitFor(() => expect(sources).toHaveLength(1)); expect(sources[0].url).toContain(`/share/${'a'.repeat(32)}/events`); await act(async () => sources[0].emitOpen()); @@ -152,6 +152,7 @@ describe('SharedProject (public page)', () => { renderPage(); expect(await screen.findByText('Ink')).toBeInTheDocument(); + await waitFor(() => expect(sources).toHaveLength(1)); const notFound = new Error('gone'); notFound.status = 404; apiMock.get.mockRejectedValueOnce(notFound);