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