Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 13 additions & 19 deletions e2e/real-account-api.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,32 +65,26 @@ test('browser completes the production account principal and user-case journey',
expect(loadedPreferences.status()).toBe(200);
await expect(page.getByRole('heading', { name: '로그인 및 보안' })).toBeVisible();

await expect(page.getByLabel('서버 시간대')).toBeVisible();
await expect(page.getByRole('textbox', { name: '서버 시간대' })).toHaveCount(0);
const [preferences] = await Promise.all([
page.waitForResponse((response) => response.url().endsWith('/api/v1/account/preferences') && response.request().method() === 'PATCH'),
page.getByRole('button', { name: '환경 저장' }).click(),
]);
expect(preferences.status()).toBe(200);
await expect(page.getByText('서버 설정을 저장했습니다.')).toBeVisible();
// Display preferences intentionally live outside the account screen. The
// account route exposes only customer-facing security and notification
// controls. The new email-preference response is covered by backend tests
// until the root repository points at that backend revision.
await expect(page.getByLabel('서버 시간대')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '이메일 알림' })).toBeVisible();

await page.getByRole('button', { name: '문의하기', exact: true }).click();
await expect(page.getByRole('dialog', { name: '문의하기' })).toBeVisible();
await page.getByLabel('케이스 제목').fill('Actual browser API incident');
await page.getByLabel('케이스 설명').fill('Production bearer principal reaches the PostgreSQL user-case store.');
const supportDialog = page.getByRole('dialog', { name: '문의하기' });
await expect(supportDialog).toBeVisible();
await supportDialog.getByLabel('문의 제목').fill('실제 브라우저 API 문의');
await supportDialog.getByLabel('문의 내용').fill('로그인한 사용자의 문의가 안전하게 접수되는지 확인합니다.');
const [submittedCase] = await Promise.all([
page.waitForResponse((response) => response.url().endsWith('/api/v1/cases') && response.request().method() === 'POST'),
page.getByRole('button', { name: '접수하기' }).click(),
supportDialog.getByRole('button', { name: '접수하기' }).click(),
]);
expect(submittedCase.status()).toBe(201);
const created = await submittedCase.json() as { id: string };
await expect(page.getByText(new RegExp(`추적 번호 ${created.id}`))).toBeVisible();

const [loadedCase] = await Promise.all([
page.waitForResponse((response) => response.url().endsWith(`/api/v1/cases/${created.id}`)),
page.getByRole('button', { name: '상태 확인' }).click(),
]);
expect(loadedCase.status()).toBe(200);
await expect(supportDialog.getByText('문의가 접수되었습니다.')).toBeVisible();
await expect(supportDialog.getByText(created.id, { exact: true })).toHaveCount(0);

// Exercise the signed-in product shell against the same real backend. These
// reads catch missing controllers, stale root pointers, auth propagation,
Expand Down
28 changes: 19 additions & 9 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ describe('Signal product UI', () => {
expect(performance).toHaveTextContent('개인 운용과 대회 성과는 합산하지 않습니다.');
});

test('removes admin and watchlist entry points and centralizes account settings in My account', async () => {
test('removes admin, watchlist and account-sidebar entry points from My account', async () => {
const user = userEvent.setup();
render(<App />);

Expand All @@ -306,11 +306,21 @@ describe('Signal product UI', () => {
// login that never existed, only what the real API panels can prove.
expect(screen.queryByText('김전략')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '접근 보안' })).not.toBeInTheDocument();
expect(screen.getByRole('navigation', { name: '계정 설정' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: '로그인 및 보안' })).toHaveAttribute('href', '#account-security');
expect(screen.getByRole('link', { name: '서비스 환경' })).toHaveAttribute('href', '#account-environment');
expect(screen.queryByRole('navigation', { name: '계정 설정' })).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: '로그인 및 보안' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '계정 관리' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '서비스 환경' })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '서버 알림 채널' })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '화면 설정' })).not.toBeInTheDocument();
expect(screen.getByText('테마와 화면 표시는 상단 톱니바퀴에서 변경할 수 있습니다.')).toBeInTheDocument();
});

test('keeps the inquiry title field styled and the withdrawal action free of a glow', () => {
expect(balancedStyles).toMatch(
/\.account-support-modal-body \.case-api-form input\s*\{[^}]*border:\s*1px solid var\(--line\)[^}]*background:\s*var\(--surface-2\)/s,
);
expect(balancedStyles).toMatch(
/\.account-withdrawal-trigger[\s\S]*?\{[^}]*box-shadow:\s*none;/,
);
});

test('switches the product between Korean and English and remembers the choice', async () => {
Expand Down Expand Up @@ -450,8 +460,8 @@ describe('Signal product UI', () => {
const notificationClient: NotificationClient = {
list,
markRead: vi.fn(),
preferences: vi.fn(),
replacePreference: vi.fn(),
emailPreference: vi.fn().mockResolvedValue({ enabled: false }),
replaceEmailPreference: vi.fn(),
};
render(<App notificationClient={notificationClient} />);

Expand All @@ -467,8 +477,8 @@ describe('Signal product UI', () => {
const notificationClient: NotificationClient = {
list: vi.fn().mockRejectedValue(new NotificationApiError(401, 'AUTHENTICATION_REQUIRED', 'corr-topbar')),
markRead: vi.fn(),
preferences: vi.fn(),
replacePreference: vi.fn(),
emailPreference: vi.fn().mockResolvedValue({ enabled: false }),
replaceEmailPreference: vi.fn(),
};
render(<App notificationClient={notificationClient} />);

Expand Down
4 changes: 2 additions & 2 deletions src/AuthRoutes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,8 @@ describe('login entry points', () => {
const notificationClient: NotificationClient = {
list: vi.fn().mockRejectedValue(new NotificationApiError(401, 'UNAUTHENTICATED', 'corr-notif-1')),
markRead: vi.fn(),
preferences: vi.fn().mockResolvedValue([]),
replacePreference: vi.fn(),
emailPreference: vi.fn().mockResolvedValue({ enabled: false }),
replaceEmailPreference: vi.fn(),
};
window.history.replaceState({}, '', '/help');
render(<App accountClient={accountClient()} notificationClient={notificationClient} />);
Expand Down
9 changes: 2 additions & 7 deletions src/EnglishLocale.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,8 @@ const englishPreferencesClient = {
const emptyNotificationClient = {
list: async () => ({ items: [], nextCreatedAt: null, nextId: null }),
markRead: async () => undefined,
preferences: async () => [],
replacePreference: async () => ({
notificationTypeCode: 'TEST',
inAppEnabled: true,
emailEnabled: false,
updatedAt: '2026-08-07T00:00:00Z',
}),
emailPreference: async () => ({ enabled: false }),
replaceEmailPreference: async (enabled: boolean) => ({ enabled }),
} as unknown as NotificationClient;

const renderEnglishApp = () => render(
Expand Down
17 changes: 10 additions & 7 deletions src/ProductScreens.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ describe('Account settings', () => {
const operationsClient: AccountOperationsClient = {
submitCase: vi.fn(),
addCaseEvidence: vi.fn(),
userCases: vi.fn().mockResolvedValue({ items: [], nextCursor: null }),
userCase: vi.fn(),
operatorCaseQueue: vi.fn(),
operatorCase: vi.fn(),
Expand All @@ -793,26 +794,28 @@ describe('Account settings', () => {
return { setTheme, setTimezone, setReduceMotion };
};

test('keeps display settings in the topbar and presents account sections with clear navigation', () => {
test('removes the account sidebar and display settings from the account page', () => {
setup();

expect(screen.getByRole('navigation', { name: '계정 설정' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: '로그인 및 보안' })).toHaveAttribute('href', '#account-security');
expect(screen.getByRole('link', { name: '서비스 환경' })).toHaveAttribute('href', '#account-environment');
expect(screen.queryByRole('navigation', { name: '계정 설정' })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '서비스 환경' })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '서버 알림 채널' })).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '화면 설정' })).not.toBeInTheDocument();
expect(screen.queryByRole('combobox', { name: '테마 선택' })).not.toBeInTheDocument();
expect(screen.queryByRole('combobox', { name: '시간대 표기 선택' })).not.toBeInTheDocument();
});

test('opens support as a focused modal instead of an always-visible form', async () => {
test('shows inquiry history on the page and opens only the composer in a modal', async () => {
const user = userEvent.setup();
setup(true);

expect(screen.queryByLabelText('케이스 제목')).not.toBeInTheDocument();
expect(await screen.findByRole('heading', { name: '문의 내역' })).toBeInTheDocument();
expect(screen.getByText('아직 작성한 문의가 없습니다.')).toBeInTheDocument();
expect(screen.queryByLabelText('문의 제목')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '문의하기' }));

const dialog = screen.getByRole('dialog', { name: '문의하기' });
expect(within(dialog).getByLabelText('케이스 제목')).toHaveFocus();
expect(within(dialog).getByLabelText('문의 제목')).toHaveFocus();
await user.click(within(dialog).getByRole('button', { name: '문의 창 닫기' }));
expect(screen.queryByRole('dialog', { name: '문의하기' })).not.toBeInTheDocument();
});
Expand Down
4 changes: 2 additions & 2 deletions src/StrategyApiView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('Strategy API view', () => {

expect(await screen.findByTestId('strategy-row-Live Momentum')).toBeInTheDocument();
expect(screen.getByTestId('strategy-counts')).toHaveTextContent('출시 가능 1');
expect(client.list).toHaveBeenCalledWith(50, undefined, expect.any(AbortSignal));
expect(client.list).toHaveBeenCalledWith(10, undefined, expect.any(AbortSignal));
// A single complete page must not offer to load more.
expect(screen.queryByTestId('strategy-load-more')).not.toBeInTheDocument();
});
Expand Down Expand Up @@ -75,7 +75,7 @@ describe('Strategy API view', () => {
expect(await screen.findByTestId('strategy-row-Second Page')).toBeInTheDocument();
// The first page is appended to, never replaced.
expect(screen.getByTestId('strategy-row-First Page')).toBeInTheDocument();
expect(client.list).toHaveBeenLastCalledWith(50, 'cursor-2');
expect(client.list).toHaveBeenLastCalledWith(10, 'cursor-2');
// Exhausted pages retire the control and the partial marker.
expect(screen.queryByTestId('strategy-load-more')).not.toBeInTheDocument();
expect(screen.getByTestId('strategy-counts')).toHaveTextContent('전체 2');
Expand Down
16 changes: 15 additions & 1 deletion src/api/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ import { AccountApiError, createAccountClient } from './account';
describe('account API client', () => {
it('stores the one-time login token and sends correlation evidence', async () => {
const setAccessToken = vi.fn();
const signIn = vi.fn();
const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({
accountId: 'account-1', tokenType: 'Bearer',
accessToken: 'access-jwt',
accessExpiresAt: '2026-08-03T00:00:00Z', refreshExpiresAt: '2026-08-03T12:00:00Z',
}), { status: 200 }));
const client = createAccountClient({
baseUrl: 'https://api.example.com/', fetchImpl, setAccessToken,
sessionStore: {
read: vi.fn().mockReturnValue({ status: 'anonymous', reason: 'absent' }),
accessToken: vi.fn(), canRefresh: vi.fn(), signIn, signOut: vi.fn(), subscribe: vi.fn(),
},
createCorrelationId: () => 'correlation-1',
});

await client.login('user@example.com', 'password');

expect(setAccessToken).toHaveBeenCalledWith('access-jwt');
expect(signIn).toHaveBeenCalledWith(expect.objectContaining({ email: 'user@example.com' }));
expect(fetchImpl).toHaveBeenCalledWith('https://api.example.com/api/v1/auth/login', expect.objectContaining({
method: 'POST', credentials: 'include',
headers: expect.objectContaining({ 'X-Correlation-Id': 'correlation-1' }),
Expand Down Expand Up @@ -58,11 +64,19 @@ describe('account API client', () => {
}), { status: 202 }));
const client = createAccountClient({
fetchImpl, getAccessToken: () => 'session-token', createCorrelationId: () => 'correlation-2',
sessionStore: {
read: vi.fn().mockReturnValue({
status: 'authenticated',
session: { accessToken: 'session-token', accountId: 'account-1', email: 'user@example.com', expiresAt: null },
}),
accessToken: vi.fn(), canRefresh: vi.fn(), signIn: vi.fn(), signOut: vi.fn(), subscribe: vi.fn(),
},
});

await client.requestWithdrawal('user@example.com', 'password', 'withdrawal-1');
await client.requestWithdrawal('password', 'withdrawal-1');

expect(fetchImpl).toHaveBeenCalledWith('/api/v1/account/withdrawal-requests', expect.objectContaining({
body: JSON.stringify({ email: 'user@example.com', password: 'password', acceptedPolicyDocumentIds: [] }),
headers: expect.objectContaining({
Authorization: 'Bearer session-token', 'Idempotency-Key': 'withdrawal-1',
}),
Expand Down
28 changes: 20 additions & 8 deletions src/api/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ export interface AccountClient {
logoutAll(signal?: AbortSignal): Promise<void>;
preferences(signal?: AbortSignal): Promise<AccountPreferences>;
updatePreferences(input: Pick<AccountPreferences, 'languageCode' | 'timezoneName' | 'themePreference'>, signal?: AbortSignal): Promise<AccountPreferences>;
requestWithdrawal(email: string, password: string, idempotencyKey: string, signal?: AbortSignal): Promise<LifecycleResult>;
cancelWithdrawal(email: string, password: string, idempotencyKey: string, signal?: AbortSignal): Promise<LifecycleResult>;
requestWithdrawal(password: string, idempotencyKey: string, signal?: AbortSignal): Promise<LifecycleResult>;
cancelWithdrawal(password: string, idempotencyKey: string, signal?: AbortSignal): Promise<LifecycleResult>;
}

export function createAccountClient({
Expand All @@ -95,11 +95,21 @@ export function createAccountClient({
const requireSession = () => {
if (!getAccessToken?.()) throw new AccountApiError(401, 'AUTHENTICATION_REQUIRED', createCorrelationId());
};
const publishTokens = (result: LoginResult) => {
const rememberedEmail = () => {
const state = sessionStore?.read();
return state?.status === 'authenticated' ? state.session.email : undefined;
};
const requireRememberedEmail = () => {
const email = rememberedEmail();
if (!email) throw new AccountApiError(401, 'PASSWORD_STEP_UP_EMAIL_UNAVAILABLE', createCorrelationId());
return email;
};
const publishTokens = (result: LoginResult, email = rememberedEmail()) => {
setAccessToken?.(result.accessToken);
sessionStore?.signIn({
accessToken: result.accessToken,
accountId: result.accountId,
...(email ? { email: email.trim() } : {}),
expiresAt: result.accessExpiresAt,
refreshExpiresAt: result.refreshExpiresAt,
});
Expand Down Expand Up @@ -193,7 +203,7 @@ export function createAccountClient({
method: 'POST', signal, body: JSON.stringify({ email, password }),
})).json());
const result = readLoginResult(value);
publishTokens(result);
publishTokens(result, email);
return result;
},
async loginWithGoogle(idToken, expectedNonce, signal) {
Expand Down Expand Up @@ -243,11 +253,13 @@ export function createAccountClient({
method: 'PATCH', signal, body: JSON.stringify(input),
})).json());
},
requestWithdrawal(email, password, idempotencyKey, signal) {
return lifecycle('/api/v1/account/withdrawal-requests', email, password, [], idempotencyKey, signal);
requestWithdrawal(password, idempotencyKey, signal) {
requireSession();
return lifecycle('/api/v1/account/withdrawal-requests', requireRememberedEmail(), password, [], idempotencyKey, signal);
},
cancelWithdrawal(email, password, idempotencyKey, signal) {
return lifecycle('/api/v1/account/withdrawal-cancellations', email, password, [], idempotencyKey, signal);
cancelWithdrawal(password, idempotencyKey, signal) {
requireSession();
return lifecycle('/api/v1/account/withdrawal-cancellations', requireRememberedEmail(), password, [], idempotencyKey, signal);
},
};
}
Expand Down
Loading
Loading