From 9325cd0e66fe38e039785c3c18300f47c33a1d46 Mon Sep 17 00:00:00 2001 From: senke Date: Fri, 24 Apr 2026 01:23:09 +0200 Subject: [PATCH] refactor(web): migrate profileService to orval-generated user client (v1.0.8 B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real service migration post-scaffolding. Replaces raw apiClient calls in @/features/profile/services/profileService.ts with the orval-generated functions from services/generated/user/user.ts while keeping every public function signature intact — no call sites touched. Functions migrated (8): - getProfile → getUsersId - getProfileByUsername → getUsersByUsernameUsername - updateProfile → putUsersId - calculateProfileCompletion → getUsersIdCompletion - followUser → postUsersIdFollow - unfollowUser → deleteUsersIdFollow - getSuggestions → getUsersSuggestions - getUserReposts → getUsersIdReposts Functions still on raw apiClient (endpoints lack swaggo annotations, deferred v1.0.9): - getFollowers → GET /users/{id}/followers - getFollowing → GET /users/{id}/following A small `unwrapProfile` helper normalises the two envelope shapes the backend returns for profile endpoints ({profile: ...} vs the raw object) so the public API stays identical. Test file rewritten to mock the generated module (`services/generated/ user/user`) for migrated functions, with the apiClient mock retained only for the two followers/following paths. 12/12 profileService tests + 36/36 feature/profile suite green. npm run typecheck ✅. Bisectable: revert this commit → tests return to apiClient-mocking pattern, profileService.ts returns to raw apiClient. No data-shape drift, no interceptor changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../profile/services/profileService.test.ts | 111 ++++++++++-------- .../profile/services/profileService.ts | 84 +++++++++---- 2 files changed, 124 insertions(+), 71 deletions(-) diff --git a/apps/web/src/features/profile/services/profileService.test.ts b/apps/web/src/features/profile/services/profileService.test.ts index 35ae733a3..6ac74f393 100644 --- a/apps/web/src/features/profile/services/profileService.test.ts +++ b/apps/web/src/features/profile/services/profileService.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AxiosError } from 'axios'; + import { getProfile, getProfileByUsername, @@ -12,9 +13,33 @@ import { type UserProfile, type UpdateProfileRequest, } from './profileService'; +import { + getUsersId, + getUsersByUsernameUsername, + putUsersId, + getUsersIdCompletion, + postUsersIdFollow, + deleteUsersIdFollow, +} from '@/services/generated/user/user'; import { apiClient } from '@/services/api/client'; -// Mock apiClient +// v1.0.8 B3 — profileService migrated to orval-generated calls. Mock the +// generated module so tests validate our wrapper logic (envelope +// unwrapping, default values) without hitting the transport layer. +// `followers` / `following` endpoints lack swaggo annotations so they +// still call apiClient directly — keep the apiClient mock for them. + +vi.mock('@/services/generated/user/user', () => ({ + getUsersId: vi.fn(), + getUsersByUsernameUsername: vi.fn(), + putUsersId: vi.fn(), + getUsersIdCompletion: vi.fn(), + postUsersIdFollow: vi.fn(), + deleteUsersIdFollow: vi.fn(), + getUsersSuggestions: vi.fn(), + getUsersIdReposts: vi.fn(), +})); + vi.mock('@/services/api/client', () => ({ apiClient: { get: vi.fn(), @@ -24,7 +49,16 @@ vi.mock('@/services/api/client', () => ({ }, })); -const mockedApiClient = apiClient as { +const mockedGen = { + getUsersId: getUsersId as unknown as ReturnType, + getUsersByUsernameUsername: getUsersByUsernameUsername as unknown as ReturnType, + putUsersId: putUsersId as unknown as ReturnType, + getUsersIdCompletion: getUsersIdCompletion as unknown as ReturnType, + postUsersIdFollow: postUsersIdFollow as unknown as ReturnType, + deleteUsersIdFollow: deleteUsersIdFollow as unknown as ReturnType, +}; + +const mockedApiClient = apiClient as unknown as { get: ReturnType; put: ReturnType; post: ReturnType; @@ -53,14 +87,14 @@ describe('profileService', () => { following_count: 5, }; - mockedApiClient.get.mockResolvedValue({ - data: { profile: mockProfile }, - }); + // Orval response envelope is typically { data, status }; our + // helper unwraps `.data` then the optional `profile` key. + mockedGen.getUsersId.mockResolvedValue({ profile: mockProfile }); const result = await getProfile('user-1'); expect(result).toEqual(mockProfile); - expect(mockedApiClient.get).toHaveBeenCalledWith('/users/user-1'); + expect(mockedGen.getUsersId).toHaveBeenCalledWith('user-1'); }); it('should throw error on fetch failure', async () => { @@ -68,9 +102,9 @@ describe('profileService', () => { mockError.response = { status: 404, data: { error: 'User not found' }, - } as any; + } as unknown as AxiosError['response']; - mockedApiClient.get.mockRejectedValue(mockError); + mockedGen.getUsersId.mockRejectedValue(mockError); await expect(getProfile('invalid-user')).rejects.toThrow(); }); @@ -91,16 +125,12 @@ describe('profileService', () => { created_at: '2024-01-01T00:00:00Z', }; - mockedApiClient.get.mockResolvedValue({ - data: { profile: mockProfile }, - }); + mockedGen.getUsersByUsernameUsername.mockResolvedValue({ profile: mockProfile }); const result = await getProfileByUsername('testuser'); expect(result).toEqual(mockProfile); - expect(mockedApiClient.get).toHaveBeenCalledWith( - '/users/by-username/testuser', - ); + expect(mockedGen.getUsersByUsernameUsername).toHaveBeenCalledWith('testuser'); }); it('should handle response without profile wrapper', async () => { @@ -117,9 +147,9 @@ describe('profileService', () => { created_at: '2024-01-01T00:00:00Z', }; - mockedApiClient.get.mockResolvedValue({ - data: mockProfile, - }); + // Some endpoints skip the envelope and return the profile directly; + // the service's unwrapProfile helper handles both. + mockedGen.getUsersByUsernameUsername.mockResolvedValue(mockProfile); const result = await getProfileByUsername('testuser'); @@ -148,17 +178,12 @@ describe('profileService', () => { created_at: '2024-01-01T00:00:00Z', }; - mockedApiClient.put.mockResolvedValue({ - data: { profile: mockUpdatedProfile }, - }); + mockedGen.putUsersId.mockResolvedValue({ profile: mockUpdatedProfile }); const result = await updateProfile('user-1', updateData); expect(result).toEqual(mockUpdatedProfile); - expect(mockedApiClient.put).toHaveBeenCalledWith( - '/users/user-1', - updateData, - ); + expect(mockedGen.putUsersId).toHaveBeenCalledWith('user-1', updateData); }); it('should throw error on update failure', async () => { @@ -166,9 +191,9 @@ describe('profileService', () => { mockError.response = { status: 400, data: { error: 'Invalid data' }, - } as any; + } as unknown as AxiosError['response']; - mockedApiClient.put.mockRejectedValue(mockError); + mockedGen.putUsersId.mockRejectedValue(mockError); await expect( updateProfile('user-1', { username: 'invalid' }), @@ -183,16 +208,12 @@ describe('profileService', () => { missing: ['bio', 'location'], }; - mockedApiClient.get.mockResolvedValue({ - data: mockCompletion, - }); + mockedGen.getUsersIdCompletion.mockResolvedValue(mockCompletion); const result = await calculateProfileCompletion('user-1'); expect(result).toEqual(mockCompletion); - expect(mockedApiClient.get).toHaveBeenCalledWith( - '/users/user-1/completion', - ); + expect(mockedGen.getUsersIdCompletion).toHaveBeenCalledWith('user-1'); }); }); @@ -203,14 +224,12 @@ describe('profileService', () => { is_following: true, }; - mockedApiClient.post.mockResolvedValue({ - data: mockResponse, - }); + mockedGen.postUsersIdFollow.mockResolvedValue(mockResponse); const result = await followUser('user-2'); expect(result).toEqual(mockResponse); - expect(mockedApiClient.post).toHaveBeenCalledWith('/users/user-2/follow'); + expect(mockedGen.postUsersIdFollow).toHaveBeenCalledWith('user-2'); }); it('should throw error on follow failure', async () => { @@ -218,9 +237,9 @@ describe('profileService', () => { mockError.response = { status: 400, data: { error: 'Cannot follow yourself' }, - } as any; + } as unknown as AxiosError['response']; - mockedApiClient.post.mockRejectedValue(mockError); + mockedGen.postUsersIdFollow.mockRejectedValue(mockError); await expect(followUser('user-1')).rejects.toThrow(); }); @@ -233,16 +252,12 @@ describe('profileService', () => { is_following: false, }; - mockedApiClient.delete.mockResolvedValue({ - data: mockResponse, - }); + mockedGen.deleteUsersIdFollow.mockResolvedValue(mockResponse); const result = await unfollowUser('user-2'); expect(result).toEqual(mockResponse); - expect(mockedApiClient.delete).toHaveBeenCalledWith( - '/users/user-2/follow', - ); + expect(mockedGen.deleteUsersIdFollow).toHaveBeenCalledWith('user-2'); }); }); @@ -260,9 +275,7 @@ describe('profileService', () => { total: 1, }; - mockedApiClient.get.mockResolvedValue({ - data: mockResponse, - }); + mockedApiClient.get.mockResolvedValue({ data: mockResponse }); const result = await getFollowers('user-1', 1, 20); @@ -290,9 +303,7 @@ describe('profileService', () => { total: 1, }; - mockedApiClient.get.mockResolvedValue({ - data: mockResponse, - }); + mockedApiClient.get.mockResolvedValue({ data: mockResponse }); const result = await getFollowing('user-1', 1, 20); diff --git a/apps/web/src/features/profile/services/profileService.ts b/apps/web/src/features/profile/services/profileService.ts index f14311aae..0ab95eeea 100644 --- a/apps/web/src/features/profile/services/profileService.ts +++ b/apps/web/src/features/profile/services/profileService.ts @@ -1,4 +1,24 @@ +/** + * Profile service — thin wrapper over orval-generated user client. + * v1.0.8 B3 migration: replaced raw apiClient calls with generated + * functions from services/generated/user/user.ts. Same public API + * (function signatures + response shapes) so callers stay untouched. + * + * The orval calls funnel through vezaMutator → apiClient, preserving + * the existing interceptor chain (auth / CSRF / retry / offline-queue). + */ +import { + getUsersId, + putUsersId, + getUsersByUsernameUsername, + getUsersIdCompletion, + postUsersIdFollow, + deleteUsersIdFollow, + getUsersSuggestions, + getUsersIdReposts, +} from '@/services/generated/user/user'; import { apiClient } from '@/services/api/client'; + import type { Track } from '@/features/tracks/types/track'; export interface UserProfile { @@ -20,18 +40,31 @@ export interface UserProfile { is_public?: boolean; } +// Orval responses wrap the payload in `.data`. Our API client response +// interceptor flattens the success envelope, so the actual shape we +// receive is `{ profile: {...} }` (or sometimes the profile directly +// depending on the route). Helpers below normalise that ambiguity. + +type ProfileEnvelope = UserProfile | { profile?: UserProfile }; + +const unwrapProfile = (raw: unknown): UserProfile => { + const env = raw as ProfileEnvelope; + if (env && typeof env === 'object' && 'profile' in env && env.profile) { + return env.profile as UserProfile; + } + return env as UserProfile; +}; + export async function getProfile(userId: string): Promise { - const response = await apiClient.get(`/users/${userId}`); - return (response.data as { profile?: UserProfile })?.profile ?? (response.data as UserProfile); + const response = await getUsersId(userId); + return unwrapProfile((response as unknown as { data?: unknown })?.data ?? response); } export async function getProfileByUsername( username: string, ): Promise { - const encoded = encodeURIComponent(username); - const response = await apiClient.get(`/users/by-username/${encoded}`); - // Backend returns { profile: {...} }; client unwraps success/data so response.data is the payload - return response.data?.profile ?? response.data; + const response = await getUsersByUsernameUsername(username); + return unwrapProfile((response as unknown as { data?: unknown })?.data ?? response); } export interface UpdateProfileRequest { @@ -51,8 +84,11 @@ export async function updateProfile( userId: string, data: UpdateProfileRequest, ): Promise { - const response = await apiClient.put(`/users/${userId}`, data); - return response.data.profile || response.data; + // Orval generated body type matches the JSON shape; cast to + // Parameters[1] of putUsersId to keep the signature strict without + // repeating the types. Orval response carries data envelope. + const response = await putUsersId(userId, data as Parameters[1]); + return unwrapProfile((response as unknown as { data?: unknown })?.data ?? response); } export interface ProfileCompletion { @@ -63,8 +99,9 @@ export interface ProfileCompletion { export async function calculateProfileCompletion( userId: string, ): Promise { - const response = await apiClient.get(`/users/${userId}/completion`); - return response.data; + const response = await getUsersIdCompletion(userId); + const payload = (response as unknown as { data?: unknown })?.data ?? response; + return payload as ProfileCompletion; } // FE-PAGE-010: Complete User Profile page implementation - Follow/Unfollow @@ -75,15 +112,17 @@ export interface FollowUserResponse { } export async function followUser(userId: string): Promise { - const response = await apiClient.post(`/users/${userId}/follow`); - return response.data; + const response = await postUsersIdFollow(userId); + const payload = (response as unknown as { data?: unknown })?.data ?? response; + return payload as FollowUserResponse; } export async function unfollowUser( userId: string, ): Promise { - const response = await apiClient.delete(`/users/${userId}/follow`); - return response.data; + const response = await deleteUsersIdFollow(userId); + const payload = (response as unknown as { data?: unknown })?.data ?? response; + return payload as FollowUserResponse; } export interface UserFollowersResponse { @@ -96,6 +135,9 @@ export interface UserFollowersResponse { total: number; } +// /users/:id/followers and /users/:id/following endpoints still lack +// swaggo annotations on the backend (deferred v1.0.9). Kept as raw +// apiClient calls for now. export async function getFollowers( userId: string, page: number = 1, @@ -141,8 +183,9 @@ export interface SuggestionsResponse { } export async function getSuggestions(limit = 10): Promise { - const response = await apiClient.get('/users/suggestions', { params: { limit } }); - const data = response.data as SuggestionsResponse; + const response = await getUsersSuggestions({ limit }); + const payload = (response as unknown as { data?: unknown })?.data ?? response; + const data = payload as Partial; return { suggestions: data?.suggestions ?? [] }; } @@ -159,12 +202,11 @@ export async function getUserReposts( limit = 20, offset = 0, ): Promise { - const response = await apiClient.get(`/users/${userId}/reposts`, { - params: { limit, offset }, - }); - const data = response.data as UserRepostsResponse; + const response = await getUsersIdReposts(userId, { limit, offset }); + const payload = (response as unknown as { data?: unknown })?.data ?? response; + const data = payload as Partial; return { - tracks: data?.tracks ?? [], + tracks: (data?.tracks ?? []) as Track[], total: data?.total ?? 0, limit: data?.limit ?? limit, offset: data?.offset ?? offset,