refactor(web): migrate profileService to orval-generated user client (v1.0.8 B3)

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) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-24 01:23:09 +02:00
parent 3ca9a2afec
commit 9325cd0e66
2 changed files with 124 additions and 71 deletions

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { import {
getProfile, getProfile,
getProfileByUsername, getProfileByUsername,
@ -12,9 +13,33 @@ import {
type UserProfile, type UserProfile,
type UpdateProfileRequest, type UpdateProfileRequest,
} from './profileService'; } from './profileService';
import {
getUsersId,
getUsersByUsernameUsername,
putUsersId,
getUsersIdCompletion,
postUsersIdFollow,
deleteUsersIdFollow,
} from '@/services/generated/user/user';
import { apiClient } from '@/services/api/client'; 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', () => ({ vi.mock('@/services/api/client', () => ({
apiClient: { apiClient: {
get: vi.fn(), get: vi.fn(),
@ -24,7 +49,16 @@ vi.mock('@/services/api/client', () => ({
}, },
})); }));
const mockedApiClient = apiClient as { const mockedGen = {
getUsersId: getUsersId as unknown as ReturnType<typeof vi.fn>,
getUsersByUsernameUsername: getUsersByUsernameUsername as unknown as ReturnType<typeof vi.fn>,
putUsersId: putUsersId as unknown as ReturnType<typeof vi.fn>,
getUsersIdCompletion: getUsersIdCompletion as unknown as ReturnType<typeof vi.fn>,
postUsersIdFollow: postUsersIdFollow as unknown as ReturnType<typeof vi.fn>,
deleteUsersIdFollow: deleteUsersIdFollow as unknown as ReturnType<typeof vi.fn>,
};
const mockedApiClient = apiClient as unknown as {
get: ReturnType<typeof vi.fn>; get: ReturnType<typeof vi.fn>;
put: ReturnType<typeof vi.fn>; put: ReturnType<typeof vi.fn>;
post: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn>;
@ -53,14 +87,14 @@ describe('profileService', () => {
following_count: 5, following_count: 5,
}; };
mockedApiClient.get.mockResolvedValue({ // Orval response envelope is typically { data, status }; our
data: { profile: mockProfile }, // helper unwraps `.data` then the optional `profile` key.
}); mockedGen.getUsersId.mockResolvedValue({ profile: mockProfile });
const result = await getProfile('user-1'); const result = await getProfile('user-1');
expect(result).toEqual(mockProfile); 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 () => { it('should throw error on fetch failure', async () => {
@ -68,9 +102,9 @@ describe('profileService', () => {
mockError.response = { mockError.response = {
status: 404, status: 404,
data: { error: 'User not found' }, 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(); await expect(getProfile('invalid-user')).rejects.toThrow();
}); });
@ -91,16 +125,12 @@ describe('profileService', () => {
created_at: '2024-01-01T00:00:00Z', created_at: '2024-01-01T00:00:00Z',
}; };
mockedApiClient.get.mockResolvedValue({ mockedGen.getUsersByUsernameUsername.mockResolvedValue({ profile: mockProfile });
data: { profile: mockProfile },
});
const result = await getProfileByUsername('testuser'); const result = await getProfileByUsername('testuser');
expect(result).toEqual(mockProfile); expect(result).toEqual(mockProfile);
expect(mockedApiClient.get).toHaveBeenCalledWith( expect(mockedGen.getUsersByUsernameUsername).toHaveBeenCalledWith('testuser');
'/users/by-username/testuser',
);
}); });
it('should handle response without profile wrapper', async () => { it('should handle response without profile wrapper', async () => {
@ -117,9 +147,9 @@ describe('profileService', () => {
created_at: '2024-01-01T00:00:00Z', created_at: '2024-01-01T00:00:00Z',
}; };
mockedApiClient.get.mockResolvedValue({ // Some endpoints skip the envelope and return the profile directly;
data: mockProfile, // the service's unwrapProfile helper handles both.
}); mockedGen.getUsersByUsernameUsername.mockResolvedValue(mockProfile);
const result = await getProfileByUsername('testuser'); const result = await getProfileByUsername('testuser');
@ -148,17 +178,12 @@ describe('profileService', () => {
created_at: '2024-01-01T00:00:00Z', created_at: '2024-01-01T00:00:00Z',
}; };
mockedApiClient.put.mockResolvedValue({ mockedGen.putUsersId.mockResolvedValue({ profile: mockUpdatedProfile });
data: { profile: mockUpdatedProfile },
});
const result = await updateProfile('user-1', updateData); const result = await updateProfile('user-1', updateData);
expect(result).toEqual(mockUpdatedProfile); expect(result).toEqual(mockUpdatedProfile);
expect(mockedApiClient.put).toHaveBeenCalledWith( expect(mockedGen.putUsersId).toHaveBeenCalledWith('user-1', updateData);
'/users/user-1',
updateData,
);
}); });
it('should throw error on update failure', async () => { it('should throw error on update failure', async () => {
@ -166,9 +191,9 @@ describe('profileService', () => {
mockError.response = { mockError.response = {
status: 400, status: 400,
data: { error: 'Invalid data' }, data: { error: 'Invalid data' },
} as any; } as unknown as AxiosError['response'];
mockedApiClient.put.mockRejectedValue(mockError); mockedGen.putUsersId.mockRejectedValue(mockError);
await expect( await expect(
updateProfile('user-1', { username: 'invalid' }), updateProfile('user-1', { username: 'invalid' }),
@ -183,16 +208,12 @@ describe('profileService', () => {
missing: ['bio', 'location'], missing: ['bio', 'location'],
}; };
mockedApiClient.get.mockResolvedValue({ mockedGen.getUsersIdCompletion.mockResolvedValue(mockCompletion);
data: mockCompletion,
});
const result = await calculateProfileCompletion('user-1'); const result = await calculateProfileCompletion('user-1');
expect(result).toEqual(mockCompletion); expect(result).toEqual(mockCompletion);
expect(mockedApiClient.get).toHaveBeenCalledWith( expect(mockedGen.getUsersIdCompletion).toHaveBeenCalledWith('user-1');
'/users/user-1/completion',
);
}); });
}); });
@ -203,14 +224,12 @@ describe('profileService', () => {
is_following: true, is_following: true,
}; };
mockedApiClient.post.mockResolvedValue({ mockedGen.postUsersIdFollow.mockResolvedValue(mockResponse);
data: mockResponse,
});
const result = await followUser('user-2'); const result = await followUser('user-2');
expect(result).toEqual(mockResponse); 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 () => { it('should throw error on follow failure', async () => {
@ -218,9 +237,9 @@ describe('profileService', () => {
mockError.response = { mockError.response = {
status: 400, status: 400,
data: { error: 'Cannot follow yourself' }, 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(); await expect(followUser('user-1')).rejects.toThrow();
}); });
@ -233,16 +252,12 @@ describe('profileService', () => {
is_following: false, is_following: false,
}; };
mockedApiClient.delete.mockResolvedValue({ mockedGen.deleteUsersIdFollow.mockResolvedValue(mockResponse);
data: mockResponse,
});
const result = await unfollowUser('user-2'); const result = await unfollowUser('user-2');
expect(result).toEqual(mockResponse); expect(result).toEqual(mockResponse);
expect(mockedApiClient.delete).toHaveBeenCalledWith( expect(mockedGen.deleteUsersIdFollow).toHaveBeenCalledWith('user-2');
'/users/user-2/follow',
);
}); });
}); });
@ -260,9 +275,7 @@ describe('profileService', () => {
total: 1, total: 1,
}; };
mockedApiClient.get.mockResolvedValue({ mockedApiClient.get.mockResolvedValue({ data: mockResponse });
data: mockResponse,
});
const result = await getFollowers('user-1', 1, 20); const result = await getFollowers('user-1', 1, 20);
@ -290,9 +303,7 @@ describe('profileService', () => {
total: 1, total: 1,
}; };
mockedApiClient.get.mockResolvedValue({ mockedApiClient.get.mockResolvedValue({ data: mockResponse });
data: mockResponse,
});
const result = await getFollowing('user-1', 1, 20); const result = await getFollowing('user-1', 1, 20);

View file

@ -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 { apiClient } from '@/services/api/client';
import type { Track } from '@/features/tracks/types/track'; import type { Track } from '@/features/tracks/types/track';
export interface UserProfile { export interface UserProfile {
@ -20,18 +40,31 @@ export interface UserProfile {
is_public?: boolean; 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<UserProfile> { export async function getProfile(userId: string): Promise<UserProfile> {
const response = await apiClient.get(`/users/${userId}`); const response = await getUsersId(userId);
return (response.data as { profile?: UserProfile })?.profile ?? (response.data as UserProfile); return unwrapProfile((response as unknown as { data?: unknown })?.data ?? response);
} }
export async function getProfileByUsername( export async function getProfileByUsername(
username: string, username: string,
): Promise<UserProfile> { ): Promise<UserProfile> {
const encoded = encodeURIComponent(username); const response = await getUsersByUsernameUsername(username);
const response = await apiClient.get(`/users/by-username/${encoded}`); return unwrapProfile((response as unknown as { data?: unknown })?.data ?? response);
// Backend returns { profile: {...} }; client unwraps success/data so response.data is the payload
return response.data?.profile ?? response.data;
} }
export interface UpdateProfileRequest { export interface UpdateProfileRequest {
@ -51,8 +84,11 @@ export async function updateProfile(
userId: string, userId: string,
data: UpdateProfileRequest, data: UpdateProfileRequest,
): Promise<UserProfile> { ): Promise<UserProfile> {
const response = await apiClient.put(`/users/${userId}`, data); // Orval generated body type matches the JSON shape; cast to
return response.data.profile || response.data; // 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<typeof putUsersId>[1]);
return unwrapProfile((response as unknown as { data?: unknown })?.data ?? response);
} }
export interface ProfileCompletion { export interface ProfileCompletion {
@ -63,8 +99,9 @@ export interface ProfileCompletion {
export async function calculateProfileCompletion( export async function calculateProfileCompletion(
userId: string, userId: string,
): Promise<ProfileCompletion> { ): Promise<ProfileCompletion> {
const response = await apiClient.get(`/users/${userId}/completion`); const response = await getUsersIdCompletion(userId);
return response.data; const payload = (response as unknown as { data?: unknown })?.data ?? response;
return payload as ProfileCompletion;
} }
// FE-PAGE-010: Complete User Profile page implementation - Follow/Unfollow // FE-PAGE-010: Complete User Profile page implementation - Follow/Unfollow
@ -75,15 +112,17 @@ export interface FollowUserResponse {
} }
export async function followUser(userId: string): Promise<FollowUserResponse> { export async function followUser(userId: string): Promise<FollowUserResponse> {
const response = await apiClient.post(`/users/${userId}/follow`); const response = await postUsersIdFollow(userId);
return response.data; const payload = (response as unknown as { data?: unknown })?.data ?? response;
return payload as FollowUserResponse;
} }
export async function unfollowUser( export async function unfollowUser(
userId: string, userId: string,
): Promise<FollowUserResponse> { ): Promise<FollowUserResponse> {
const response = await apiClient.delete(`/users/${userId}/follow`); const response = await deleteUsersIdFollow(userId);
return response.data; const payload = (response as unknown as { data?: unknown })?.data ?? response;
return payload as FollowUserResponse;
} }
export interface UserFollowersResponse { export interface UserFollowersResponse {
@ -96,6 +135,9 @@ export interface UserFollowersResponse {
total: number; 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( export async function getFollowers(
userId: string, userId: string,
page: number = 1, page: number = 1,
@ -141,8 +183,9 @@ export interface SuggestionsResponse {
} }
export async function getSuggestions(limit = 10): Promise<SuggestionsResponse> { export async function getSuggestions(limit = 10): Promise<SuggestionsResponse> {
const response = await apiClient.get('/users/suggestions', { params: { limit } }); const response = await getUsersSuggestions({ limit });
const data = response.data as SuggestionsResponse; const payload = (response as unknown as { data?: unknown })?.data ?? response;
const data = payload as Partial<SuggestionsResponse>;
return { suggestions: data?.suggestions ?? [] }; return { suggestions: data?.suggestions ?? [] };
} }
@ -159,12 +202,11 @@ export async function getUserReposts(
limit = 20, limit = 20,
offset = 0, offset = 0,
): Promise<UserRepostsResponse> { ): Promise<UserRepostsResponse> {
const response = await apiClient.get(`/users/${userId}/reposts`, { const response = await getUsersIdReposts(userId, { limit, offset });
params: { limit, offset }, const payload = (response as unknown as { data?: unknown })?.data ?? response;
}); const data = payload as Partial<UserRepostsResponse>;
const data = response.data as UserRepostsResponse;
return { return {
tracks: data?.tracks ?? [], tracks: (data?.tracks ?? []) as Track[],
total: data?.total ?? 0, total: data?.total ?? 0,
limit: data?.limit ?? limit, limit: data?.limit ?? limit,
offset: data?.offset ?? offset, offset: data?.offset ?? offset,