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:
parent
3ca9a2afec
commit
9325cd0e66
2 changed files with 124 additions and 71 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue