diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index 18bd0473..06a2bd93 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -146,7 +146,8 @@ jobs: - name: Upload backend failure report artifact if: ${{ failure() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 + continue-on-error: true with: name: backend-test-failure-report path: | @@ -168,94 +169,31 @@ jobs: channel: "stable" cache: true - - name: Ensure browser for Flutter web tests - run: | - has_runnable_browser=false - for candidate in google-chrome google-chrome-stable chromium chromium-browser; do - if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then - has_runnable_browser=true - break - fi - done - - if [ "$has_runnable_browser" = true ]; then - echo "Runnable Chrome/Chromium already installed." - exit 0 - fi - - if command -v sudo >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y chromium || sudo apt-get install -y chromium-browser - else - apt-get update - apt-get install -y chromium || apt-get install -y chromium-browser - fi - - for candidate in google-chrome google-chrome-stable chromium chromium-browser; do - if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then - echo "Installed browser: $candidate" - exit 0 - fi - done - - echo "No runnable Chrome/Chromium found after install." - exit 1 - - name: Run userfront tests run: | cd userfront if [ -d test ]; then - CHROME_BIN="" - for candidate in google-chrome google-chrome-stable chromium chromium-browser; do - if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then - CHROME_BIN="$(command -v "$candidate")" - break - fi - done - if [ -z "$CHROME_BIN" ]; then - echo "Chrome/Chromium not found for web tests." - exit 1 - fi - - echo "Using browser: $CHROME_BIN" - export CHROME_EXECUTABLE="$CHROME_BIN" mkdir -p ../reports set +e - flutter test 2>&1 | tee ../reports/userfront-test-vm.log - vm_test_exit_code=${PIPESTATUS[0]} - - web_test_exit_code=0 - if [ "$vm_test_exit_code" -eq 0 ]; then - flutter test --platform chrome test/locale_storage_platform_test.dart 2>&1 | tee ../reports/userfront-test-web.log - web_test_exit_code=${PIPESTATUS[0]} - fi + flutter test 2>&1 | tee ../reports/userfront-test.log + test_exit_code=${PIPESTATUS[0]} set -e - if [ "$vm_test_exit_code" -ne 0 ] || [ "$web_test_exit_code" -ne 0 ]; then + if [ "$test_exit_code" -ne 0 ]; then { echo "# Userfront Test Failure Report" echo echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" echo "- Job: \`userfront-tests\`" - echo "- Browser: \`$CHROME_BIN\`" - echo "- VM Test Exit Code: \`$vm_test_exit_code\`" - echo "- Web Test Exit Code: \`$web_test_exit_code\`" + echo "- Exit Code: \`$test_exit_code\`" echo - echo "## Commands" - echo "1. \`flutter test\`" - echo "2. \`flutter test --platform chrome test/locale_storage_platform_test.dart\`" + echo "## Command" + echo "\`flutter test\`" echo - if [ -f ../reports/userfront-test-vm.log ]; then - echo "## VM Test Log Tail (last 200 lines)" + if [ -f ../reports/userfront-test.log ]; then + echo "## Test Log Tail (last 200 lines)" echo '```text' - tail -n 200 ../reports/userfront-test-vm.log - echo '```' - echo - fi - if [ -f ../reports/userfront-test-web.log ]; then - echo "## Web Test Log Tail (last 200 lines)" - echo '```text' - tail -n 200 ../reports/userfront-test-web.log + tail -n 200 ../reports/userfront-test.log echo '```' fi } > ../reports/userfront-test-failure-report.md @@ -265,6 +203,29 @@ jobs: echo "No userfront tests: skipping (test/ directory not found)." fi + - name: Ensure userfront failure report exists + if: ${{ failure() }} + run: | + mkdir -p reports + if [ -f reports/userfront-test-failure-report.md ]; then + exit 0 + fi + + { + echo "# Userfront Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`userfront-tests\`" + echo "- Reason: \`Job failed before detailed report generation\`" + echo + if [ -f reports/userfront-test.log ]; then + echo "## Test Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/userfront-test.log + echo '```' + fi + } > reports/userfront-test-failure-report.md + - name: Publish userfront failure summary if: ${{ failure() }} run: | @@ -274,13 +235,13 @@ jobs: - name: Upload userfront failure report artifact if: ${{ failure() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 + continue-on-error: true with: name: userfront-test-failure-report path: | reports/userfront-test-failure-report.md - reports/userfront-test-vm.log - reports/userfront-test-web.log + reports/userfront-test.log if-no-files-found: ignore adminfront-tests: @@ -300,13 +261,62 @@ jobs: - name: Install adminfront dependencies run: | + mkdir -p reports + set +e cd adminfront - npm ci + npm ci 2>&1 | tee ../reports/adminfront-install.log + install_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$install_exit_code" -ne 0 ]; then + { + echo "# Adminfront Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`adminfront-tests\`" + echo "- Reason: \`Dependency install failed\`" + echo "- Exit Code: \`$install_exit_code\`" + echo + echo "## Command" + echo "\`cd adminfront && npm ci\`" + echo + echo "## Install Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/adminfront-install.log + echo '```' + } > reports/adminfront-test-failure-report.md + exit 1 + fi - name: Provision browsers for adminfront tests run: | + set +e cd adminfront - npx playwright install --with-deps + npx playwright install --with-deps 2>&1 | tee ../reports/adminfront-provision.log + provision_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$provision_exit_code" -ne 0 ]; then + { + echo "# Adminfront Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`adminfront-tests\`" + echo "- Reason: \`Browser provisioning failed\`" + echo "- Exit Code: \`$provision_exit_code\`" + echo + echo "## Command" + echo "\`cd adminfront && npx playwright install --with-deps\`" + echo + echo "## Provision Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/adminfront-provision.log + echo '```' + } > reports/adminfront-test-failure-report.md + exit 1 + fi - name: Run adminfront tests run: | @@ -341,6 +351,43 @@ jobs: exit "$test_exit_code" + - name: Ensure adminfront failure report exists + if: ${{ failure() }} + run: | + mkdir -p reports + if [ -f reports/adminfront-test-failure-report.md ]; then + exit 0 + fi + + { + echo "# Adminfront Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`adminfront-tests\`" + echo "- Reason: \`Job failed before detailed report generation\`" + echo + if [ -f reports/adminfront-install.log ]; then + echo "## Install Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/adminfront-install.log + echo '```' + echo + fi + if [ -f reports/adminfront-provision.log ]; then + echo "## Provision Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/adminfront-provision.log + echo '```' + echo + fi + if [ -f reports/adminfront-test.log ]; then + echo "## Test Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/adminfront-test.log + echo '```' + fi + } > reports/adminfront-test-failure-report.md + - name: Publish adminfront failure summary if: ${{ failure() }} run: | @@ -350,11 +397,14 @@ jobs: - name: Upload adminfront failure report artifact if: ${{ failure() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 + continue-on-error: true with: name: adminfront-test-failure-report path: | reports/adminfront-test-failure-report.md + reports/adminfront-install.log + reports/adminfront-provision.log reports/adminfront-test.log adminfront/playwright-report adminfront/test-results @@ -377,13 +427,62 @@ jobs: - name: Install devfront dependencies run: | + mkdir -p reports + set +e cd devfront - npm ci + npm ci 2>&1 | tee ../reports/devfront-install.log + install_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$install_exit_code" -ne 0 ]; then + { + echo "# Devfront Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`devfront-tests\`" + echo "- Reason: \`Dependency install failed\`" + echo "- Exit Code: \`$install_exit_code\`" + echo + echo "## Command" + echo "\`cd devfront && npm ci\`" + echo + echo "## Install Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/devfront-install.log + echo '```' + } > reports/devfront-test-failure-report.md + exit 1 + fi - name: Provision browsers for devfront tests run: | + set +e cd devfront - npx playwright install --with-deps + npx playwright install --with-deps 2>&1 | tee ../reports/devfront-provision.log + provision_exit_code=${PIPESTATUS[0]} + cd .. + set -e + + if [ "$provision_exit_code" -ne 0 ]; then + { + echo "# Devfront Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`devfront-tests\`" + echo "- Reason: \`Browser provisioning failed\`" + echo "- Exit Code: \`$provision_exit_code\`" + echo + echo "## Command" + echo "\`cd devfront && npx playwright install --with-deps\`" + echo + echo "## Provision Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/devfront-provision.log + echo '```' + } > reports/devfront-test-failure-report.md + exit 1 + fi - name: Run devfront tests run: | @@ -418,6 +517,43 @@ jobs: exit "$test_exit_code" + - name: Ensure devfront failure report exists + if: ${{ failure() }} + run: | + mkdir -p reports + if [ -f reports/devfront-test-failure-report.md ]; then + exit 0 + fi + + { + echo "# Devfront Test Failure Report" + echo + echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`" + echo "- Job: \`devfront-tests\`" + echo "- Reason: \`Job failed before detailed report generation\`" + echo + if [ -f reports/devfront-install.log ]; then + echo "## Install Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/devfront-install.log + echo '```' + echo + fi + if [ -f reports/devfront-provision.log ]; then + echo "## Provision Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/devfront-provision.log + echo '```' + echo + fi + if [ -f reports/devfront-test.log ]; then + echo "## Test Log Tail (last 200 lines)" + echo '```text' + tail -n 200 reports/devfront-test.log + echo '```' + fi + } > reports/devfront-test-failure-report.md + - name: Publish devfront failure summary if: ${{ failure() }} run: | @@ -427,11 +563,14 @@ jobs: - name: Upload devfront failure report artifact if: ${{ failure() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 + continue-on-error: true with: name: devfront-test-failure-report path: | reports/devfront-test-failure-report.md + reports/devfront-install.log + reports/devfront-provision.log reports/devfront-test.log devfront/playwright-report devfront/test-results diff --git a/README.md b/README.md index 8d08715a..eb6aa65f 100644 --- a/README.md +++ b/README.md @@ -325,8 +325,8 @@ KETO_WRITE_URL = "http://keto:4467" ### 프런트 테스트 브라우저 프리비저닝 정책 - `userfront-tests` - - 실행 가능한 Chrome/Chromium을 탐지하고, 없으면 설치 후 테스트 실행 - - `CHROME_EXECUTABLE` 지정 후 웹 테스트를 실행 + - `flutter test`(VM)만 실행 + - `locale_storage` 정책 테스트는 엔진 단위로 통합되어 별도 브라우저 실행이 필요하지 않음 - `adminfront-tests`, `devfront-tests` - Playwright 기반 테스트 - `npx playwright install --with-deps`로 브라우저/OS 의존성을 사전 설치 @@ -340,11 +340,11 @@ KETO_WRITE_URL = "http://keto:4467" - `adminfront-test-failure-report` - `devfront-test-failure-report` -### userfront의 `skipped` 테스트 안내 -- `locale_storage_platform_test.dart`는 웹 전용 테스트(`kIsWeb`)를 포함합니다. -- 따라서 일반 `flutter test`(VM)에서는 일부 테스트가 `skipped` 되는 것이 정상입니다. -- 실제 웹 실행 검증은 별도 단계에서 수행합니다. - - `flutter test --platform chrome test/locale_storage_platform_test.dart` +### userfront `locale_storage` 테스트 정책 +- `locale_storage_platform_test.dart`는 `LocaleStorageEngine` 기반 정책 테스트로 통합되었습니다. +- 일반 `flutter test`(VM) 실행에 포함되며, 브라우저 전용 `kIsWeb` 케이스를 사용하지 않습니다. +- 단일 파일만 확인하려면 다음 명령을 사용합니다. + - `flutter test test/locale_storage_platform_test.dart` ### 로컬 개발 (Manual) Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능. diff --git a/adminfront/tests/user-management.spec.ts b/adminfront/tests/user-management.spec.ts index ebd4e730..1614c547 100644 --- a/adminfront/tests/user-management.spec.ts +++ b/adminfront/tests/user-management.spec.ts @@ -27,6 +27,10 @@ test("user create and delete flow", async ({ page }) => { const users: UserSummary[] = []; let idSeq = 1; + await page.addInitScript(() => { + window.localStorage.setItem("admin_session", "playwright-admin-session"); + }); + await page.route("**/api/v1/admin/users**", async (route) => { const request = route.request(); const url = new URL(request.url()); @@ -109,8 +113,14 @@ test("user create and delete flow", async ({ page }) => { }); await page.goto("/users"); + await expect(page).toHaveURL(/\/users$/); + await expect( + page.getByRole("heading", { name: "사용자 관리" }), + ).toBeVisible(); - await page.getByRole("link", { name: "사용자 추가" }).click(); + const addUserLink = page.getByRole("link", { name: "사용자 추가" }); + await expect(addUserLink).toBeVisible(); + await addUserLink.click(); await expect(page).toHaveURL(/\/users\/new$/); const uniqueEmail = `playwright-${Date.now()}@example.com`; diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts index a48c16fa..249e78e0 100644 --- a/devfront/tests/clients.spec.ts +++ b/devfront/tests/clients.spec.ts @@ -1,7 +1,68 @@ import { expect, test } from "@playwright/test"; test("clients page loads correctly", async ({ page }) => { + const nowInSeconds = Math.floor(Date.now() / 1000); + + await page.addInitScript((issuedAt) => { + const mockOidcUser = { + id_token: "playwright-id-token", + session_state: "playwright-session", + access_token: "playwright-access-token", + refresh_token: "playwright-refresh-token", + token_type: "Bearer", + scope: "openid profile email", + profile: { + sub: "playwright-user", + email: "playwright@example.com", + name: "Playwright User", + }, + expires_at: issuedAt + 3600, + }; + + // oidc-client-ts storage key format: oidc.user:{authority}:{client_id} + window.localStorage.setItem( + "oidc.user:http://localhost:5000/oidc:devfront", + JSON.stringify(mockOidcUser), + ); + window.localStorage.setItem( + "oidc.user:http://localhost:5000/oidc/:devfront", + JSON.stringify(mockOidcUser), + ); + }, nowInSeconds); + + await page.route("**/api/v1/dev/clients**", async (route) => { + if (route.request().method() !== "GET") { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: "Not found" }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + id: "client-playwright", + name: "Playwright Client", + type: "confidential", + status: "active", + createdAt: new Date().toISOString(), + redirectUris: ["http://localhost:5174/callback"], + scopes: ["openid", "profile", "email"], + }, + ], + limit: 50, + offset: 0, + }), + }); + }); + await page.goto("/clients"); + await expect(page).toHaveURL(/\/clients$/); // 타이틀 확인 await expect(page).toHaveTitle(/바론 개발자 서비스/); diff --git a/docs/test-plan/userfront-test-inventory.md b/docs/test-plan/userfront-test-inventory.md index 76d27e9a..217ab751 100644 --- a/docs/test-plan/userfront-test-inventory.md +++ b/docs/test-plan/userfront-test-inventory.md @@ -16,10 +16,12 @@ | `userfront/test/locale_registry_test.dart` | `extractSupportedLocaleCodesFromAssets excludes template and invalid` | i18n 로케일 해석/정규화 규칙 검증 | | `userfront/test/locale_registry_test.dart` | `fallback locale prefers en when available` | fallback/복구 경로 검증 | | `userfront/test/locale_registry_test.dart` | `fallback locale uses first sorted code when en is absent` | fallback/복구 경로 검증 | -| `userfront/test/locale_storage_platform_test.dart` | `legacy key에서 locale로 마이그레이션 (웹)` | i18n 로케일 해석/정규화 규칙 검증 | -| `userfront/test/locale_storage_platform_test.dart` | `localStorage write/read (웹)` | 브라우저 스토리지 저장/복원 정책 검증 | -| `userfront/test/locale_storage_platform_test.dart` | `localStorage 접근이 차단되면 sessionStorage로 fallback (웹)` | fallback/복구 경로 검증 | -| `userfront/test/locale_storage_platform_test.dart` | `localStorage 접근이 차단되면 메모리 fallback (웹)` | fallback/복구 경로 검증 | +| `userfront/test/locale_storage_platform_test.dart` | `기본 모드에서는 local 우선으로 저장/조회한다` | locale 저장 우선순위 정책 검증 | +| `userfront/test/locale_storage_platform_test.dart` | `legacy key를 읽으면 current key로 마이그레이션한다` | legacy migration 회귀 방지 검증 | +| `userfront/test/locale_storage_platform_test.dart` | `localStorage가 차단되면 sessionStorage로 fallback 한다` | fallback/복구 경로 검증 | +| `userfront/test/locale_storage_platform_test.dart` | `local/session 모두 차단되면 memory fallback 한다` | fallback/복구 경로 검증 | +| `userfront/test/locale_storage_platform_test.dart` | `sessionOnly 모드에서는 session + memory만 사용한다` | 테스트 강제 모드 정책 검증 | +| `userfront/test/locale_storage_platform_test.dart` | `memoryOnly 모드에서는 memory만 사용한다` | 테스트 강제 모드 정책 검증 | | `userfront/test/locale_utils_test.dart` | `buildLocalizedPath applies locale` | i18n 로케일 해석/정규화 규칙 검증 | | `userfront/test/locale_utils_test.dart` | `buildLocalizedPath keeps unknown 2-letter prefix as path` | 리다이렉트/쿼리 보존 규칙 검증 | | `userfront/test/locale_utils_test.dart` | `buildLocalizedPath preserves fragment` | 리다이렉트/쿼리 보존 규칙 검증 | @@ -49,6 +51,7 @@ | `userfront/test/password_login_flow_policy_test.dart` | `redirectTo가 있으면 OIDC redirect를 우선한다` | 로그인 분기/라우팅 규칙 검증 | | `userfront/test/router_redirect_widget_test.dart` | `/login: login_challenge와 redirect_uri를 전달` | 리다이렉트/쿼리 보존 규칙 검증 | | `userfront/test/router_redirect_widget_test.dart` | `로그인 상태: profile 접근 시 signin으로 리다이렉트하지 않음` | 로그인 분기/라우팅 규칙 검증 | +| `userfront/test/router_redirect_widget_test.dart` | `로그인 후 같은 브라우저 새 창/팝업에서도 세션이 유지된다` | 로그인 세션 지속성(동일 브라우저) 검증 | | `userfront/test/router_redirect_widget_test.dart` | `비로그인: redirect_uri/login_challenge가 signin으로 전달` | 리다이렉트/쿼리 보존 규칙 검증 | | `userfront/test/router_redirect_widget_test.dart` | `비로그인: redirect_uri가 없으면 redirect_url을 전달` | 리다이렉트/쿼리 보존 규칙 검증 | | `userfront/test/widget_test.dart` | `BaronSSOApp builds` | 기본 앱 렌더링 스모크 검증 | diff --git a/docs/trouble-shooting/issue-281-locale-storage-refactor-plan.md b/docs/trouble-shooting/issue-281-locale-storage-refactor-plan.md index 8ab92634..d17993d0 100644 --- a/docs/trouble-shooting/issue-281-locale-storage-refactor-plan.md +++ b/docs/trouble-shooting/issue-281-locale-storage-refactor-plan.md @@ -41,7 +41,7 @@ ## 완료 기준(DoD) - 정책 변경 시 수정 포인트가 공통 모듈 중심으로 줄어듭니다. - 기존 locale 저장/조회 동작과 migration 동작이 유지됩니다. -- 웹 테스트가 안정적으로 통과하며 fallback/migration 회귀 케이스가 포함됩니다. +- VM 기반 테스트가 안정적으로 통과하며 fallback/migration 회귀 케이스가 포함됩니다. ## 구현 시 주의사항 - 외부에서 사용하는 public API 시그니처는 가능한 유지합니다. @@ -83,10 +83,12 @@ - lint: `inputs.run_lint` - backend-tests: `inputs.run_backend_tests` - userfront-tests: `inputs.run_userfront_tests` -- userfront-tests에 웹 테스트 실행 유지 - - `flutter test --platform chrome test/locale_storage_platform_test.dart` +- userfront-tests 정책 정리 + - `flutter test` 단일 실행으로 운영 + - `locale_storage` 정책 검증은 VM 테스트(`locale_storage_platform_test.dart`)로 통합 + - 브라우저 설치/`--platform chrome` 단계 제거 ### 검증 결과 - `cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos` 통과 - `cd userfront && flutter test` 통과 -- `cd userfront && CHROME_EXECUTABLE= flutter test --platform chrome test/locale_storage_platform_test.dart` 통과 +- `cd userfront && flutter test test/locale_storage_platform_test.dart` 통과 diff --git a/userfront/lib/core/i18n/locale_storage_engine.dart b/userfront/lib/core/i18n/locale_storage_engine.dart new file mode 100644 index 00000000..0c907b40 --- /dev/null +++ b/userfront/lib/core/i18n/locale_storage_engine.dart @@ -0,0 +1,244 @@ +import 'locale_storage_backend.dart'; +import 'locale_storage_policy.dart'; + +enum _StorageTarget { local, session, memory } + +abstract interface class LocaleStorageTarget { + String? read(String key); + + bool write(String key, String value); + + bool remove(String key); + + void clear(); +} + +class LocaleStorageNoopTarget implements LocaleStorageTarget { + const LocaleStorageNoopTarget(); + + @override + String? read(String key) => null; + + @override + bool write(String key, String value) => false; + + @override + bool remove(String key) => false; + + @override + void clear() {} +} + +class LocaleStorageCallbackTarget implements LocaleStorageTarget { + LocaleStorageCallbackTarget({ + required this.readCallback, + required this.writeCallback, + required this.removeCallback, + required this.clearCallback, + }); + + final String? Function(String key) readCallback; + final void Function(String key, String value) writeCallback; + final void Function(String key) removeCallback; + final void Function() clearCallback; + + @override + String? read(String key) => readCallback(key); + + @override + bool write(String key, String value) { + writeCallback(key, value); + return true; + } + + @override + bool remove(String key) { + removeCallback(key); + return true; + } + + @override + void clear() => clearCallback(); +} + +class LocaleStorageEngine implements LocaleStorageBackend { + LocaleStorageEngine({ + required LocaleStorageTarget localTarget, + required LocaleStorageTarget sessionTarget, + }) : _localTarget = localTarget, + _sessionTarget = sessionTarget; + + final LocaleStorageTarget _localTarget; + final LocaleStorageTarget _sessionTarget; + final Map _memory = {}; + LocaleStorageTestMode _mode = LocaleStorageTestMode.normal; + + List<_StorageTarget> _fallbackTargets() { + switch (_mode) { + case LocaleStorageTestMode.normal: + return [ + _StorageTarget.local, + _StorageTarget.session, + _StorageTarget.memory, + ]; + case LocaleStorageTestMode.sessionOnly: + return [_StorageTarget.session, _StorageTarget.memory]; + case LocaleStorageTestMode.memoryOnly: + return [_StorageTarget.memory]; + } + } + + String? _safeReadTarget(_StorageTarget target, String key) { + try { + switch (target) { + case _StorageTarget.local: + return _localTarget.read(key); + case _StorageTarget.session: + return _sessionTarget.read(key); + case _StorageTarget.memory: + return _memory[key]; + } + } catch (_) { + return null; + } + } + + bool _safeWriteTarget(_StorageTarget target, String key, String value) { + try { + switch (target) { + case _StorageTarget.local: + return _localTarget.write(key, value); + case _StorageTarget.session: + return _sessionTarget.write(key, value); + case _StorageTarget.memory: + _memory[key] = value; + return true; + } + } catch (_) { + return false; + } + } + + bool _safeRemoveTarget(_StorageTarget target, String key) { + try { + switch (target) { + case _StorageTarget.local: + return _localTarget.remove(key); + case _StorageTarget.session: + return _sessionTarget.remove(key); + case _StorageTarget.memory: + _memory.remove(key); + return true; + } + } catch (_) { + return false; + } + } + + void _safeClearTarget(_StorageTarget target) { + try { + switch (target) { + case _StorageTarget.local: + _localTarget.clear(); + case _StorageTarget.session: + _sessionTarget.clear(); + case _StorageTarget.memory: + _memory.clear(); + } + } catch (_) { + // 테스트 정리 단계에서는 clear 예외를 무시합니다. + } + } + + String? _readByKey(String key) { + for (final target in _fallbackTargets()) { + final value = _safeReadTarget(target, key); + if (value != null) { + return value; + } + } + return null; + } + + void _writeByKey(String key, String value) { + for (final target in _fallbackTargets()) { + if (_safeWriteTarget(target, key, value)) { + return; + } + } + } + + void _removeEverywhere(String key) { + _safeRemoveTarget(_StorageTarget.local, key); + _safeRemoveTarget(_StorageTarget.session, key); + _memory.remove(key); + } + + @override + String? read() { + final current = _readByKey(LocaleStoragePolicy.currentKey); + if (LocaleStoragePolicy.hasValue(current)) { + return current; + } + + final legacy = _readByKey(LocaleStoragePolicy.legacyKey); + if (LocaleStoragePolicy.shouldMigrateLegacy( + current: current, + legacy: legacy, + )) { + _writeByKey(LocaleStoragePolicy.currentKey, legacy!); + _removeEverywhere(LocaleStoragePolicy.legacyKey); + return legacy; + } + + return null; + } + + @override + void write(String locale) { + _writeByKey(LocaleStoragePolicy.currentKey, locale); + } + + @override + void setTestMode(LocaleStorageTestMode mode) { + _mode = mode; + } + + @override + void clearForTests() { + _safeClearTarget(_StorageTarget.local); + _safeClearTarget(_StorageTarget.session); + _memory.clear(); + _mode = LocaleStorageTestMode.normal; + } + + @override + void seedLegacyForTests(String locale) { + _writeByKey(LocaleStoragePolicy.legacyKey, locale); + } + + @override + LocaleStorageDebugState debugStateForTests() { + return LocaleStorageDebugState( + mode: _mode, + localCurrent: _safeReadTarget( + _StorageTarget.local, + LocaleStoragePolicy.currentKey, + ), + localLegacy: _safeReadTarget( + _StorageTarget.local, + LocaleStoragePolicy.legacyKey, + ), + sessionCurrent: _safeReadTarget( + _StorageTarget.session, + LocaleStoragePolicy.currentKey, + ), + sessionLegacy: _safeReadTarget( + _StorageTarget.session, + LocaleStoragePolicy.legacyKey, + ), + memoryCurrent: _memory[LocaleStoragePolicy.currentKey], + memoryLegacy: _memory[LocaleStoragePolicy.legacyKey], + ); + } +} diff --git a/userfront/lib/core/i18n/locale_storage_stub.dart b/userfront/lib/core/i18n/locale_storage_stub.dart index 1fb79030..56ab3c13 100644 --- a/userfront/lib/core/i18n/locale_storage_stub.dart +++ b/userfront/lib/core/i18n/locale_storage_stub.dart @@ -1,59 +1,7 @@ import 'locale_storage_backend.dart'; -import 'locale_storage_policy.dart'; +import 'locale_storage_engine.dart'; -class LocaleStorageImpl implements LocaleStorageBackend { - final Map _memory = {}; - LocaleStorageTestMode _mode = LocaleStorageTestMode.normal; - - @override - String? read() { - final current = _memory[LocaleStoragePolicy.currentKey]; - if (LocaleStoragePolicy.hasValue(current)) { - return current; - } - - final legacy = _memory[LocaleStoragePolicy.legacyKey]; - if (LocaleStoragePolicy.shouldMigrateLegacy( - current: current, - legacy: legacy, - )) { - _memory[LocaleStoragePolicy.currentKey] = legacy!; - _memory.remove(LocaleStoragePolicy.legacyKey); - return legacy; - } - - return null; - } - - @override - void write(String locale) { - _memory[LocaleStoragePolicy.currentKey] = locale; - } - - @override - void setTestMode(LocaleStorageTestMode mode) { - _mode = mode; - } - - @override - void clearForTests() { - _memory.clear(); - _mode = LocaleStorageTestMode.normal; - } - - @override - void seedLegacyForTests(String locale) { - _memory[LocaleStoragePolicy.legacyKey] = locale; - } - - @override - LocaleStorageDebugState debugStateForTests() { - return LocaleStorageDebugState( - mode: _mode, - memoryCurrent: _memory[LocaleStoragePolicy.currentKey], - memoryLegacy: _memory[LocaleStoragePolicy.legacyKey], - ); - } -} - -final LocaleStorageBackend localeStorage = LocaleStorageImpl(); +final LocaleStorageBackend localeStorage = LocaleStorageEngine( + localTarget: const LocaleStorageNoopTarget(), + sessionTarget: const LocaleStorageNoopTarget(), +); diff --git a/userfront/lib/core/i18n/locale_storage_web.dart b/userfront/lib/core/i18n/locale_storage_web.dart index 05e436e9..8b189c62 100644 --- a/userfront/lib/core/i18n/locale_storage_web.dart +++ b/userfront/lib/core/i18n/locale_storage_web.dart @@ -3,198 +3,20 @@ import 'package:web/web.dart' as web; import 'locale_storage_backend.dart'; -import 'locale_storage_policy.dart'; +import 'locale_storage_engine.dart'; -enum _StorageTarget { local, session, memory } - -class LocaleStorageImpl implements LocaleStorageBackend { - static final Map _memory = {}; - static LocaleStorageTestMode _mode = LocaleStorageTestMode.normal; - - List<_StorageTarget> _fallbackTargets() { - switch (_mode) { - case LocaleStorageTestMode.normal: - return [ - _StorageTarget.local, - _StorageTarget.session, - _StorageTarget.memory, - ]; - case LocaleStorageTestMode.sessionOnly: - return [_StorageTarget.session, _StorageTarget.memory]; - case LocaleStorageTestMode.memoryOnly: - return [_StorageTarget.memory]; - } - } - - String? _safeReadLocal(String key) { - try { - return web.window.localStorage.getItem(key); - } catch (_) { - return null; - } - } - - String? _safeReadSession(String key) { - try { - return web.window.sessionStorage.getItem(key); - } catch (_) { - return null; - } - } - - bool _safeWriteLocal(String key, String value) { - try { - web.window.localStorage.setItem(key, value); - return true; - } catch (_) { - return false; - } - } - - bool _safeWriteSession(String key, String value) { - try { - web.window.sessionStorage.setItem(key, value); - return true; - } catch (_) { - return false; - } - } - - bool _safeRemoveLocal(String key) { - try { - web.window.localStorage.removeItem(key); - return true; - } catch (_) { - return false; - } - } - - bool _safeRemoveSession(String key) { - try { - web.window.sessionStorage.removeItem(key); - return true; - } catch (_) { - return false; - } - } - - void _safeClearLocal() { - try { - web.window.localStorage.clear(); - } catch (_) { - // 스토리지 접근이 차단된 경우는 테스트 정리에서 무시합니다. - } - } - - void _safeClearSession() { - try { - web.window.sessionStorage.clear(); - } catch (_) { - // 스토리지 접근이 차단된 경우는 테스트 정리에서 무시합니다. - } - } - - String? _readFromTarget(_StorageTarget target, String key) { - switch (target) { - case _StorageTarget.local: - return _safeReadLocal(key); - case _StorageTarget.session: - return _safeReadSession(key); - case _StorageTarget.memory: - return _memory[key]; - } - } - - bool _writeToTarget(_StorageTarget target, String key, String value) { - switch (target) { - case _StorageTarget.local: - return _safeWriteLocal(key, value); - case _StorageTarget.session: - return _safeWriteSession(key, value); - case _StorageTarget.memory: - _memory[key] = value; - return true; - } - } - - String? _readByKey(String key) { - for (final target in _fallbackTargets()) { - final value = _readFromTarget(target, key); - if (value != null) { - return value; - } - } - return null; - } - - void _writeByKey(String key, String value) { - for (final target in _fallbackTargets()) { - if (_writeToTarget(target, key, value)) { - return; - } - } - } - - void _removeEverywhere(String key) { - _safeRemoveLocal(key); - _safeRemoveSession(key); - _memory.remove(key); - } - - @override - String? read() { - final current = _readByKey(LocaleStoragePolicy.currentKey); - if (LocaleStoragePolicy.hasValue(current)) { - return current; - } - - final legacy = _readByKey(LocaleStoragePolicy.legacyKey); - if (LocaleStoragePolicy.shouldMigrateLegacy( - current: current, - legacy: legacy, - )) { - _writeByKey(LocaleStoragePolicy.currentKey, legacy!); - _removeEverywhere(LocaleStoragePolicy.legacyKey); - return legacy; - } - return null; - } - - @override - void write(String locale) { - _writeByKey(LocaleStoragePolicy.currentKey, locale); - } - - @override - void setTestMode(LocaleStorageTestMode mode) { - _mode = mode; - } - - @override - void clearForTests() { - _safeClearLocal(); - _safeClearSession(); - _memory.clear(); - _mode = LocaleStorageTestMode.normal; - } - - @override - void seedLegacyForTests(String locale) { - _writeByKey(LocaleStoragePolicy.legacyKey, locale); - } - - @override - LocaleStorageDebugState debugStateForTests() { - return LocaleStorageDebugState( - mode: _mode, - localCurrent: _safeReadLocal(LocaleStoragePolicy.currentKey), - localLegacy: _safeReadLocal(LocaleStoragePolicy.legacyKey), - sessionCurrent: _safeReadSession(LocaleStoragePolicy.currentKey), - sessionLegacy: _safeReadSession(LocaleStoragePolicy.legacyKey), - memoryCurrent: _memory[LocaleStoragePolicy.currentKey], - memoryLegacy: _memory[LocaleStoragePolicy.legacyKey], - ); - } -} - -final LocaleStorageBackend localeStorage = LocaleStorageImpl(); +final LocaleStorageBackend localeStorage = LocaleStorageEngine( + localTarget: LocaleStorageCallbackTarget( + readCallback: (key) => web.window.localStorage.getItem(key), + writeCallback: (key, value) => web.window.localStorage.setItem(key, value), + removeCallback: (key) => web.window.localStorage.removeItem(key), + clearCallback: () => web.window.localStorage.clear(), + ), + sessionTarget: LocaleStorageCallbackTarget( + readCallback: (key) => web.window.sessionStorage.getItem(key), + writeCallback: (key, value) => + web.window.sessionStorage.setItem(key, value), + removeCallback: (key) => web.window.sessionStorage.removeItem(key), + clearCallback: () => web.window.sessionStorage.clear(), + ), +); diff --git a/userfront/test/locale_storage_platform_test.dart b/userfront/test/locale_storage_platform_test.dart index 119439e2..2fcf35d1 100644 --- a/userfront/test/locale_storage_platform_test.dart +++ b/userfront/test/locale_storage_platform_test.dart @@ -1,75 +1,138 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:userfront/core/i18n/locale_storage.dart'; import 'package:userfront/core/i18n/locale_storage_backend.dart'; +import 'package:userfront/core/i18n/locale_storage_engine.dart'; +import 'package:userfront/core/i18n/locale_storage_policy.dart'; + +class _FakeTarget implements LocaleStorageTarget { + _FakeTarget(); + + final Map store = {}; + bool throwOnRead = false; + bool throwOnWrite = false; + bool throwOnRemove = false; + bool throwOnClear = false; + + @override + String? read(String key) { + if (throwOnRead) { + throw StateError('read blocked'); + } + return store[key]; + } + + @override + bool write(String key, String value) { + if (throwOnWrite) { + throw StateError('write blocked'); + } + store[key] = value; + return true; + } + + @override + bool remove(String key) { + if (throwOnRemove) { + throw StateError('remove blocked'); + } + store.remove(key); + return true; + } + + @override + void clear() { + if (throwOnClear) { + throw StateError('clear blocked'); + } + store.clear(); + } +} void main() { + late _FakeTarget localTarget; + late _FakeTarget sessionTarget; + late LocaleStorageEngine engine; + setUp(() { - LocaleStorage.setTestModeForTests(LocaleStorageTestMode.normal); - LocaleStorage.clearForTests(); + localTarget = _FakeTarget(); + sessionTarget = _FakeTarget(); + engine = LocaleStorageEngine( + localTarget: localTarget, + sessionTarget: sessionTarget, + ); + engine.clearForTests(); }); - tearDown(() { - LocaleStorage.setTestModeForTests(LocaleStorageTestMode.normal); - LocaleStorage.clearForTests(); - }); + test('기본 모드에서는 local 우선으로 저장/조회한다', () { + engine.write('ko'); + expect(engine.read(), 'ko'); - test('localStorage write/read (웹)', () { - if (!kIsWeb) { - return; - } - - LocaleStorage.write('ko'); - expect(LocaleStorage.read(), 'ko'); - - final state = LocaleStorage.debugStateForTests(); + final state = engine.debugStateForTests(); expect(state.localCurrent, 'ko'); expect(state.sessionCurrent, isNull); expect(state.memoryCurrent, isNull); - }, skip: !kIsWeb); + }); - test('legacy key에서 locale로 마이그레이션 (웹)', () { - if (!kIsWeb) { - return; - } + test('legacy key를 읽으면 current key로 마이그레이션한다', () { + localTarget.store[LocaleStoragePolicy.legacyKey] = 'en'; - LocaleStorage.seedLegacyForTests('en'); - expect(LocaleStorage.read(), 'en'); + expect(engine.read(), 'en'); - final state = LocaleStorage.debugStateForTests(); + final state = engine.debugStateForTests(); expect(state.localCurrent, 'en'); expect(state.localLegacy, isNull); - }, skip: !kIsWeb); + }); - test('localStorage 접근이 차단되면 메모리 fallback (웹)', () { - if (!kIsWeb) { - return; - } + test('localStorage가 차단되면 sessionStorage로 fallback 한다', () { + localTarget + ..throwOnRead = true + ..throwOnWrite = true + ..throwOnRemove = true; - LocaleStorage.forceMemoryStorageForTests(true); + engine.write('ko'); + expect(engine.read(), 'ko'); - LocaleStorage.write('en'); - expect(LocaleStorage.read(), 'en'); - - final state = LocaleStorage.debugStateForTests(); - expect(state.localCurrent, isNull); - expect(state.sessionCurrent, isNull); - expect(state.memoryCurrent, 'en'); - }, skip: !kIsWeb); - - test('localStorage 접근이 차단되면 sessionStorage로 fallback (웹)', () { - if (!kIsWeb) { - return; - } - - LocaleStorage.forceSessionStorageForTests(true); - - LocaleStorage.write('ko'); - expect(LocaleStorage.read(), 'ko'); - - final state = LocaleStorage.debugStateForTests(); + final state = engine.debugStateForTests(); expect(state.localCurrent, isNull); expect(state.sessionCurrent, 'ko'); expect(state.memoryCurrent, isNull); - }, skip: !kIsWeb); + }); + + test('local/session 모두 차단되면 memory fallback 한다', () { + localTarget + ..throwOnRead = true + ..throwOnWrite = true + ..throwOnRemove = true; + sessionTarget + ..throwOnRead = true + ..throwOnWrite = true + ..throwOnRemove = true; + + engine.write('en'); + expect(engine.read(), 'en'); + + final state = engine.debugStateForTests(); + expect(state.localCurrent, isNull); + expect(state.sessionCurrent, isNull); + expect(state.memoryCurrent, 'en'); + }); + + test('sessionOnly 모드에서는 session + memory만 사용한다', () { + engine.setTestMode(LocaleStorageTestMode.sessionOnly); + engine.write('ko'); + + final state = engine.debugStateForTests(); + expect(state.localCurrent, isNull); + expect(state.sessionCurrent, 'ko'); + expect(state.memoryCurrent, isNull); + }); + + test('memoryOnly 모드에서는 memory만 사용한다', () { + engine.setTestMode(LocaleStorageTestMode.memoryOnly); + engine.write('en'); + + final state = engine.debugStateForTests(); + expect(state.localCurrent, isNull); + expect(state.sessionCurrent, isNull); + expect(state.memoryCurrent, 'en'); + }); } diff --git a/userfront/test/router_redirect_widget_test.dart b/userfront/test/router_redirect_widget_test.dart index 105d547e..a11580f8 100644 --- a/userfront/test/router_redirect_widget_test.dart +++ b/userfront/test/router_redirect_widget_test.dart @@ -152,4 +152,25 @@ void main() { expect(find.text('profile-page'), findsOneWidget); expect(find.textContaining('signin|'), findsNothing); }); + + testWidgets('로그인 후 같은 브라우저 새 창/팝업에서도 세션이 유지된다', (tester) async { + await tester.pumpWidget(_buildTestApp('/en/signin')); + await tester.pumpAndSettle(); + expect(find.textContaining('signin|'), findsOneWidget); + + AuthTokenStore.setToken('persisted-token', provider: 'ory'); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + await tester.pumpWidget( + _buildTestApp( + '/en/profile?redirect_uri=https%3A%2F%2Frp.example.com%2Fcb', + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('profile-page'), findsOneWidget); + expect(find.textContaining('signin|'), findsNothing); + }); }