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
1 change: 1 addition & 0 deletions e2e/dockerResourcePolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('real API Docker resource policy', () => {
expect(setup).toContain("path.join('..', 'backend')");
expect(setup).toContain("path.join('..')");
});

it('never reaps another running process and ages stopped resources before recovery', () => {
expect(shouldReapContainer(true, '2026-08-03T01:00:00Z', now)).toBe(false);
expect(shouldReapContainer(false, '2026-08-03T03:30:00Z', now)).toBe(false);
Expand Down
139 changes: 114 additions & 25 deletions e2e/real-account-api.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
import { execFileSync } from 'node:child_process';
import { createHmac } from 'node:crypto';
import { expect, test } from '@playwright/test';

test('browser completes the production account principal and user-case journey', async ({ page }) => {
const postgres = process.env.A23_POSTGRES_CONTAINER;
const verificationKey = process.env.A23_VERIFICATION_HMAC_KEY;
if (!postgres || !verificationKey) throw new Error('real API runtime state is unavailable');
const email = `a23-browser-${Date.now()}@example.com`;
const password = 'CorrectHorse!2026';
const verificationToken = `a23-verification-${Date.now()}`;

// Logged out, the account route goes straight to the sign-in screen.
await page.goto('/account');
Expand All @@ -26,19 +20,9 @@ test('browser completes the production account principal and user-case journey',
page.getByRole('button', { name: '가입', exact: true }).click(),
]);
expect(signup.status()).toBe(202);
const accountId = String((await signup.json()).accountId);

const digest = createHmac('sha256', Buffer.from(verificationKey, 'base64'))
.update(verificationToken).digest('base64url');
execFileSync('docker', ['exec', postgres, 'psql', '-U', 'postgres', '-d', 'a23',
'-v', 'ON_ERROR_STOP=1', '-c',
`update identity.email_verification_requests set token_digest='${digest}' where account_id='${accountId}' and consumed_at is null and revoked_at is null`]);

await expect(page.getByRole('status')).toContainText('인증 링크를 이메일로 보냈습니다.');
await expect(page.getByLabel('가입 인증 코드')).toHaveCount(0);
await page.goto(`/api/v1/auth/verify-email?token=${encodeURIComponent(verificationToken)}`);
await expect(page).toHaveURL(/\/login\?emailVerified=true$/);
await expect(page.getByRole('status')).toContainText('이메일 인증이 완료되었습니다. 로그인해 주세요.');
expect(await signup.json()).toMatchObject({ verificationRequired: false, verificationExpiresAt: null });
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('status')).toContainText('가입이 완료되었습니다. 바로 로그인할 수 있습니다.');

// Wrong password first: the screen reports the API's code and stays put.
await page.getByLabel('로그인 이메일').fill(email);
Expand All @@ -49,7 +33,7 @@ test('browser completes the production account principal and user-case journey',
]);
expect(rejectedLogin.status()).toBe(401);
await expect(page.getByRole('alert')).toBeVisible();
await expect(page).toHaveURL(/\/login\?emailVerified=true$/);
await expect(page).toHaveURL(/\/login$/);

await page.getByLabel('로그인 비밀번호', { exact: true }).fill(password);
const preferencesLoaded = page.waitForResponse((response) =>
Expand Down Expand Up @@ -123,14 +107,119 @@ test('browser completes the production account principal and user-case journey',
await expect(page).toHaveURL(`/strategies/${strategyId}/basic`);
await expect(page.getByTestId('basic-editor-workspace')).toBeVisible();
expect((await initialLease).status()).toBe(201);
const reacquiredLease = page.waitForResponse((response) =>
response.url().endsWith('/edit-lease')
&& response.request().method() === 'POST'
&& response.status() === 201);
await page.reload();
expect((await reacquiredLease).status()).toBe(201);
// Reload may reuse the live editor instance's lease or acquire a new one.
// Persistence is the contract under test here; requiring a second POST made
// the journey wait for five minutes when the existing lease remained valid.
await expect(page.getByTestId('basic-editor-workspace')).toBeVisible();

// A real strategy is not complete until an official catalog instrument can be selected.
// The old smoke test stopped before this dialog, so a zero-instrument catalog still passed.
await page.getByRole('button', { name: 'PARTITION 01 종목 관리' }).click();
const instrumentDialog = page.getByRole('dialog', { name: 'PARTITION 1 종목 관리' });
await expect(instrumentDialog.getByRole('option')).toHaveCount(3);
for (const symbol of ['AAPL', 'MSFT']) {
await instrumentDialog.getByRole('combobox', { name: '종목 검색' }).fill(symbol);
await instrumentDialog.getByRole('option', { name: new RegExp(`^${symbol}`) }).click();
await instrumentDialog.getByRole('button', { name: '종목 추가' }).click();
}
await instrumentDialog.getByRole('button', { name: '완료' }).click();
await expect(page.getByRole('button', { name: 'PARTITION 01 종목 관리' })).toContainText('2개 종목');

// Build a genuinely composite strategy through the visible editor: five buy conditions and
// five sell conditions, with every editable variable changed away from its unset state.
await page.getByRole('tab', { name: /패키지/ }).click();
await page.getByRole('button', { name: 'RSI 반등 패키지 적용' }).click();
const partition = page.getByRole('article', { name: 'PARTITION 01' });
const buyCard = partition.locator('[data-strategy-card]').nth(0);
const sellCard = partition.locator('[data-strategy-card]').nth(1);

const choose = async (scope: typeof buyCard, label: string, option: string) => {
await scope.getByRole('combobox', { name: label }).click();
await page.getByRole('option', { name: option, exact: true }).click();
};
const selectCard = async (card: typeof buyCard, side: '매수' | '매도') => {
if (await card.getAttribute('data-selected') !== 'true') {
await card.getByRole('group', { name: `${side} 전략 카드 이동 영역` }).press('Enter');
}
await expect(card).toHaveAttribute('data-selected', 'true');
};
const addBlock = async (card: typeof buyCard, label: string) => {
const blocks = card.locator('.draggable-strategy-block');
const before = await blocks.count();
await page.getByRole('button', { name: `${label} 블록 추가` }).last().press('Enter');
await expect(blocks).toHaveCount(before + 1);
};
await choose(buyCard, 'RSI 반등 방향', '상승');
await buyCard.getByRole('spinbutton', { name: 'RSI 반등 값' }).fill('31');
await choose(buyCard, '거래량 비교', '초과');
await choose(buyCard, '거래량 값 선택', '최근 20봉 평균 거래량 2배');

await selectCard(buyCard, '매수');
await page.getByRole('tab', { name: /블록/ }).click();
for (const label of ['가격 비교', '가격 변화율', '평균선 교차']) {
await addBlock(buyCard, label);
}
await choose(buyCard, '가격 비교 비교', '초과');
await choose(buyCard, '가격 비교 값 선택', '이전 20봉 최고 가격');
await choose(buyCard, '가격 변화율 기준 선택', '당일 장 시작가');
await choose(buyCard, '가격 변화율 방향', '상승');
await buyCard.getByRole('spinbutton', { name: '가격 변화율 값' }).fill('2.5');
await choose(buyCard, '평균선 교차 방향', '상승');
await choose(buyCard, '평균선 교차 값 선택', '20봉 · 60봉');

await selectCard(sellCard, '매도');
await choose(sellCard, 'RSI 반등 방향', '하락');
await sellCard.getByRole('spinbutton', { name: 'RSI 반등 값' }).fill('69');
for (const label of ['현재 수익률', '보유 기간', '최고 수익률', '고점 대비 하락']) {
await addBlock(sellCard, label);
}
await choose(sellCard, '현재 수익률 방향', '손실');
await sellCard.getByRole('spinbutton', { name: '현재 수익률 값' }).fill('4');
await choose(sellCard, '보유 기간 값 선택', '5거래일');
await choose(sellCard, '최고 수익률 비교', '초과');
await sellCard.getByRole('spinbutton', { name: '최고 수익률 값' }).fill('12');
await choose(sellCard, '고점 대비 하락 비교', '초과');
await sellCard.getByRole('spinbutton', { name: '고점 대비 하락 값' }).fill('6');
await sellCard.getByRole('spinbutton', { name: '매도 비율' }).fill('50');

const validationResponse = page.waitForResponse((response) =>
response.url().endsWith(`/api/v1/strategies/${strategyId}/validations`)
&& response.request().method() === 'POST');
const documentSaveResponse = page.waitForResponse((response) =>
response.url().endsWith(`/api/v1/strategies/${strategyId}/document`)
&& response.request().method() === 'PUT');
await page.getByRole('button', { name: '저장', exact: true }).click();
const savedDocument = await (await documentSaveResponse).json() as {
semanticDocument: { groups: Array<{ blocks: Array<{ elementCode: string; parameters: Record<string, string> }> }> };
};
const validated = await validationResponse;
expect(validated.status()).toBe(201);
const validationBody = await validated.json() as { status: string; findings: Array<{ severity: string }> };
expect(validationBody.status).toBe('VALID');
expect(validationBody.findings.some((finding) => finding.severity === 'INFORMATION')).toBe(true);
await expect(page.getByRole('alert')).toContainText('검증된 출시 가능 상태로 저장했습니다.');

const savedConditions = savedDocument.semanticDocument.groups.flatMap((group) => group.blocks)
.filter((block) => block.elementCode !== 'BASIC_EQUAL_ALLOCATION_ORDER');
expect(savedConditions.map((block) => block.elementCode)).toEqual([
'BASIC_RSI_CROSS', 'BASIC_VOLUME_COMPARE', 'BASIC_PRICE_COMPARE', 'BASIC_PRICE_CHANGE_PERCENT',
'BASIC_SMA_CROSS', 'BASIC_RSI_CROSS', 'BASIC_POSITION_RETURN', 'BASIC_HOLDING_PERIOD',
'BASIC_PEAK_RETURN', 'BASIC_DRAWDOWN_FROM_PEAK',
]);
expect(savedConditions.find((block) => block.elementCode === 'BASIC_PRICE_CHANGE_PERCENT')?.parameters)
.toMatchObject({ base: 'SESSION_OPEN', direction: 'UP', thresholdPercent: '2.5', resolution: '30m' });
expect(savedConditions.find((block) => block.elementCode === 'BASIC_HOLDING_PERIOD')?.parameters)
.toMatchObject({ unit: 'TRADING_DAY', amount: '5', resolution: '30m' });

const persistedLease = page.waitForResponse((response) =>
response.url().endsWith('/edit-lease') && response.request().method() === 'POST' && response.status() === 201);
await page.reload();
await persistedLease;
await expect(page.getByRole('spinbutton', { name: '가격 변화율 값' })).toHaveValue('2.5');
await expect(page.getByRole('spinbutton', { name: '현재 수익률 값' })).toHaveValue('4');
await expect(page.getByRole('button', { name: '개인 봇 출시' })).toBeEnabled();

const botListResponse = page.waitForResponse((response) =>
response.url().endsWith('/api/v1/bots/operations') && response.request().method() === 'GET');
await page.goto('/bots');
Expand Down
20 changes: 16 additions & 4 deletions e2e/realApiGlobalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ export default async function globalSetup(): Promise<() => void> {
if (cleaning) return;
cleaning = true;
const failures: string[] = [];
delete process.env.A23_POSTGRES_CONTAINER;
delete process.env.A23_VERIFICATION_HMAC_KEY;
for (const container of ownedContainers) {
try {
if (resourceExists('container', container)) docker('rm', '-f', container);
Expand Down Expand Up @@ -103,6 +101,7 @@ export default async function globalSetup(): Promise<() => void> {
'-v', `${bundle}:/flyway/sql:ro`, 'redgate/flyway:11-alpine',
'-url=jdbc:postgresql://postgres:5432/a23', '-user=postgres', `-password=${databasePassword}`,
'validate');
seedStrategyInstruments(postgres);

docker('run', '-d', '--name', backend, '--network', network,
'--label', projectLabel, '--label', runLabel,
Expand All @@ -122,8 +121,6 @@ export default async function globalSetup(): Promise<() => void> {
'--project-cache-dir', '/tmp/a23-project-cache');
ownedContainers.add(backend);
await waitForBackend(backend);
process.env.A23_POSTGRES_CONTAINER = postgres;
process.env.A23_VERIFICATION_HMAC_KEY = verificationHmacKey;
started = true;
} finally {
if (!started) cleanup(true);
Expand All @@ -137,6 +134,21 @@ export default async function globalSetup(): Promise<() => void> {
};
}

function seedStrategyInstruments(postgres: string): void {
const rows = [
['52000000-0000-4000-8000-000000000001', '53000000-0000-4000-8000-000000000001', 'AAPL', 'STOCK'],
['52000000-0000-4000-8000-000000000002', '53000000-0000-4000-8000-000000000002', 'MSFT', 'STOCK'],
['52000000-0000-4000-8000-000000000003', '53000000-0000-4000-8000-000000000003', 'SPY', 'ETF'],
];
const instruments = rows.map(([instrumentId, , symbol, assetType]) =>
`('${instrumentId}','${assetType}'::market_data.asset_type,'XNAS','USD','e2e-${symbol}','2000-01-01',now())`).join(',');
const symbols = rows.map(([instrumentId, symbolId, symbol]) =>
`('${symbolId}','${instrumentId}','XNAS','${symbol}','2000-01-01T00:00:00Z')`).join(',');
docker('exec', postgres, 'psql', '-U', 'postgres', '-d', 'a23', '-v', 'ON_ERROR_STOP=1', '-c',
`insert into market_data.instruments (id,asset_type,primary_exchange_mic,currency_code,provider_reference,listed_at,created_at) values ${instruments}; `
+ `insert into market_data.instrument_symbols (id,instrument_id,exchange_mic,symbol,effective_from) values ${symbols};`);
}

function gradleCacheSource(): string {
const configured = process.env.A23_GRADLE_CACHE_DIR?.trim();
if (configured) {
Expand Down
4 changes: 3 additions & 1 deletion playwright.real-api.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ export default defineConfig({
retries: 0,
reporter: [['list']],
globalSetup: './e2e/realApiGlobalSetup.ts',
timeout: 120_000,
// This is a production-shaped account journey followed by a full composite
// strategy authoring pass, not a single-page smoke test.
timeout: 300_000,
expect: { timeout: 15_000 },
use: {
baseURL: `http://127.0.0.1:${appPort}`,
Expand Down
38 changes: 29 additions & 9 deletions src/AuthRoutes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { setSessionAccessToken } from './api/sessionAccessToken';
const balancedStyles = readFileSync('src/styles/balanced.css', 'utf8');

const accountClient = (overrides: Partial<AccountClient> = {}): AccountClient => ({
signup: vi.fn().mockResolvedValue({ accountId: 'account-1', verificationExpiresAt: '2026-08-06T00:00:00Z' }),
signup: vi.fn().mockResolvedValue({ accountId: 'account-1', verificationRequired: false, verificationExpiresAt: null }),
verifyEmail: vi.fn().mockResolvedValue(undefined),
resendVerification: vi.fn().mockResolvedValue({ verificationRequired: true, verificationExpiresAt: '2026-08-07T00:00:00Z' }),
// The real client publishes the session token on login; the guarded routes
Expand Down Expand Up @@ -247,7 +247,7 @@ describe('customer signup screen', () => {
expect(confirmation).toHaveAttribute('type', 'text');
});

it('asks the user to follow the email link and then continue to login', async () => {
it('continues directly to login after signup when verification is not required', async () => {
const client = accountClient();
window.history.replaceState({}, '', '/signup');
render(<App accountClient={client} />);
Expand All @@ -262,19 +262,39 @@ describe('customer signup screen', () => {
await userEvent.click(screen.getByRole('button', { name: '가입' }));
expect(client.signup).toHaveBeenCalledWith('new@example.com', 'StrongPass!2026');

expect(await screen.findByRole('status')).toHaveTextContent('인증 링크를 이메일로 보냈습니다.');
expect(screen.getByText('new@example.com')).toBeInTheDocument();
expect(screen.getByText("메일의 '이메일 인증하기' 버튼을 눌러 인증을 완료해 주세요.")).toBeInTheDocument();
expect(screen.queryByLabelText('가입 인증 코드')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '이메일 인증' })).not.toBeInTheDocument();
expect(await screen.findByRole('status')).toHaveTextContent('가입이 완료되었습니다. 바로 로그인할 수 있습니다.');
expect(client.verifyEmail).not.toHaveBeenCalled();
await waitFor(() => expect(window.location.pathname).toBe('/login'));
});

it('continues directly to login when signup does not require email verification', async () => {
const client = accountClient({
signup: vi.fn().mockResolvedValue({
accountId: 'account-1',
verificationRequired: false,
verificationExpiresAt: null,
}),
});
window.history.replaceState({}, '', '/signup');
render(<App accountClient={client} />);

await userEvent.type(await screen.findByLabelText('가입 이메일'), 'new@example.com');
await userEvent.type(screen.getByLabelText('가입 비밀번호'), 'ValidPass!2026');
await userEvent.type(screen.getByLabelText('가입 비밀번호 확인'), 'ValidPass!2026');
await userEvent.click(screen.getByRole('button', { name: '가입' }));

await userEvent.click(await screen.findByRole('button', { name: '로그인하러 가기' }));
await waitFor(() => expect(window.location.pathname).toBe('/login'));
expect(await screen.findByRole('status')).toHaveTextContent('가입이 완료되었습니다. 바로 로그인할 수 있습니다.');
});

it('resends the verification mail for the account the signup created', async () => {
const client = accountClient();
const client = accountClient({
signup: vi.fn().mockResolvedValue({
accountId: 'account-1',
verificationRequired: true,
verificationExpiresAt: '2026-08-06T00:00:00Z',
}),
});
window.history.replaceState({}, '', '/signup');
render(<App accountClient={client} />);

Expand Down
Loading
Loading