refactor(web): migrate queue.ts + finish authService → orval
Some checks failed
Veza CI / Rust (Stream Server) (push) Failing after 2s
Frontend CI / test (push) Failing after 2m1s
Security Scan / Secret Scanning (gitleaks) (push) Successful in 1m1s
Veza CI / Backend (Go) (push) Failing after 15m48s
E2E Playwright / e2e (full) (push) Failing after 11m33s
Veza CI / Frontend (Web) (push) Failing after 28m3s
Veza CI / Notify on failure (push) Successful in 5s

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<T>() 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) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-26 00:56:44 +02:00
parent 0e72172291
commit aa6ccbefed
3 changed files with 171 additions and 80 deletions

View file

@ -33,11 +33,12 @@ const mockedApiClient = apiClient as {
get: ReturnType<typeof vi.fn>;
};
// 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();
});

View file

@ -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
* queryheader 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<AuthResponse> {
// INT-API-003: Standardized error handling using handleApiServiceError
export async function register(data: RegisterFormData): Promise<AuthResponse> {
try {
const response = await apiClient.post<AuthResponse>('/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<AuthResponse> {
try {
const response = await apiClient.post<AuthResponse>('/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<void> {
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<void> {
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<void> {
try {
await apiClient.get(`/auth/verify-email?token=${token}`);
await postAuthVerifyEmail({ token });
} catch (error) {
handleApiServiceError(error, { context: 'auth' });
}

View file

@ -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 = <T>(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<QueueResponse>('/queue');
const data = response.data as unknown as QueueResponse;
const response = await orvalGetQueue();
const data = unwrapPayload<QueueResponse>(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<QueueResponse> {
const response = await apiClient.put<QueueResponse>('/queue', payload);
return response.data as unknown as QueueResponse;
const response = await orvalUpdateQueue(
payload as Parameters<typeof orvalUpdateQueue>[0],
);
return unwrapPayload<QueueResponse>(response);
},
async addToQueue(trackId: string): Promise<QueueItemResponse> {
const response = await apiClient.post<{ item: QueueItemResponse }>(
'/queue/items',
{ track_id: trackId },
const response = await orvalAddItem(
{ track_id: trackId } as unknown as Parameters<typeof orvalAddItem>[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<void> {
await apiClient.delete(`/queue/items/${itemId}`);
await orvalRemoveItem(itemId);
},
async clearQueue(): Promise<void> {
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<void> {
await apiClient.delete(`/queue/session/${token}`);
await orvalDeleteSession(token);
},
async addToSessionQueue(token: string, trackId: string): Promise<void> {
await apiClient.post(`/queue/session/${token}/items`, { track_id: trackId });
await orvalAddToSession(
token,
{ track_id: trackId } as unknown as Parameters<typeof orvalAddToSession>[1],
);
},
async removeFromSessionQueue(token: string, itemId: string): Promise<void> {
await apiClient.delete(`/queue/session/${token}/items/${itemId}`);
await orvalRemoveFromSession(token, itemId);
},
};