forked from baron/baron-sso
locale_store 리팩토링
This commit is contained in:
@@ -8,9 +8,26 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_lint:
|
||||
description: "Run linters for Go and Flutter"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_backend_tests:
|
||||
description: "Run backend Go tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
run_userfront_tests:
|
||||
description: "Run userfront Flutter tests"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_lint == true }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -54,7 +71,7 @@ jobs:
|
||||
|
||||
backend-tests:
|
||||
needs: lint
|
||||
if: ${{ always() }}
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_backend_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
@@ -88,7 +105,7 @@ jobs:
|
||||
|
||||
userfront-tests:
|
||||
needs: lint
|
||||
if: ${{ always() }}
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_tests == true) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -100,12 +117,34 @@ jobs:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Ensure browser for Flutter web tests
|
||||
run: |
|
||||
if command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1 || command -v chromium >/dev/null 2>&1; then
|
||||
echo "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-browser || sudo apt-get install -y chromium
|
||||
else
|
||||
apt-get update
|
||||
apt-get install -y chromium-browser || apt-get install -y chromium
|
||||
fi
|
||||
|
||||
- name: Run userfront tests
|
||||
run: |
|
||||
cd userfront
|
||||
if [ -d test ]; then
|
||||
CHROME_BIN="$(command -v google-chrome || command -v google-chrome-stable || command -v chromium-browser || command -v chromium || true)"
|
||||
if [ -z "$CHROME_BIN" ]; then
|
||||
echo "Chrome/Chromium not found for web tests."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export CHROME_EXECUTABLE="$CHROME_BIN"
|
||||
flutter test
|
||||
# flutter test --platform chrome test/locale_storage_platform_test.dart
|
||||
flutter test --platform chrome test/locale_storage_platform_test.dart
|
||||
else
|
||||
echo "No userfront tests: skipping (test/ directory not found)."
|
||||
fi
|
||||
|
||||
88
docs/organization-chart-policy.md
Normal file
88
docs/organization-chart-policy.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Organization Chart Architecture & Implementation Policy (ADR)
|
||||
|
||||
## 1. Overview (개요)
|
||||
본 문서는 Baron SSO 내 `adminfront`에서 사용될 **조직도(Organization Chart) 및 다중 테넌시(Multi-Tenancy) 대응 기능**에 대한 아키텍처 결정 사항(Architecture Decision Record)과 세부 구현 방향을 정의합니다.
|
||||
|
||||
이 정책은 기존 B2B 테넌트 모델(`Tenant`)과 사내 사용자 그룹 모델(`UserGroup`), 그리고 Ory Keto 기반의 권한 제어(ReBAC) 시스템 간의 일관성을 유지하면서, 복잡하고 다양한 형태의 고객사별 조직 구조(N-Depth)를 지원하기 위해 작성되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Architectural Decisions (핵심 아키텍처 결정)
|
||||
|
||||
### 2.1 B2B Tenant vs. Internal UserGroup Hierarchy (테넌트 vs. 유저그룹 계층화)
|
||||
조직의 계층(Hierarchy)을 표현하기 위해 `Tenant` 자체를 중첩(Nested)시킬 것인지, 아니면 단일 `Tenant` 내의 `UserGroup`을 중첩시킬 것인지에 대한 결정입니다.
|
||||
|
||||
* **Decision (결정):** 조직도는 **`UserGroup` 내부의 자기 참조(`parent_id`)를 통해 계층화**합니다.
|
||||
* **Rationale (이유):**
|
||||
* **관심사 분리 (Separation of Concerns):** `Tenant` 모델은 결제, 도메인 매핑, B2B 고객사(Company) 격리 등 무거운 비즈니스 로직을 담고 있습니다. "개발팀", "인사부"와 같은 단순한 사내 조직 단위까지 `Tenant` 테이블에 저장하면 시스템 복잡도가 기하급수적으로 증가합니다.
|
||||
* **조회 성능 (Performance):** 특정 고객사(Company)의 전체 조직도를 그릴 때 `SELECT * FROM user_groups WHERE tenant_id = ?` 단일 쿼리로 모든 노드를 가져와 애플리케이션 메모리에서 트리를 구성할 수 있어 성능상 매우 유리합니다.
|
||||
* **단일 진실 공급원 (SoT):** 회사(Company) 단위의 물리적 격리는 `Tenant`가, 논리적인 사내 부서/팀 구조는 `UserGroup`이 담당하도록 역할을 명확히 분리합니다.
|
||||
|
||||
### 2.2 Flexible N-Depth Organizational Structure (유연한 N-Depth 조직 구조)
|
||||
고객사마다 조직 단계(부, 국, 실, 본부, 파트, 반, 셀 등)의 명칭과 깊이(Depth)가 다릅니다. 이를 하드코딩된 Enum으로 제한해서는 안 됩니다.
|
||||
|
||||
* **Decision (결정):** 조직의 단계나 명칭을 시스템(DB 스키마)에서 강제하지 않으며, **N-Depth 인접 목록(Adjacency List) 모델을 사용**합니다.
|
||||
* **Implementation (구현):**
|
||||
* `UserGroup` 모델에 `parent_id` (UUID, Nullable) 컬럼을 추가하여 부모-자식 관계를 형성합니다.
|
||||
* 조직 타입(`unit_type`) 필드는 고정된 Enum(예: `TEAM`, `GROUP`) 대신, 고객사가 자유롭게 입력할 수 있는 **동적 문자열(`String`)**로 관리하거나, 계층의 상대적 깊이(Depth)만을 의미 단위로 사용합니다.
|
||||
* 프론트엔드의 Checkbox Tree 컴포넌트는 재귀적(Recursive)으로 설계되어 데이터의 깊이에 상관없이 무한한 N-Depth를 렌더링할 수 있어야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Structure & Schema Updates (데이터 구조 및 스키마 업데이트)
|
||||
|
||||
새로운 테이블을 추가하는 대신, 기존 모델을 확장하여 중복을 방지합니다.
|
||||
|
||||
### 3.1 `user_groups` 테이블 확장
|
||||
조직 계층 및 부서 단위 표현을 위해 필드를 추가합니다.
|
||||
* `id`, `tenant_id`, `name`, `description` (기존 유지)
|
||||
* **`parent_id` (UUID, Nullable FK):** 상위 `UserGroup` 참조 (조직 트리 구성).
|
||||
* **`unit_type` (String, Optional):** 조직 단위 명칭 (예: "본부", "실", "팀"). 시스템이 강제하지 않으며 프론트엔드 라벨링 용도로 사용됩니다.
|
||||
|
||||
### 3.2 `users` 테이블 확장 (직급 및 직무)
|
||||
CSV에서 업로드되는 사용자의 인사 정보(직급, 직무 등)는 `User` 모델에 직접 저장합니다.
|
||||
* **`position` (String):** 직급 (예: "수석", "책임", "사원").
|
||||
* **`job_title` (String):** 직무 (예: "프론트엔드 개발", "기획").
|
||||
* *(또는 기존에 존재하는 `Metadata` (JSONB) 필드를 활용하여 스키마 변경 없이 동적 속성으로 관리할 수도 있습니다.)*
|
||||
|
||||
---
|
||||
|
||||
## 4. ReBAC Integration Policy (Ory Keto 연동 정책)
|
||||
|
||||
DB의 `user_groups` 계층 트리는 Ory Keto의 관계 튜플(Tuple)과 동기화되어 권한 제어에 사용됩니다. (기존 통합 권한 정책 `tenant-usergroup-policy.md` 준수)
|
||||
|
||||
1. **조직 계층 동기화 (Hierarchy):**
|
||||
* DB에서 A팀(`UserGroup`)이 B본부(`UserGroup`)의 하위로 설정되면, Keto에는 `UserGroup:<A팀_ID>#parent@UserGroup:<B본부_ID>` 튜플이 생성됩니다.
|
||||
2. **소속원 매핑 (Membership):**
|
||||
* 유저가 A팀에 속하면 `UserGroup:<A팀_ID>#members@User:<유저_ID>` 튜플이 생성됩니다.
|
||||
3. **조직장 및 어드민 승격 (Leadership):**
|
||||
* CSV 데이터 분석 또는 수동 지정을 통해 특정 유저가 A팀의 '조직장'으로 식별되면, `UserGroup:<A팀_ID>#owners@User:<유저_ID>` 튜플이 생성됩니다.
|
||||
* 정책에 따라 `owners` 관계를 가진 유저는 해당 조직(UserGroup)과 그 하위 조직에 대한 `admins` 권한을 자동으로 상속받습니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Loading & CSV Upload Strategy (데이터 로딩 및 CSV 업로드 전략)
|
||||
|
||||
고정된 컬럼 구조는 다양한 회사의 조직도를 수용할 수 없으므로 유연한 파싱 로직이 필요합니다.
|
||||
|
||||
### 5.1 Flexible CSV Format (유연한 CSV 포맷)
|
||||
* **경로 기반 방식 (Path-based):** 조직 계층을 슬래시(`/`) 등으로 구분하여 하나의 문자열로 전달받습니다.
|
||||
* *예시 컬럼:* `[조직_경로, 직급, 이름, 직무, 이메일]`
|
||||
* *데이터 예시:* `"개발본부/클라우드실/플랫폼팀", "수석", "홍길동", "백엔드 개발", "hong@example.com"`
|
||||
* **동적 뎁스 방식 (Dynamic Depth):** 뒤에서부터 고정된 사용자 속성 열(직급, 직무, 이름, 이메일 등)을 식별하고, 그 앞의 모든 열을 동적인 계층 구조로 해석합니다.
|
||||
|
||||
### 5.2 Processing Flow (처리 흐름)
|
||||
1. **Parsing & Validation:** 프론트엔드/백엔드에서 유연한 CSV 포맷을 파싱하고, `UserGroup` 계층 경로를 분석합니다.
|
||||
2. **Tree Resolution:** 백엔드는 "개발본부 > 클라우드실 > 플랫폼팀" 경로를 DB에서 조회하거나 없으면 순차적으로 생성(`parent_id` 매핑)하여 `UserGroup` ID 트리를 완성합니다.
|
||||
3. **User Upsert:** `User` 정보를 생성하거나 업데이트(`position`, `job_title` 갱신)합니다.
|
||||
4. **Keto Synchronization:** DB 트랜잭션 완료 후, Background Worker가 변경된 조직 계층과 멤버십 정보를 기반으로 Ory Keto 튜플을 생성/삭제(Reconciliation)합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend Multi-Tenancy UI (프론트엔드 다중 테넌트 UI)
|
||||
|
||||
관리자가 여러 테넌트(Company)에 접근 권한이 있을 경우, 조직도를 명확히 구분하여 보여주어야 합니다.
|
||||
|
||||
* **Tabs Interface:** 화면 상단 또는 측면에 사용자가 접근 가능한 최상위 `Tenant` 목록을 탭(Tabs) 형태로 제공합니다.
|
||||
* **Scoped Fetching:** 특정 탭(Tenant)을 선택할 때마다 해당 `tenant_id`를 파라미터로 백엔드 API를 호출하여, 격리된 해당 회사만의 `UserGroup` 트리를 렌더링합니다.
|
||||
* **Checkbox Tree Component:** Radix UI와 TailwindCSS를 기반으로 개발되며, N-Depth 중첩을 지원하고 부모-자식 간의 반선택(Indeterminate) 상태를 재귀적으로 계산하는 독립적인(Reusable) 컴포넌트로 구현됩니다.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Issue #281: locale_storage 리팩터링 계획
|
||||
|
||||
## 목적
|
||||
- `locale_storage` 관련 로직에서 정책 지식(키, fallback, migration)을 한 곳으로 모아 변경 비용을 줄입니다.
|
||||
- 테스트를 내부 구현 의존(`webStorage`)에서 파사드 의존(`LocaleStorage`) 중심으로 바꿔 회귀 위험을 낮춥니다.
|
||||
- 테스트 강제 훅(`forceMemoryStorageForTests`, `forceSessionStorageForTests`)의 진입점을 단일화합니다.
|
||||
|
||||
## 현재 문제 요약
|
||||
1. 저장 정책 지식이 여러 파일에 분산되어 있습니다.
|
||||
2. 테스트가 구현 세부사항에 결합되어 정책 변경 시 함께 깨질 가능성이 큽니다.
|
||||
3. 플랫폼별 훅 wiring이 반복되어 확장 시 누락 가능성이 있습니다.
|
||||
|
||||
## 변경 범위
|
||||
- 대상 파일
|
||||
- `userfront/lib/core/i18n/locale_storage.dart`
|
||||
- `userfront/lib/core/i18n/locale_storage_stub.dart`
|
||||
- `userfront/lib/core/i18n/locale_storage_web.dart`
|
||||
- `userfront/test/locale_storage_platform_test.dart`
|
||||
- `userfront/test/helpers/web_storage.dart`
|
||||
- `userfront/test/helpers/web_storage_stub.dart`
|
||||
- `userfront/test/helpers/web_storage_web.dart`
|
||||
|
||||
## 리팩터링 단계
|
||||
### 1) 저장 정책 공통화
|
||||
- 저장 키 상수(`locale`, legacy `baron_locale`)와 migration 로직을 공통 모듈로 이동합니다.
|
||||
- fallback 순서(local -> session -> memory)를 공통 정책 함수로 추출합니다.
|
||||
- `locale_storage_web.dart`는 정책 모듈 호출 위주로 단순화합니다.
|
||||
|
||||
### 2) 테스트 결합도 축소
|
||||
- 테스트 assertion의 중심을 `LocaleStorage` API로 이동합니다.
|
||||
- `webStorage` 직접 검증은 최소화하고, 필요한 경우 정책 모듈의 관찰 포인트를 제한적으로 제공합니다.
|
||||
|
||||
### 3) 테스트 훅 단일 진입
|
||||
- `LocaleStorage` 파사드에서만 테스트 훅을 제어하도록 정리합니다.
|
||||
- 플랫폼 구현은 훅 내부 세부 정책을 직접 노출하지 않도록 인터페이스를 정돈합니다.
|
||||
|
||||
### 4) 회귀 테스트 보강
|
||||
- legacy key migration(`baron_locale -> locale`) 회귀 테스트를 명시적으로 유지합니다.
|
||||
- storage access 실패/비가용 상황에서 fallback 순서를 검증하는 테스트를 추가합니다.
|
||||
|
||||
## 완료 기준(DoD)
|
||||
- 정책 변경 시 수정 포인트가 공통 모듈 중심으로 줄어듭니다.
|
||||
- 기존 locale 저장/조회 동작과 migration 동작이 유지됩니다.
|
||||
- 웹 테스트가 안정적으로 통과하며 fallback/migration 회귀 케이스가 포함됩니다.
|
||||
|
||||
## 구현 시 주의사항
|
||||
- 외부에서 사용하는 public API 시그니처는 가능한 유지합니다.
|
||||
- 테스트 편의를 위한 훅은 운영 코드 경로에 영향이 없도록 격리합니다.
|
||||
- 리팩터링 중간 단계에서도 테스트가 통과하도록 작은 단위로 나눠 적용합니다.
|
||||
|
||||
## 롤백 기준
|
||||
- locale 저장/복구가 실패하거나, 웹 환경에서 fallback 동작이 달라지는 경우 즉시 이전 커밋 단위로 되돌립니다.
|
||||
- migration 동작이 깨지는 경우 해당 단계만 우선 revert하고 정책 모듈 분리부터 재진행합니다.
|
||||
|
||||
## 구현 결과 (2026-02-20)
|
||||
### 반영된 코드 변경
|
||||
- 공통 계약/디버그 상태 타입 추가
|
||||
- `userfront/lib/core/i18n/locale_storage_backend.dart`
|
||||
- 저장 정책 상수/판단 로직 분리
|
||||
- `userfront/lib/core/i18n/locale_storage_policy.dart`
|
||||
- 파사드 단일 테스트 진입점 정리
|
||||
- `userfront/lib/core/i18n/locale_storage.dart`
|
||||
- `setTestModeForTests`, `clearForTests`, `seedLegacyForTests`, `debugStateForTests` 추가
|
||||
- 기존 `forceMemoryStorageForTests`, `forceSessionStorageForTests` 호환 유지
|
||||
- 플랫폼 구현 리팩터링
|
||||
- `userfront/lib/core/i18n/locale_storage_web.dart`
|
||||
- `userfront/lib/core/i18n/locale_storage_stub.dart`
|
||||
- fallback 순서(local -> session -> memory) 유지
|
||||
- legacy migration 시 legacy key(`baron_locale`)를 local/session/memory 전체에서 정리
|
||||
- 테스트 정리
|
||||
- `userfront/test/locale_storage_platform_test.dart`를 `LocaleStorage` API 중심 검증으로 전환
|
||||
- 삭제: `userfront/test/helpers/web_storage.dart`
|
||||
- 삭제: `userfront/test/helpers/web_storage_stub.dart`
|
||||
- 삭제: `userfront/test/helpers/web_storage_web.dart`
|
||||
|
||||
### CI/워크플로우 반영
|
||||
- 파일: `.gitea/workflows/code_check.yml`
|
||||
- `workflow_dispatch.inputs` 복원
|
||||
- `run_lint`
|
||||
- `run_backend_tests`
|
||||
- `run_userfront_tests`
|
||||
- 각 job 실행 조건 복원
|
||||
- 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`
|
||||
|
||||
### 검증 결과
|
||||
- `cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos` 통과
|
||||
- `cd userfront && flutter test` 통과
|
||||
- `cd userfront && CHROME_EXECUTABLE=<browser> flutter test --platform chrome test/locale_storage_platform_test.dart` 통과
|
||||
@@ -1,11 +1,59 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'locale_storage_backend.dart';
|
||||
import 'locale_storage_stub.dart'
|
||||
if (dart.library.js_interop) 'locale_storage_web.dart';
|
||||
|
||||
abstract class LocaleStorage {
|
||||
static bool _forceMemory = false;
|
||||
static bool _forceSession = false;
|
||||
|
||||
static void _syncTestMode() {
|
||||
if (_forceMemory) {
|
||||
localeStorage.setTestMode(LocaleStorageTestMode.memoryOnly);
|
||||
return;
|
||||
}
|
||||
if (_forceSession) {
|
||||
localeStorage.setTestMode(LocaleStorageTestMode.sessionOnly);
|
||||
return;
|
||||
}
|
||||
localeStorage.setTestMode(LocaleStorageTestMode.normal);
|
||||
}
|
||||
|
||||
static String? read() => localeStorage.read();
|
||||
static void write(String locale) => localeStorage.write(locale);
|
||||
static void forceMemoryStorageForTests(bool value) =>
|
||||
localeStorage.forceMemoryStorageForTests(value);
|
||||
static void forceSessionStorageForTests(bool value) =>
|
||||
localeStorage.forceSessionStorageForTests(value);
|
||||
|
||||
@visibleForTesting
|
||||
static void setTestModeForTests(LocaleStorageTestMode mode) {
|
||||
_forceMemory = mode == LocaleStorageTestMode.memoryOnly;
|
||||
_forceSession = mode == LocaleStorageTestMode.sessionOnly;
|
||||
_syncTestMode();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void clearForTests() {
|
||||
localeStorage.clearForTests();
|
||||
_forceMemory = false;
|
||||
_forceSession = false;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void seedLegacyForTests(String locale) {
|
||||
localeStorage.seedLegacyForTests(locale);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static LocaleStorageDebugState debugStateForTests() {
|
||||
return localeStorage.debugStateForTests();
|
||||
}
|
||||
|
||||
static void forceMemoryStorageForTests(bool value) {
|
||||
_forceMemory = value;
|
||||
_syncTestMode();
|
||||
}
|
||||
|
||||
static void forceSessionStorageForTests(bool value) {
|
||||
_forceSession = value;
|
||||
_syncTestMode();
|
||||
}
|
||||
}
|
||||
|
||||
38
userfront/lib/core/i18n/locale_storage_backend.dart
Normal file
38
userfront/lib/core/i18n/locale_storage_backend.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum LocaleStorageTestMode { normal, sessionOnly, memoryOnly }
|
||||
|
||||
@immutable
|
||||
class LocaleStorageDebugState {
|
||||
const LocaleStorageDebugState({
|
||||
required this.mode,
|
||||
this.localCurrent,
|
||||
this.localLegacy,
|
||||
this.sessionCurrent,
|
||||
this.sessionLegacy,
|
||||
this.memoryCurrent,
|
||||
this.memoryLegacy,
|
||||
});
|
||||
|
||||
final LocaleStorageTestMode mode;
|
||||
final String? localCurrent;
|
||||
final String? localLegacy;
|
||||
final String? sessionCurrent;
|
||||
final String? sessionLegacy;
|
||||
final String? memoryCurrent;
|
||||
final String? memoryLegacy;
|
||||
}
|
||||
|
||||
abstract interface class LocaleStorageBackend {
|
||||
String? read();
|
||||
|
||||
void write(String locale);
|
||||
|
||||
void setTestMode(LocaleStorageTestMode mode);
|
||||
|
||||
void clearForTests();
|
||||
|
||||
void seedLegacyForTests(String locale);
|
||||
|
||||
LocaleStorageDebugState debugStateForTests();
|
||||
}
|
||||
13
userfront/lib/core/i18n/locale_storage_policy.dart
Normal file
13
userfront/lib/core/i18n/locale_storage_policy.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
class LocaleStoragePolicy {
|
||||
static const currentKey = 'locale';
|
||||
static const legacyKey = 'baron_locale';
|
||||
|
||||
static bool hasValue(String? value) => value != null && value.isNotEmpty;
|
||||
|
||||
static bool shouldMigrateLegacy({
|
||||
required String? current,
|
||||
required String? legacy,
|
||||
}) {
|
||||
return !hasValue(current) && hasValue(legacy);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,59 @@
|
||||
class LocaleStorageImpl {
|
||||
String? _locale;
|
||||
import 'locale_storage_backend.dart';
|
||||
import 'locale_storage_policy.dart';
|
||||
|
||||
String? read() => _locale;
|
||||
class LocaleStorageImpl implements LocaleStorageBackend {
|
||||
final Map<String, String> _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) {
|
||||
_locale = locale;
|
||||
_memory[LocaleStoragePolicy.currentKey] = locale;
|
||||
}
|
||||
|
||||
void forceMemoryStorageForTests(bool value) {
|
||||
// Stub
|
||||
@override
|
||||
void setTestMode(LocaleStorageTestMode mode) {
|
||||
_mode = mode;
|
||||
}
|
||||
|
||||
void forceSessionStorageForTests(bool value) {
|
||||
// Stub
|
||||
@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 localeStorage = LocaleStorageImpl();
|
||||
final LocaleStorageBackend localeStorage = LocaleStorageImpl();
|
||||
|
||||
@@ -1,120 +1,200 @@
|
||||
// ignore_for_file: avoid_web_libraries_in_flutter
|
||||
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class LocaleStorageImpl {
|
||||
static const _key = 'locale';
|
||||
static const _legacyKey = 'baron_locale';
|
||||
import 'locale_storage_backend.dart';
|
||||
import 'locale_storage_policy.dart';
|
||||
|
||||
enum _StorageTarget { local, session, memory }
|
||||
|
||||
class LocaleStorageImpl implements LocaleStorageBackend {
|
||||
static final Map<String, String> _memory = {};
|
||||
static bool _forceMemory = false;
|
||||
static bool _forceSession = false;
|
||||
static LocaleStorageTestMode _mode = LocaleStorageTestMode.normal;
|
||||
|
||||
@visibleForTesting
|
||||
void forceMemoryStorageForTests(bool value) {
|
||||
_forceMemory = value;
|
||||
if (!value) {
|
||||
_memory.clear();
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void forceSessionStorageForTests(bool value) {
|
||||
_forceSession = value;
|
||||
String? _safeReadLocal(String key) {
|
||||
try {
|
||||
return web.window.localStorage.getItem(key);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _read(String key) {
|
||||
if (!_forceMemory && !_forceSession) {
|
||||
try {
|
||||
return web.window.localStorage.getItem(key);
|
||||
} catch (_) {
|
||||
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
|
||||
try {
|
||||
return web.window.sessionStorage.getItem(key);
|
||||
} catch (_) {
|
||||
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||
}
|
||||
}
|
||||
String? _safeReadSession(String key) {
|
||||
try {
|
||||
return web.window.sessionStorage.getItem(key);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
if (!_forceMemory) {
|
||||
try {
|
||||
return web.window.sessionStorage.getItem(key);
|
||||
} catch (_) {
|
||||
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||
}
|
||||
}
|
||||
return _memory[key];
|
||||
}
|
||||
|
||||
void _write(String key, String value) {
|
||||
if (!_forceMemory && !_forceSession) {
|
||||
try {
|
||||
web.window.localStorage.setItem(key, value);
|
||||
return;
|
||||
} catch (_) {
|
||||
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
|
||||
try {
|
||||
web.window.sessionStorage.setItem(key, value);
|
||||
return;
|
||||
} catch (_) {
|
||||
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||
}
|
||||
}
|
||||
bool _safeWriteLocal(String key, String value) {
|
||||
try {
|
||||
web.window.localStorage.setItem(key, value);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!_forceMemory) {
|
||||
try {
|
||||
web.window.sessionStorage.setItem(key, value);
|
||||
return;
|
||||
} catch (_) {
|
||||
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||
}
|
||||
}
|
||||
_memory[key] = value;
|
||||
}
|
||||
|
||||
void _remove(String key) {
|
||||
if (!_forceMemory && !_forceSession) {
|
||||
try {
|
||||
web.window.localStorage.removeItem(key);
|
||||
return;
|
||||
} catch (_) {
|
||||
// localStorage 접근이 차단된 경우 sessionStorage로 fallback.
|
||||
try {
|
||||
web.window.sessionStorage.removeItem(key);
|
||||
return;
|
||||
} catch (_) {
|
||||
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (!_forceMemory) {
|
||||
try {
|
||||
web.window.sessionStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
void _writeByKey(String key, String value) {
|
||||
for (final target in _fallbackTargets()) {
|
||||
if (_writeToTarget(target, key, value)) {
|
||||
return;
|
||||
} catch (_) {
|
||||
// sessionStorage도 차단된 경우 메모리 fallback 사용.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _removeEverywhere(String key) {
|
||||
_safeRemoveLocal(key);
|
||||
_safeRemoveSession(key);
|
||||
_memory.remove(key);
|
||||
}
|
||||
|
||||
@override
|
||||
String? read() {
|
||||
final current = _read(_key);
|
||||
if (current != null && current.isNotEmpty) {
|
||||
final current = _readByKey(LocaleStoragePolicy.currentKey);
|
||||
if (LocaleStoragePolicy.hasValue(current)) {
|
||||
return current;
|
||||
}
|
||||
final legacy = _read(_legacyKey);
|
||||
if (legacy != null && legacy.isNotEmpty) {
|
||||
_write(_key, legacy);
|
||||
_remove(_legacyKey);
|
||||
|
||||
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) {
|
||||
_write(_key, 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 localeStorage = LocaleStorageImpl();
|
||||
final LocaleStorageBackend localeStorage = LocaleStorageImpl();
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import 'web_storage_stub.dart'
|
||||
if (dart.library.html) 'web_storage_web.dart';
|
||||
|
||||
export 'web_storage_stub.dart'
|
||||
if (dart.library.html) 'web_storage_web.dart';
|
||||
@@ -1,21 +0,0 @@
|
||||
class WebStorage {
|
||||
bool get isWeb => false;
|
||||
|
||||
String? get(String key) => null;
|
||||
|
||||
void set(String key, String value) {}
|
||||
|
||||
String? getSession(String key) => null;
|
||||
|
||||
void setSession(String key, String value) {}
|
||||
|
||||
void removeSession(String key) {}
|
||||
|
||||
void clearSession() {}
|
||||
|
||||
void remove(String key) {}
|
||||
|
||||
void clear() {}
|
||||
}
|
||||
|
||||
final webStorage = WebStorage();
|
||||
@@ -1,37 +0,0 @@
|
||||
// ignore_for_file: avoid_web_libraries_in_flutter
|
||||
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
class WebStorage {
|
||||
bool get isWeb => true;
|
||||
|
||||
String? get(String key) => web.window.localStorage.getItem(key);
|
||||
|
||||
void set(String key, String value) {
|
||||
web.window.localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
String? getSession(String key) => web.window.sessionStorage.getItem(key);
|
||||
|
||||
void setSession(String key, String value) {
|
||||
web.window.sessionStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
void removeSession(String key) {
|
||||
web.window.sessionStorage.removeItem(key);
|
||||
}
|
||||
|
||||
void clearSession() {
|
||||
web.window.sessionStorage.clear();
|
||||
}
|
||||
|
||||
void remove(String key) {
|
||||
web.window.localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
web.window.localStorage.clear();
|
||||
}
|
||||
}
|
||||
|
||||
final webStorage = WebStorage();
|
||||
@@ -1,87 +1,75 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/i18n/locale_storage.dart';
|
||||
|
||||
import 'helpers/web_storage.dart';
|
||||
import 'package:userfront/core/i18n/locale_storage_backend.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
LocaleStorage.forceMemoryStorageForTests(false);
|
||||
LocaleStorage.forceSessionStorageForTests(false);
|
||||
if (webStorage.isWeb) {
|
||||
webStorage.clear();
|
||||
webStorage.clearSession();
|
||||
}
|
||||
LocaleStorage.setTestModeForTests(LocaleStorageTestMode.normal);
|
||||
LocaleStorage.clearForTests();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
LocaleStorage.forceMemoryStorageForTests(false);
|
||||
LocaleStorage.forceSessionStorageForTests(false);
|
||||
if (webStorage.isWeb) {
|
||||
webStorage.clear();
|
||||
webStorage.clearSession();
|
||||
}
|
||||
LocaleStorage.setTestModeForTests(LocaleStorageTestMode.normal);
|
||||
LocaleStorage.clearForTests();
|
||||
});
|
||||
|
||||
test(
|
||||
'localStorage write/read (웹)',
|
||||
() {
|
||||
if (!webStorage.isWeb) {
|
||||
return;
|
||||
}
|
||||
test('localStorage write/read (웹)', () {
|
||||
if (!kIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocaleStorage.write('ko');
|
||||
expect(webStorage.get('locale'), 'ko');
|
||||
expect(LocaleStorage.read(), 'ko');
|
||||
},
|
||||
skip: !webStorage.isWeb,
|
||||
);
|
||||
LocaleStorage.write('ko');
|
||||
expect(LocaleStorage.read(), 'ko');
|
||||
|
||||
test(
|
||||
'legacy key에서 locale로 마이그레이션 (웹)',
|
||||
() {
|
||||
if (!webStorage.isWeb) {
|
||||
return;
|
||||
}
|
||||
final state = LocaleStorage.debugStateForTests();
|
||||
expect(state.localCurrent, 'ko');
|
||||
expect(state.sessionCurrent, isNull);
|
||||
expect(state.memoryCurrent, isNull);
|
||||
}, skip: !kIsWeb);
|
||||
|
||||
webStorage.set('baron_locale', 'en');
|
||||
expect(LocaleStorage.read(), 'en');
|
||||
expect(webStorage.get('locale'), 'en');
|
||||
expect(webStorage.get('baron_locale'), isNull);
|
||||
},
|
||||
skip: !webStorage.isWeb,
|
||||
);
|
||||
test('legacy key에서 locale로 마이그레이션 (웹)', () {
|
||||
if (!kIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
test(
|
||||
'localStorage 접근이 차단되면 메모리 fallback (웹)',
|
||||
() {
|
||||
if (!webStorage.isWeb) {
|
||||
return;
|
||||
}
|
||||
LocaleStorage.seedLegacyForTests('en');
|
||||
expect(LocaleStorage.read(), 'en');
|
||||
|
||||
LocaleStorage.forceMemoryStorageForTests(true);
|
||||
final state = LocaleStorage.debugStateForTests();
|
||||
expect(state.localCurrent, 'en');
|
||||
expect(state.localLegacy, isNull);
|
||||
}, skip: !kIsWeb);
|
||||
|
||||
LocaleStorage.write('en');
|
||||
expect(webStorage.get('locale'), isNull);
|
||||
expect(webStorage.getSession('locale'), isNull);
|
||||
expect(LocaleStorage.read(), 'en');
|
||||
},
|
||||
skip: !webStorage.isWeb,
|
||||
);
|
||||
test('localStorage 접근이 차단되면 메모리 fallback (웹)', () {
|
||||
if (!kIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
test(
|
||||
'localStorage 접근이 차단되면 sessionStorage로 fallback (웹)',
|
||||
() {
|
||||
if (!webStorage.isWeb) {
|
||||
return;
|
||||
}
|
||||
LocaleStorage.forceMemoryStorageForTests(true);
|
||||
|
||||
LocaleStorage.forceSessionStorageForTests(true);
|
||||
LocaleStorage.write('en');
|
||||
expect(LocaleStorage.read(), 'en');
|
||||
|
||||
LocaleStorage.write('ko');
|
||||
expect(webStorage.get('locale'), isNull);
|
||||
expect(webStorage.getSession('locale'), 'ko');
|
||||
expect(LocaleStorage.read(), 'ko');
|
||||
},
|
||||
skip: !webStorage.isWeb,
|
||||
);
|
||||
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();
|
||||
expect(state.localCurrent, isNull);
|
||||
expect(state.sessionCurrent, 'ko');
|
||||
expect(state.memoryCurrent, isNull);
|
||||
}, skip: !kIsWeb);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user