feat: 정식 사용자의 오픈 요청과 동의 기반 예약을 영속화한다

This commit is contained in:
2026-10-23 00:18:45 +09:00
parent 856187b315
commit 236f1bc4ed
24 changed files with 2333 additions and 142 deletions
@@ -8,14 +8,22 @@ const operationNames = (route: Route): string[] => {
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const installFixture = async (page: Page): Promise<string[]> => {
const installFixture = async (page: Page, opener = false): Promise<string[]> => {
const operations: string[] = [];
let savedRequest: Record<string, unknown> | null = null;
let policy = { enabled: true, lifetimeDays: 7, buildLeadMinutes: 10, endorsementThreshold: 2 as number | null };
await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'regular-user-session');
});
await page.route('**/gateway/api/trpc/**', async (route) => {
expect(route.request().headers()['x-session-token']).toBe('regular-user-session');
const results = operationNames(route).map((operation) => {
const url = new URL(route.request().url());
const raw =
route.request().method() === 'POST'
? route.request().postDataJSON()
: JSON.parse(url.searchParams.get('input') ?? '{}');
const results = operationNames(route).map((operation, index) => {
const input = url.searchParams.get('batch') === '1' ? raw[String(index)] : raw;
operations.push(operation);
if (operation === 'me') {
return response({
@@ -52,6 +60,7 @@ const installFixture = async (page: Page): Promise<string[]> => {
]);
}
if (operation === 'lobby.scenarios') {
expect(input).toEqual({ profileName: 'pya:default' });
return response([
{
id: 2701,
@@ -77,6 +86,48 @@ const installFixture = async (page: Page): Promise<string[]> => {
},
]);
}
if (operation === 'openRequests.list') {
expect(input).toEqual({ profileName: 'pya:default' });
return response({ policy, canRequest: true, requests: savedRequest ? [savedRequest] : [] });
}
if (operation === 'admin.openRequests.permissions') {
return response({ canAccept: opener, canManagePolicy: opener });
}
if (operation === 'openRequests.create') {
expect(Object.keys(input).sort()).toEqual(['install', 'profileName']);
expect(input.profileName).toBe('pya:default');
expect(input.install.preopenAt).toBe('2099-08-18T02:30:00.000Z');
expect(input.install.openAt).toBe('2099-08-18T03:00:00.000Z');
expect(input.install.scenarioId).toBe(2701);
expect(input.install).not.toHaveProperty('gitRef');
savedRequest = {
id: '11111111-1111-4111-8111-111111111111',
status: 'PENDING',
requesterName: '일반유저',
isMine: true,
announcement: { scenarioTitle: '아시아 명장전', defaultStatTotal: 310 },
install: input.install,
expiresAt: '2099-08-18T02:20:00.000Z',
createdAt: '2099-08-17T00:00:00.000Z',
endorsementCount: 0,
endorsed: false,
canEndorse: false,
operationStatus: null,
};
return response({ id: savedRequest.id, status: savedRequest.status });
}
if (operation === 'admin.openRequests.decide') {
expect(opener).toBe(true);
expect(input).toEqual({ id: savedRequest?.id, action: 'ACCEPT' });
savedRequest = { ...savedRequest, status: 'RESERVED', operationStatus: 'QUEUED' };
return response({ id: savedRequest.id, status: 'RESERVED' });
}
if (operation === 'admin.openRequests.updatePolicy') {
expect(opener).toBe(true);
expect(input.reason).toBe('테스트 서버 정책 변경');
policy = input.policy;
return response(policy);
}
throw new Error(`Unhandled tRPC operation: ${operation}`);
});
const batched = new URL(route.request().url()).searchParams.get('batch') === '1';
@@ -95,7 +146,7 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
const operations = await installFixture(page);
await page.goto('lobby');
const suggestionLink = page.getByRole('link', { name: '오픈 건의 양식 작성' });
const suggestionLink = page.getByRole('link', { name: '오픈 요청 · 목록' });
await expect(suggestionLink).toBeVisible();
await suggestionLink.hover();
await suggestionLink.focus();
@@ -103,8 +154,10 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
await suggestionLink.click();
await expect(page).toHaveURL(/\/gateway\/open-suggestion$/);
await expect(page.getByRole('heading', { name: '오픈 건의 양식' })).toBeVisible();
await expect(page.getByText('서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.')).toBeVisible();
await expect(page.getByRole('heading', { name: '오픈 요청', exact: true })).toBeVisible();
await expect(
page.getByText('복사는 문구만 복사하며, 오픈 요청 등록 버튼을 눌러야 요청이 저장됩니다.')
).toBeVisible();
await expect(page.getByTestId('scenario-summary')).toContainText('310');
await page.getByTestId('proposal-open').fill('2026-08-18T12:00');
@@ -166,7 +219,77 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges
await page.screenshot({ path: testInfo.outputPath('open-suggestion-mobile.png'), fullPage: true });
expect(operations).toContain('lobby.scenarios');
expect(operations.every((operation) => ['me', 'lobby.notice', 'lobby.profiles', 'lobby.scenarios'].includes(operation))).toBe(
true
);
expect(
operations.every((operation) =>
[
'me',
'lobby.notice',
'lobby.profiles',
'lobby.scenarios',
'openRequests.list',
'admin.openRequests.permissions',
].includes(operation)
)
).toBe(true);
});
test('registers a durable proposal and lets the opener accept and configure it across desktop/mobile', async ({
page,
}, testInfo) => {
const operations = await installFixture(page, true);
await page.goto('open-suggestion');
await expect(page.getByRole('heading', { name: '오픈 요청 목록' })).toBeVisible();
await page.getByTestId('proposal-preopen').fill('2099-08-18T11:30');
await page.getByTestId('proposal-open').fill('2099-08-18T12:00');
const submit = page.getByTestId('submit-open-request');
await submit.hover();
await submit.focus();
await expect(submit).toBeEnabled();
await submit.click();
const card = page.locator('.request-card');
await expect(card).toContainText('일반유저');
await expect(card).toContainText('수락 대기');
await page.reload();
await expect(card).toContainText('아시아 명장전');
await card.getByText('요청 설정 전체').click();
await expect(card).toContainText('시간 동기화');
await card.getByRole('button', { name: '수락하여 오픈 예약' }).hover();
await card.getByRole('button', { name: '수락하여 오픈 예약' }).click();
await expect(card).toContainText('오픈 예약');
await expect(card).toContainText('준비 예약');
await expect(card.getByRole('button', { name: '요청 철회' })).toHaveCount(0);
await page.getByText('서버 오픈 요청 정책', { exact: true }).click();
const policy = page.locator('.request-policy');
await policy.getByLabel('가오픈 전 준비 시간(분)').fill('25');
await policy.getByLabel('동의로 자동 예약').uncheck();
await expect(policy.getByLabel('필요 동의 수(요청자 제외)')).toHaveCount(0);
await policy.getByLabel('변경 사유').fill('테스트 서버 정책 변경');
await policy.getByRole('button', { name: '정책 저장' }).click();
await expect(page.getByText('이 서버는 오프너가 수락하면 예약됩니다.')).toBeVisible();
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await page.evaluate(() => document.fonts.ready);
const measurements = await page.locator('.request-list').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: rect.toJSON(),
color: style.color,
font: style.font,
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
html: element.outerHTML,
};
});
expect(measurements.documentWidth).toBe(viewport.width);
expect(measurements.rect.right).toBeLessThanOrEqual(viewport.width);
await writeFile(testInfo.outputPath(`requests-${viewport.width}.json`), JSON.stringify(measurements, null, 2));
await page.screenshot({ path: testInfo.outputPath(`requests-${viewport.width}.png`), fullPage: true });
}
expect(operations).toContain('openRequests.create');
expect(operations).toContain('admin.openRequests.decide');
expect(operations).toContain('admin.openRequests.updatePolicy');
});