From 7a4e07d04b243b025dcd0a43408d9938f394e449 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 14 Apr 2026 07:12:30 +0200 Subject: [PATCH 1/2] fix(avatar): snap size to backend sharp whitelist (#3972) --- .../core/components/user.avatar.component.vue | 15 ++++++++++- .../tests/user.avatar.component.unit.tests.js | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/modules/core/components/user.avatar.component.vue b/src/modules/core/components/user.avatar.component.vue index 3c3d14f36..c79dcbd01 100644 --- a/src/modules/core/components/user.avatar.component.vue +++ b/src/modules/core/components/user.avatar.component.vue @@ -12,7 +12,7 @@ > @@ -31,6 +31,15 @@ * Color is derived from email hash for consistency. */ +/** + * Allowed avatar sizes served by the backend `/api/uploads/images/` endpoint. + * Mirrors the sharp-resize whitelist in devkit Node + * `modules/uploads/config/uploads.{env}.config.js` (field `uploads.avatar.sharp.sizes`). + * Any value outside this list returns HTTP 422 "Wrong size param" — if you + * change one, update the other. + */ +const SHARP_SIZES = [128, 256, 512, 1024]; + /** * Determine whether a hex color is light (needs dark text for contrast). * Uses relative luminance per WCAG 2.x. @@ -80,6 +89,10 @@ export default { hasAvatar() { return !!(this.user && this.user.avatar && this.user.avatar !== ''); }, + avatarSize() { + const requested = this.size * 2; + return SHARP_SIZES.find((s) => s >= requested) ?? SHARP_SIZES[SHARP_SIZES.length - 1]; + }, initials() { if (!this.user) return ''; const f = (this.user.firstName || '').charAt(0).toUpperCase(); diff --git a/src/modules/core/tests/user.avatar.component.unit.tests.js b/src/modules/core/tests/user.avatar.component.unit.tests.js index 0dd80fe51..216def7c0 100644 --- a/src/modules/core/tests/user.avatar.component.unit.tests.js +++ b/src/modules/core/tests/user.avatar.component.unit.tests.js @@ -86,6 +86,33 @@ describe('user.avatar.component', () => { }); }); + describe('avatarSize computed', () => { + it('snaps size=24 (requested 48) to 128', () => { + const wrapper = createWrapper({ user: { email: 'a@b.com' }, size: 24 }); + expect(wrapper.vm.avatarSize).toBe(128); + }); + + it('snaps size=64 (requested 128) to 128', () => { + const wrapper = createWrapper({ user: { email: 'a@b.com' }, size: 64 }); + expect(wrapper.vm.avatarSize).toBe(128); + }); + + it('snaps size=200 (requested 400) to 512', () => { + const wrapper = createWrapper({ user: { email: 'a@b.com' }, size: 200 }); + expect(wrapper.vm.avatarSize).toBe(512); + }); + + it('snaps size=512 (requested 1024) to 1024', () => { + const wrapper = createWrapper({ user: { email: 'a@b.com' }, size: 512 }); + expect(wrapper.vm.avatarSize).toBe(1024); + }); + + it('caps size=600 (requested 1200) to 1024', () => { + const wrapper = createWrapper({ user: { email: 'a@b.com' }, size: 600 }); + expect(wrapper.vm.avatarSize).toBe(1024); + }); + }); + describe('hasAvatar computed', () => { it('returns true when user has avatar', () => { const wrapper = createWrapper({ user: { avatar: 'photo.jpg', email: 'a@b.com' } }); From 92a8abb509ab06a6d3818707bb59778069bcbf00 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 14 Apr 2026 07:24:21 +0200 Subject: [PATCH 2/2] fix(avatar): add avatarSize JSDoc and setImages integration test - add JSDoc header on avatarSize computed (Copilot review) - add integration test asserting setImages receives snapped size (CodeRabbit nitpick) --- .../core/components/user.avatar.component.vue | 6 ++++++ .../tests/user.avatar.component.unit.tests.js | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/modules/core/components/user.avatar.component.vue b/src/modules/core/components/user.avatar.component.vue index c79dcbd01..8342874af 100644 --- a/src/modules/core/components/user.avatar.component.vue +++ b/src/modules/core/components/user.avatar.component.vue @@ -89,6 +89,12 @@ export default { hasAvatar() { return !!(this.user && this.user.avatar && this.user.avatar !== ''); }, + /** + * Snap the requested avatar size (doubled for retina) to the backend + * sharp whitelist. Any value outside `SHARP_SIZES` would return HTTP 422, + * so we pick the smallest allowed size that fits — or cap at the largest. + * @returns {number} A size from `SHARP_SIZES` (128, 256, 512, or 1024). + */ avatarSize() { const requested = this.size * 2; return SHARP_SIZES.find((s) => s >= requested) ?? SHARP_SIZES[SHARP_SIZES.length - 1]; diff --git a/src/modules/core/tests/user.avatar.component.unit.tests.js b/src/modules/core/tests/user.avatar.component.unit.tests.js index 216def7c0..f7fce23c5 100644 --- a/src/modules/core/tests/user.avatar.component.unit.tests.js +++ b/src/modules/core/tests/user.avatar.component.unit.tests.js @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'; import { createVuetify } from 'vuetify'; import * as components from 'vuetify/components'; import * as directives from 'vuetify/directives'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import UserAvatarComponent from '../components/user.avatar.component.vue'; const mockConfig = { @@ -111,6 +111,20 @@ describe('user.avatar.component', () => { const wrapper = createWrapper({ user: { email: 'a@b.com' }, size: 600 }); expect(wrapper.vm.avatarSize).toBe(1024); }); + + it('uses snapped size when building avatar image URL', () => { + const setImages = vi.fn((api, file, size) => `${api.protocol}://${api.host}:${api.port}/api/uploads/images/${file}-${size}`); + const wrapper = mount(UserAvatarComponent, { + props: { user: { email: 'a@b.com', avatar: 'photo.jpg' }, size: 200 }, + global: { + plugins: [vuetify], + config: { globalProperties: { config: mockConfig, setImages } }, + }, + }); + + expect(wrapper.vm.avatarSize).toBe(512); + expect(setImages).toHaveBeenCalledWith(mockConfig.api, 'photo.jpg', 512, null); + }); }); describe('hasAvatar computed', () => {