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 { 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<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>;
put: ReturnType<typeof vi.fn>;
post: ReturnType<typeof vi.fn>;
@ -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);

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 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<UserProfile> {
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<UserProfile> {
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<UserProfile> {
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<typeof putUsersId>[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<ProfileCompletion> {
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<FollowUserResponse> {
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<FollowUserResponse> {
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<SuggestionsResponse> {
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<SuggestionsResponse>;
return { suggestions: data?.suggestions ?? [] };
}
@ -159,12 +202,11 @@ export async function getUserReposts(
limit = 20,
offset = 0,
): Promise<UserRepostsResponse> {
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<UserRepostsResponse>;
return {
tracks: data?.tracks ?? [],
tracks: (data?.tracks ?? []) as Track[],
total: data?.total ?? 0,
limit: data?.limit ?? limit,
offset: data?.offset ?? offset,