Merge branch 'main' into feature/main-signup-kakao-gate
# Conflicts: # app/gateway-frontend/src/views/LobbyView.vue
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
code: -32603,
|
||||
data: {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
httpStatus: 500,
|
||||
path,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const installLobbyFixture = async (page: Page, options: { failLogout?: boolean } = {}) => {
|
||||
let loggedOut = false;
|
||||
let logoutBody = '';
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-session-token', 'playwright-session');
|
||||
window.localStorage.setItem('sammo-game-token', 'playwright-game-session');
|
||||
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'me') {
|
||||
return response(
|
||||
loggedOut
|
||||
? null
|
||||
: {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '테스터',
|
||||
roles: [],
|
||||
createdAt: '2026-07-26T00:00:00.000Z',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (operation === 'lobby.notice') {
|
||||
return response('');
|
||||
}
|
||||
if (operation === 'lobby.profiles') {
|
||||
return response([]);
|
||||
}
|
||||
if (operation === 'auth.logout') {
|
||||
logoutBody = route.request().postData() ?? '';
|
||||
if (options.failLogout) {
|
||||
return errorResponse(operation, '로그아웃 서버가 응답하지 않습니다.');
|
||||
}
|
||||
loggedOut = true;
|
||||
return response({ ok: true });
|
||||
}
|
||||
throw new Error(`Unhandled tRPC operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
return {
|
||||
logoutBody: () => logoutBody,
|
||||
};
|
||||
};
|
||||
|
||||
test('logs out through the server before clearing all browser session state', async ({ page }) => {
|
||||
const fixture = await installLobbyFixture(page);
|
||||
await page.goto('lobby');
|
||||
|
||||
const logout = page.locator('#btn_logout');
|
||||
await expect(logout).toBeVisible();
|
||||
const before = await logout.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
backgroundColor: style.backgroundColor,
|
||||
border: style.border,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
};
|
||||
});
|
||||
expect(before).toMatchObject({
|
||||
width: 200,
|
||||
height: 48,
|
||||
backgroundColor: 'rgb(48, 48, 48)',
|
||||
border: '0px none rgb(255, 255, 255)',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
});
|
||||
|
||||
await logout.hover();
|
||||
await logout.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/gateway\/$/);
|
||||
expect(fixture.logoutBody()).toContain('playwright-session');
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
session: window.localStorage.getItem('sammo-session-token'),
|
||||
game: window.localStorage.getItem('sammo-game-token'),
|
||||
profile: window.localStorage.getItem('sammo-game-profile'),
|
||||
}))
|
||||
)
|
||||
.toEqual({ session: null, game: null, profile: null });
|
||||
});
|
||||
|
||||
test('keeps the lobby and every token when server logout fails', async ({ page }) => {
|
||||
await installLobbyFixture(page, { failLogout: true });
|
||||
await page.goto('lobby');
|
||||
|
||||
await page.locator('#btn_logout').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/gateway\/lobby$/);
|
||||
await expect(page.getByRole('alert')).toContainText('로그아웃 서버가 응답하지 않습니다.');
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
session: window.localStorage.getItem('sammo-session-token'),
|
||||
game: window.localStorage.getItem('sammo-game-token'),
|
||||
profile: window.localStorage.getItem('sammo-game-profile'),
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
session: 'playwright-session',
|
||||
game: 'playwright-game-session',
|
||||
profile: 'che:default',
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../.
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: ['server-operations.spec.ts', 'lobby-admin-navigation.spec.ts'],
|
||||
testMatch: ['server-operations.spec.ts', 'lobby-admin-navigation.spec.ts', 'logout.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -28,14 +28,13 @@ const profiles = ref<LobbyProfile[]>([]);
|
||||
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
||||
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
||||
const entryLoading = ref<Record<string, boolean>>({});
|
||||
const logoutLoading = ref(false);
|
||||
const logoutError = ref('');
|
||||
const canAccessAdmin = computed(
|
||||
() =>
|
||||
me.value?.roles.some(
|
||||
(role) =>
|
||||
role === 'superuser' ||
|
||||
role === 'admin' ||
|
||||
role === 'admin.superuser' ||
|
||||
role.startsWith('admin.')
|
||||
role === 'superuser' || role === 'admin' || role === 'admin.superuser' || role.startsWith('admin.')
|
||||
) ?? false
|
||||
);
|
||||
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
|
||||
@@ -86,12 +85,28 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (logoutLoading.value) {
|
||||
return;
|
||||
}
|
||||
logoutError.value = '';
|
||||
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
||||
if (sessionToken) {
|
||||
if (!sessionToken) {
|
||||
await router.replace('/');
|
||||
return;
|
||||
}
|
||||
logoutLoading.value = true;
|
||||
try {
|
||||
await trpc.auth.logout.mutate({ sessionToken });
|
||||
window.localStorage.removeItem('sammo-session-token');
|
||||
window.localStorage.removeItem('sammo-game-token');
|
||||
window.localStorage.removeItem('sammo-game-profile');
|
||||
me.value = null;
|
||||
await router.replace('/');
|
||||
} catch (error) {
|
||||
logoutError.value = error instanceof Error ? error.message : '로그아웃에 실패했습니다.';
|
||||
} finally {
|
||||
logoutLoading.value = false;
|
||||
}
|
||||
await router.push('/');
|
||||
};
|
||||
|
||||
const handleKakaoVerification = async (): Promise<void> => {
|
||||
@@ -404,9 +419,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
{{ profileDetails[profile.profileName]?.turnTerm ?? '-' }}분 턴
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-zinc-500 py-8 text-center">
|
||||
지도를 불러오는 중...
|
||||
</div>
|
||||
<div v-else class="text-xs text-zinc-500 py-8 text-center">지도를 불러오는 중...</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-zinc-600 py-8 text-center">- 폐 쇄 중 -</div>
|
||||
</div>
|
||||
@@ -428,10 +441,12 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
비밀번호 & 전콘 & 탈퇴
|
||||
</RouterLink>
|
||||
<button
|
||||
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
||||
id="btn_logout"
|
||||
class="legacy-logout-button"
|
||||
:disabled="logoutLoading"
|
||||
@click="handleLogout"
|
||||
>
|
||||
로 그 아 웃
|
||||
{{ logoutLoading ? '로그아웃 중…' : '로 그 아 웃' }}
|
||||
</button>
|
||||
<RouterLink
|
||||
v-if="canAccessAdmin"
|
||||
@@ -441,7 +456,45 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
관리자 페이지
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p v-if="logoutError" class="px-6 pb-4 text-center text-sm text-red-400" role="alert">
|
||||
{{ logoutError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legacy-logout-button {
|
||||
box-sizing: border-box;
|
||||
width: 200px;
|
||||
height: 48px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #303030;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-family: Pretendard, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.legacy-logout-button:hover,
|
||||
.legacy-logout-button:focus,
|
||||
.legacy-logout-button:active {
|
||||
background: #303030;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.legacy-logout-button:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.legacy-logout-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user