From aa6ccbefed5af52cceb78c5bb8d630419e54bd6b Mon Sep 17 00:00:00 2001 From: senke Date: Sun, 26 Apr 2026 00:56:44 +0200 Subject: [PATCH] =?UTF-8?q?refactor(web):=20migrate=20queue.ts=20+=20finis?= =?UTF-8?q?h=20authService=20=E2=86=92=20orval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the v1.0.8 deferrals on the frontend side now that the backend swaggo annotations + orval regen landed in the previous commit. queue.ts (services/api/queue.ts, 11 functions): - getQueue / updateQueue / addToQueue / removeFromQueue / clearQueue → orval (getQueue / putQueue / postQueueItems / deleteQueueItemsId / deleteQueue). - createQueueSession / getQueueSession / deleteQueueSession / addToSessionQueue / removeFromSessionQueue → orval (postQueueSession / getQueueSessionToken / deleteQueueSessionToken / postQueueSessionTokenItems / deleteQueueSessionTokenItemsId). Public surface (queueApi.{...} object) preserved verbatim — no changes to the two consumers (useQueueSync.ts, PlayerQueue.tsx). An unwrapPayload() helper strips the APIResponse {data: ...} envelope, mirroring the B4 / B5 / B6 patterns. mapQueueItemToTrack conversion logic kept identical. authService.ts (5/9 deferred functions migrated, total 9/9 now): - register → postAuthRegister + rename `password_confirm` → `password_confirmation` (backend DTO field, see register_request.go:8). Frontend RegisterFormData keeps its existing field name; the rename happens at the wire boundary. - refreshToken → postAuthRefresh + rename `refreshToken` → `refresh_token`. - requestPasswordReset → postAuthPasswordResetRequest. Wire shape `{email}` matches the frontend ForgotPasswordFormData 1:1. - resetPassword → postAuthPasswordReset + rename `password` → `new_password` (backend DTO ResetPasswordRequest). `confirmPassword` from the form is dropped — the backend only validates the new password against the strength policy; the equality check is client-side responsibility (the form does it). - verifyEmail → postAuthVerifyEmail. Verb shift GET → POST to match the backend route registration (routes_auth.go:107) and the swaggo annotation on auth.go:VerifyEmail. Token still passed as `?token=` query param. The wire-shape renames pre-existed as drift between the frontend serializer and the Go DTO field tags; the backend likely tolerated some via lenient unmarshaling or the affected paths were rarely exercised end-to-end before E2E CI lands. Migration to orval forces the correct shape because the typed body is the source of truth. authService.ts docblock rewritten to inventory the wire-shape mappings instead of the prior "deferred" warning. Callers (LoginPage / RegisterPage / ResetPasswordPage / etc.) untouched — service signatures unchanged. authService.test.ts: - orval module mocks added for postAuthRegister / postAuthRefresh / postAuthPasswordResetRequest / postAuthPasswordReset / postAuthVerifyEmail (delegate to apiClient mock, same pattern as the 4 already migrated in v1.0.8 B6). - Wire-shape assertions updated for register (`password_confirmation`), refreshToken (`refresh_token`), resetPassword (`new_password`), verifyEmail (POST instead of GET). Comments cite the backend DTO line where the field name lives. Tests: 17/17 in authService.test.ts green. 708/709 across features/auth + features/player + services/__tests__ (1 skipped is the long-standing ResetPasswordPage flake unrelated to this work). npm run typecheck clean. Bisectable: revert this commit → queue / auth functions return to raw apiClient pattern (with the pre-existing wire drift). Combined with the previous commit (backend annotations) gives a clean two-step migration narrative. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../auth/services/authService.test.ts | 62 +++++++-- .../src/features/auth/services/authService.ts | 69 +++++----- apps/web/src/services/api/queue.ts | 120 ++++++++++++------ 3 files changed, 171 insertions(+), 80 deletions(-) diff --git a/apps/web/src/features/auth/services/authService.test.ts b/apps/web/src/features/auth/services/authService.test.ts index f0ef30f45..674bee1e7 100644 --- a/apps/web/src/features/auth/services/authService.test.ts +++ b/apps/web/src/features/auth/services/authService.test.ts @@ -33,11 +33,12 @@ const mockedApiClient = apiClient as { get: ReturnType; }; -// v1.0.8 B6 — login / logout / resendVerificationEmail / -// checkUsernameAvailability migrated to orval. Tests still mock -// `apiClient.post/get` for legacy ergonomics; the orval module's -// functions are stubbed to delegate back to those mocks so existing -// `toHaveBeenCalledWith('/auth/...', ...)` assertions keep working. +// v1.0.8 (post-tag, queue+auth annotation session) — full authService +// migration to orval. Tests still mock `apiClient.post/get` for legacy +// ergonomics; the orval module's functions are stubbed to delegate +// back to those mocks so existing +// `toHaveBeenCalledWith('/auth/...', ...)` assertions keep working, +// modulo the wire-shape renames documented in authService.ts. vi.mock('@/services/generated/auth/auth', () => ({ postAuthLogin: vi.fn(async (body: unknown) => mockedApiClient.post('/auth/login', body).then((r: { data?: unknown }) => r?.data), @@ -45,6 +46,31 @@ vi.mock('@/services/generated/auth/auth', () => ({ postAuthLogout: vi.fn(async () => mockedApiClient.post('/auth/logout').then((r: { data?: unknown }) => r?.data), ), + postAuthRegister: vi.fn(async (body: unknown) => + mockedApiClient + .post('/auth/register', body) + .then((r: { data?: unknown }) => r?.data), + ), + postAuthRefresh: vi.fn(async (body: unknown) => + mockedApiClient + .post('/auth/refresh', body) + .then((r: { data?: unknown }) => r?.data), + ), + postAuthPasswordResetRequest: vi.fn(async (body: unknown) => + mockedApiClient + .post('/auth/password/reset-request', body) + .then((r: { data?: unknown }) => r?.data), + ), + postAuthPasswordReset: vi.fn(async (body: unknown) => + mockedApiClient + .post('/auth/password/reset', body) + .then((r: { data?: unknown }) => r?.data), + ), + postAuthVerifyEmail: vi.fn(async (params: { token: string }) => + mockedApiClient + .post(`/auth/verify-email?token=${params.token}`) + .then((r: { data?: unknown }) => r?.data), + ), postAuthResendVerification: vi.fn(async (body: unknown) => mockedApiClient .post('/auth/resend-verification', body) @@ -150,10 +176,14 @@ describe('authService', () => { }); expect(result).toEqual(mockResponse); + // Note: frontend RegisterFormData uses `password_confirm`; the + // service maps this to `password_confirmation` (backend DTO field + // name from register_request.go:8) before sending. This assertion + // checks the WIRE shape, not the form-data shape. expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/register', { email: 'newuser@example.com', password: 'password123', - password_confirm: 'password123', + password_confirmation: 'password123', username: 'newuser', }); }); @@ -220,8 +250,10 @@ describe('authService', () => { const result = await refreshToken('refresh-token-123'); expect(result).toEqual(mockResponse); + // Wire shape: backend DTO uses snake_case `refresh_token`, + // not the camelCase `refreshToken` passed as the JS arg. expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/refresh', { - refreshToken: 'refresh-token-123', + refresh_token: 'refresh-token-123', }); }); @@ -281,11 +313,16 @@ describe('authService', () => { confirmPassword: 'newpassword123', }); + // Wire shape: backend DTO field is `new_password` (cf. handlers/ + // password_reset_handler.go:ResetPasswordRequest); the frontend + // form-data type uses `password`. confirmPassword is dropped — + // backend only validates that the new password meets the strength + // policy, the equality check happens client-side in the form. expect(mockedApiClient.post).toHaveBeenCalledWith( '/auth/password/reset', { token: 'reset-token-123', - password: 'newpassword123', + new_password: 'newpassword123', }, ); }); @@ -311,11 +348,14 @@ describe('authService', () => { describe('verifyEmail', () => { it('should successfully verify email', async () => { - mockedApiClient.get.mockResolvedValue({ data: {} }); + // Verb shift: was GET, backend route is POST (auth.go:VerifyEmail + // / routes_auth.go:107). Token still passed as query param per + // the swaggo annotation. + mockedApiClient.post.mockResolvedValue({ data: {} }); await verifyEmail('verification-token-123'); - expect(mockedApiClient.get).toHaveBeenCalledWith( + expect(mockedApiClient.post).toHaveBeenCalledWith( '/auth/verify-email?token=verification-token-123', ); }); @@ -327,7 +367,7 @@ describe('authService', () => { data: { error: 'Invalid or expired token' }, } as any; - mockedApiClient.get.mockRejectedValue(mockError); + mockedApiClient.post.mockRejectedValue(mockError); await expect(verifyEmail('invalid-token')).rejects.toThrow(); }); diff --git a/apps/web/src/features/auth/services/authService.ts b/apps/web/src/features/auth/services/authService.ts index c16bdbf6d..ee9c376e3 100644 --- a/apps/web/src/features/auth/services/authService.ts +++ b/apps/web/src/features/auth/services/authService.ts @@ -1,30 +1,38 @@ /** - * Auth service — orval-backed (partial v1.0.8 B6). + * Auth service — orval-backed (full migration post-v1.0.8 B6/B6.bis). * - * Migrated to orval generated client: - * login, logout, resendVerificationEmail, checkUsernameAvailability. + * All 9 functions now route through the orval-generated client in + * services/generated/auth/auth.ts. Backend DTOs are the source of truth + * for the wire shape; this service maps from the frontend form-data + * type names (camelCase / French-named) to the snake_case JSON field + * names the Go DTOs expect. * - * Still on raw apiClient (deferred v1.0.9 — backend annotation gaps or - * field-name drift would risk breaking auth): - * - register → backend DTO says `password_confirmation` while the - * frontend currently sends `password_confirm` - * (register_request.go:8). Renaming via orval would - * change wire shape; needs explicit verification on - * prod first. - * - refreshToken → same pattern, backend expects `refresh_token` but - * current frontend sends `refreshToken`. - * - requestPasswordReset / resetPassword → endpoints not yet annotated - * in swaggo (no /auth/password/* in openapi.yaml). - * - verifyEmail → frontend uses GET /auth/verify-email?token= but - * orval-generated spec says POST. Verb drift + the - * query→header migration parked in FUNCTIONAL_AUDIT - * §4#7 should land together in v1.0.9. + * Wire-shape mappings worth knowing (drifts that pre-existed): + * - register: `password_confirm` (frontend) → `password_confirmation` + * (backend `register_request.go:8`) + * - refreshToken: `refreshToken` (frontend arg) → `refresh_token` (DTO) + * - resetPassword: `password` (frontend `ResetPasswordFormData`) → + * `new_password` (backend `ResetPasswordRequest`) + * - verifyEmail: verb shift GET → POST (token still passed as + * `?token=` query param; this matches the route + * registered at `routes_auth.go:107` and the swaggo + * annotation on `auth.go:VerifyEmail`). + * + * The previous code sending `password_confirm` / `refreshToken` / + * `password` was likely silently broken or fell through a backend + * tolerance path — the new shape is what the swaggo-emitted + * `openapi.yaml` declares, and what `c.ShouldBindJSON` of the + * corresponding DTOs actually requires. */ -import { apiClient } from '@/services/api/client'; import { postAuthLogin, postAuthLogout, + postAuthRefresh, + postAuthRegister, postAuthResendVerification, + postAuthPasswordReset, + postAuthPasswordResetRequest, + postAuthVerifyEmail, getAuthCheckUsername, } from '@/services/generated/auth/auth'; import { handleApiServiceError } from '@/utils/serviceErrorHandler'; @@ -84,13 +92,13 @@ export async function login(data: LoginFormData): Promise { // INT-API-003: Standardized error handling using handleApiServiceError export async function register(data: RegisterFormData): Promise { try { - const response = await apiClient.post('/auth/register', { + const response = await postAuthRegister({ email: data.email, password: data.password, - password_confirm: data.password_confirm, // Envoyer la confirmation du mot de passe + password_confirmation: data.password_confirm, username: data.username, }); - return response.data; + return response as unknown as AuthResponse; } catch (error) { handleApiServiceError(error, { context: 'auth', @@ -128,10 +136,8 @@ export async function refreshToken( refreshToken: string, ): Promise { try { - const response = await apiClient.post('/auth/refresh', { - refreshToken, - }); - return response.data; + const response = await postAuthRefresh({ refresh_token: refreshToken }); + return response as unknown as AuthResponse; } catch (error) { handleApiServiceError(error, { context: 'auth' }); } @@ -147,7 +153,7 @@ export async function requestPasswordReset( data: ForgotPasswordFormData, ): Promise { try { - await apiClient.post('/auth/password/reset-request', data); + await postAuthPasswordResetRequest({ email: data.email }); } catch (error) { handleApiServiceError(error, { context: 'auth' }); } @@ -163,9 +169,9 @@ export async function resetPassword( data: ResetPasswordFormData, ): Promise { try { - await apiClient.post('/auth/password/reset', { + await postAuthPasswordReset({ token: data.token, - password: data.password, + new_password: data.password, }); } catch (error) { handleApiServiceError(error, { context: 'auth' }); @@ -178,9 +184,12 @@ export async function resetPassword( * @throws ApiError en cas d'erreur */ // INT-API-003: Standardized error handling using handleApiServiceError +// Note: backend route is POST (`auth.go:VerifyEmail` / +// `routes_auth.go:107`), token still passed as `?token=` query param. +// orval handles both — we only adjust the verb client-side. export async function verifyEmail(token: string): Promise { try { - await apiClient.get(`/auth/verify-email?token=${token}`); + await postAuthVerifyEmail({ token }); } catch (error) { handleApiServiceError(error, { context: 'auth' }); } diff --git a/apps/web/src/services/api/queue.ts b/apps/web/src/services/api/queue.ts index 72a663c6d..d87b8ad20 100644 --- a/apps/web/src/services/api/queue.ts +++ b/apps/web/src/services/api/queue.ts @@ -1,9 +1,28 @@ /** - * Queue API Service - * v0.102: Persistent user playback queue — sync with backend + * Queue API Service — orval-backed (v1.0.8 post-tag, queue annotation session). + * v0.102: persistent user playback queue + collaborative session sharing + * (v0.203 Lot D1). + * + * Public surface (queueApi object) preserved verbatim — useQueueSync, + * PlayerQueue, and any other consumer keeps importing + * `queueApi.{getQueue, addToQueue, ...}` without change. The body of + * each method now delegates to the orval-generated functions in + * services/generated/queue/queue.ts (annotated swaggo backend landed + * concurrently). */ -import { apiClient } from './client'; +import { + getQueue as orvalGetQueue, + putQueue as orvalUpdateQueue, + postQueueItems as orvalAddItem, + deleteQueueItemsId as orvalRemoveItem, + deleteQueue as orvalClearQueue, + postQueueSession as orvalCreateSession, + getQueueSessionToken as orvalGetSession, + deleteQueueSessionToken as orvalDeleteSession, + postQueueSessionTokenItems as orvalAddToSession, + deleteQueueSessionTokenItemsId as orvalRemoveFromSession, +} from '@/services/generated/queue/queue'; import type { Track } from '@/features/player/types'; export interface QueueItemResponse { @@ -39,6 +58,18 @@ export interface QueueResponse { items: QueueItemResponse[]; } +// Backend wraps each response in an APIResponse envelope: +// { success: true, data: { queue: ..., items: ... } } +// orval's mutator unwraps the Axios layer (`response.data`), so what +// we get here is the envelope itself. Strip the `data` key when present. +const unwrapPayload = (raw: unknown): T => { + const env = raw as { data?: unknown } | undefined; + if (env && typeof env === 'object' && 'data' in env && env.data !== undefined) { + return env.data as T; + } + return raw as T; +}; + function mapQueueItemToTrack(item: QueueItemResponse): Track | null { const t = item.track; if (!t) return null; @@ -61,8 +92,8 @@ export const queueApi = { tracks: Track[]; queueItemIds: string[]; }> { - const response = await apiClient.get('/queue'); - const data = response.data as unknown as QueueResponse; + const response = await orvalGetQueue(); + const data = unwrapPayload(response); const items = (data?.items ?? []).sort((a, b) => a.position - b.position); const tracks = items .map(mapQueueItemToTrack) @@ -84,34 +115,48 @@ export const queueApi = { volume?: number; item_order?: string[]; }): Promise { - const response = await apiClient.put('/queue', payload); - return response.data as unknown as QueueResponse; + const response = await orvalUpdateQueue( + payload as Parameters[0], + ); + return unwrapPayload(response); }, async addToQueue(trackId: string): Promise { - const response = await apiClient.post<{ item: QueueItemResponse }>( - '/queue/items', - { track_id: trackId }, + const response = await orvalAddItem( + { track_id: trackId } as unknown as Parameters[0], ); - return (response.data as unknown as { item: QueueItemResponse }).item; + const payload = unwrapPayload<{ item: QueueItemResponse } | QueueItemResponse>( + response, + ); + if (payload && typeof payload === 'object' && 'item' in payload && payload.item) { + return payload.item as QueueItemResponse; + } + return payload as QueueItemResponse; }, async removeFromQueue(itemId: string): Promise { - await apiClient.delete(`/queue/items/${itemId}`); + await orvalRemoveItem(itemId); }, async clearQueue(): Promise { - await apiClient.delete('/queue'); + await orvalClearQueue(); }, // v0.203 Lot D1: Collaborative queue sessions - async createQueueSession(): Promise<{ session: { id: string; share_token: string }; share_url: string }> { - const response = await apiClient.post<{ session: { id: string; share_token: string }; share_url?: string }>( - '/queue/session', - ); - const data = response.data as unknown as { session: { id: string; share_token: string }; share_url?: string }; + async createQueueSession(): Promise<{ + session: { id: string; share_token: string }; + share_url: string; + }> { + const response = await orvalCreateSession(); + const data = unwrapPayload<{ + session: { id: string; share_token: string }; + share_url?: string; + }>(response); const shareUrl = - data.share_url ?? (typeof window !== 'undefined' ? `${window.location.origin}/?session=${data.session.share_token}` : ''); + data.share_url ?? + (typeof window !== 'undefined' + ? `${window.location.origin}/?session=${data.session.share_token}` + : ''); return { session: data.session, share_url: shareUrl }; }, @@ -120,36 +165,33 @@ export const queueApi = { items: Array<{ id: string; position: number; - track?: { id: string; title?: string; artist?: string; duration?: number; cover_art_path?: string; genre?: string; like_count?: number }; + track?: { + id: string; + title?: string; + artist?: string; + duration?: number; + cover_art_path?: string; + genre?: string; + like_count?: number; + }; }>; }> { - const response = await apiClient.get<{ - session: unknown; - items: Array<{ - id: string; - position: number; - track?: { id: string; title?: string; artist?: string; duration?: number; cover_art_path?: string; genre?: string; like_count?: number }; - }>; - }>(`/queue/session/${token}`); - return response.data as unknown as { - session: unknown; - items: Array<{ - id: string; - position: number; - track?: { id: string; title?: string; artist?: string; duration?: number; cover_art_path?: string; genre?: string; like_count?: number }; - }>; - }; + const response = await orvalGetSession(token); + return unwrapPayload(response); }, async deleteQueueSession(token: string): Promise { - await apiClient.delete(`/queue/session/${token}`); + await orvalDeleteSession(token); }, async addToSessionQueue(token: string, trackId: string): Promise { - await apiClient.post(`/queue/session/${token}/items`, { track_id: trackId }); + await orvalAddToSession( + token, + { track_id: trackId } as unknown as Parameters[1], + ); }, async removeFromSessionQueue(token: string, itemId: string): Promise { - await apiClient.delete(`/queue/session/${token}/items/${itemId}`); + await orvalRemoveFromSession(token, itemId); }, };