feat(ds-app): 본 서버(GPU DS) 라이브 결선 — 앱 기본을 오프라인 스캐폴드에서 라이브로 전환
- AppModel: AuthPhase 상태기계(checking/loggedOut/ready) + live() 팩토리 (LiveDSClient + realRouter, ask 토큰 = TokenProvider 단일 소스) + bootstrap (refresh 쿠키 무로그인 복귀, single-shot, 취소 시 재시도 복원) + login(TOTP 개행·공백 정규화) + 사용 중 세션 만료 시 loggedOut 강등 + 401 회전 후 다운로드 ?token= 사본 재동기화(guarded 깔때기) - LoginView 신규(기능 셸, 서버 host 표시, 서버 detail 메시지 노출) - RootView: 인증 게이트 + errorText 하단 배너(no-silent-fallback 가시화) - DSApp: 기본 .live(publicTLS=document.hyungi.net/api), DSAPP_FIXTURE=1 / DSAPP_DS_URL env 스위치(파싱 실패 = fail-loud, prod silent fallback 금지) - LiveDSClient.currentAccessToken() — realRouter ask 토큰 closure 용 - AppFeatureTests 신규 10건(인증 상태기계·single-shot·transport 사유·totp) 검증: swift test 82/82 green + xcodebuild .app BUILD SUCCEEDED + 라이브 negative-path(/auth/login 401·/auth/refresh 401, 본 서버 양 경로 도달). 3-렌즈 어드버서리얼 리뷰 반영(재진입 가드/transport 구분/env fail-loud/토큰 사본 동기화/만료 강등). Sources/AI 무수정(시그니처 동결 준수). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,32 @@
|
||||
import SwiftUI
|
||||
import AppFeature
|
||||
|
||||
/// Thin @main entry: window + DI only. Injects AppModel (FixtureDSClient + AIRouter(MockAIProvider))
|
||||
/// so the whole pipeline renders with zero real backend / zero real LLM. Feature logic lives in
|
||||
/// AppFeature, keeping the seam to a future Xcode/iPhone target trivial.
|
||||
/// Thin @main entry: window + DI only. 기본 = 본 서버(GPU DS) 라이브 결선(AppModel.live —
|
||||
/// LiveDSClient + 실 AIFabric 라우터, base 기본 publicTLS = https://document.hyungi.net/api).
|
||||
/// env 스위치: DSAPP_FIXTURE=1 → 오프라인 스캐폴드(Fixture+Mock) / DSAPP_DS_URL → base 오버라이드
|
||||
/// (예: http://100.110.63.63:8000/api). Feature logic lives in AppFeature, keeping the seam to a
|
||||
/// future iPhone/Watch target trivial.
|
||||
@main
|
||||
struct DSApp: App {
|
||||
@State private var model: AppModel
|
||||
|
||||
@MainActor
|
||||
init() {
|
||||
_model = State(initialValue: AppModel.preview)
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let initial: AppModel
|
||||
if env["DSAPP_FIXTURE"] == "1" {
|
||||
initial = .preview
|
||||
} else if let raw = env["DSAPP_DS_URL"] {
|
||||
// dev 전용 오버라이드 — 파싱 실패 시 prod(publicTLS)로 silent fallback 금지, 즉사.
|
||||
let trimmed = raw.hasSuffix("/") ? String(raw.dropLast()) : raw
|
||||
guard let url = URL(string: trimmed), url.scheme != nil, url.host() != nil else {
|
||||
fatalError("DSAPP_DS_URL 파싱 실패: \(raw)")
|
||||
}
|
||||
initial = .live(base: .custom(url))
|
||||
} else {
|
||||
initial = .live()
|
||||
}
|
||||
_model = State(initialValue: initial)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
@@ -47,6 +47,11 @@ let package = Package(
|
||||
dependencies: ["DSKit", "AIFabric"],
|
||||
swiftSettings: [.swiftLanguageMode(.v6)]
|
||||
),
|
||||
.testTarget(
|
||||
name: "AppFeatureTests",
|
||||
dependencies: ["AppFeature", "DSKit"],
|
||||
swiftSettings: [.swiftLanguageMode(.v6)]
|
||||
),
|
||||
.testTarget(
|
||||
name: "AITests",
|
||||
dependencies: ["AIFabric"],
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
import DSKit
|
||||
|
||||
/// 본 서버(GPU DS) 정식 로그인 — 기능 셸 (페이지 매력 시안은 FU-E 별도 트랙).
|
||||
/// refresh 쿠키 부재/만료 시에만 노출; 성공하면 HttpOnly refresh 쿠키가 다음 실행 복귀를 담당해
|
||||
/// 이 화면 없이 곧장 셸로 들어간다. TOTP 는 계정에 설정된 경우에만 필요(선택 입력).
|
||||
public struct LoginView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var totp = ""
|
||||
@State private var submitting = false
|
||||
@FocusState private var focus: Field?
|
||||
private enum Field { case username, password, totp }
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Document Server")
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundStyle(Sage.ink)
|
||||
Text(serverHost)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Sage.muted)
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
TextField("아이디", text: $username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($focus, equals: .username)
|
||||
.onSubmit { focus = .password }
|
||||
SecureField("비밀번호", text: $password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($focus, equals: .password)
|
||||
.onSubmit { submit() }
|
||||
TextField("2FA 코드 (설정한 경우)", text: $totp)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($focus, equals: .totp)
|
||||
.onSubmit { submit() }
|
||||
}
|
||||
if let error = model.loginError {
|
||||
Text(error)
|
||||
.font(.callout)
|
||||
.foregroundStyle(Sage.danger)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Button(action: submit) {
|
||||
Group {
|
||||
if submitting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("로그인")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Sage.brand)
|
||||
.disabled(submitting || username.isEmpty || password.isEmpty)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(width: 360)
|
||||
.background(Sage.card, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line))
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Sage.surface)
|
||||
.onAppear { focus = .username }
|
||||
}
|
||||
|
||||
/// 연결 대상 표시 — base 의 host (예: document.hyungi.net / 100.110.63.63).
|
||||
private var serverHost: String {
|
||||
model.base.url.host() ?? model.base.url.absoluteString
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
guard !submitting, !username.isEmpty, !password.isEmpty else { return }
|
||||
submitting = true
|
||||
Task {
|
||||
await model.login(username: username, password: password, totp: totp)
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("로그인") {
|
||||
@Previewable @State var model = AppModel.preview
|
||||
LoginView()
|
||||
.environment(model)
|
||||
.frame(width: 700, height: 500)
|
||||
}
|
||||
#endif
|
||||
@@ -3,6 +3,7 @@ import DSKit
|
||||
|
||||
/// DEVONthink-style 3-column shell. RootView only ROUTES; each page owns its own interior treatment
|
||||
/// (no shell-level auto-inherit). macOS-only target.
|
||||
/// 인증 게이트: checking(부팅 시 refresh 쿠키 복귀 시도) → loggedOut(LoginView) → ready(3-pane 셸).
|
||||
public struct RootView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
@@ -10,6 +11,22 @@ public struct RootView: View {
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
switch model.authPhase {
|
||||
case .checking:
|
||||
ProgressView("서버 연결 확인 중")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Sage.surface)
|
||||
case .loggedOut:
|
||||
LoginView()
|
||||
case .ready:
|
||||
shell
|
||||
}
|
||||
}
|
||||
.task { await model.bootstrap() }
|
||||
}
|
||||
|
||||
private var shell: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
Sidebar()
|
||||
.navigationSplitViewColumnWidth(min: 220, ideal: 250)
|
||||
@@ -21,7 +38,24 @@ public struct RootView: View {
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.tint(Sage.brand)
|
||||
.task { await model.loadInitial() }
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
// 라이브 데이터 호출 실패 가시화 (no-silent-fallback) — 닫기 전까지 유지.
|
||||
if let err = model.errorText {
|
||||
HStack(spacing: 10) {
|
||||
Text(err)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
Button("닫기") { model.errorText = nil }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(Sage.danger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +154,6 @@ struct EmptyState: View {
|
||||
@Previewable @State var model = AppModel.preview
|
||||
RootView()
|
||||
.environment(model)
|
||||
.task { await model.loadInitial() }
|
||||
.frame(minWidth: 1000, minHeight: 660)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -23,6 +23,10 @@ public final class AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 인증 단계: 시작 시 refresh 쿠키로 무로그인 복귀 시도(checking) → 실패 시 로그인 화면(loggedOut)
|
||||
/// → 성공 시 셸(ready). Fixture 클라이언트는 refresh 가 fixture 토큰을 돌려줘 곧장 ready.
|
||||
public enum AuthPhase: Equatable { case checking, loggedOut, ready }
|
||||
|
||||
public var section: Section = .dashboard
|
||||
public var selectedDocumentID: Int?
|
||||
public var selectedMemoID: Int?
|
||||
@@ -41,14 +45,23 @@ public final class AppModel {
|
||||
public var digest: DigestResponse?
|
||||
public var errorText: String?
|
||||
|
||||
public private(set) var authPhase: AuthPhase = .checking
|
||||
/// 로그인 화면에 노출되는 실패 사유 (재시도 시 소거).
|
||||
public var loginError: String?
|
||||
/// bootstrap single-shot 가드 (취소 시에만 되돌림).
|
||||
private var didBootstrap = false
|
||||
|
||||
let client: any DSClient
|
||||
let ai: AIService
|
||||
/// Placeholder token from the auth fixture — builds a real-SHAPED download URL with no expectation it resolves offline.
|
||||
/// DS base — 다운로드 등 파생 URL 의 단일 기준 (live()/preview 가 주입).
|
||||
let base: DSBaseURL
|
||||
/// 현재 access 토큰 (다운로드 ?token= 용). bootstrap/login 시 갱신.
|
||||
public private(set) var accessToken: String = ""
|
||||
|
||||
public init(client: any DSClient, ai: AIService) {
|
||||
public init(client: any DSClient, ai: AIService, base: DSBaseURL = .publicTLS) {
|
||||
self.client = client
|
||||
self.ai = ai
|
||||
self.base = base
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -56,8 +69,66 @@ public final class AppModel {
|
||||
AppModel(client: FixtureDSClient(), ai: AIService(router: AppAIComposition.mockRouter()))
|
||||
}
|
||||
|
||||
/// 본 서버(GPU DS) 라이브 결선: LiveDSClient + 실 AIFabric 라우터(realRouter). ask 토큰 closure 는
|
||||
/// client 의 TokenProvider 단일 소스(401 refresh 회전 반영). 토큰 영속화 기본 = InMemory —
|
||||
/// access 토큰은 15분 수명이라 영속 가치가 없고, 재시작 복귀는 HttpOnly refresh 쿠키(7일,
|
||||
/// HTTPCookieStorage 자동 보관)가 담당. Keychain 은 서명 배포 빌드에서 주입.
|
||||
@MainActor
|
||||
public static func live(
|
||||
base: DSBaseURL = .publicTLS,
|
||||
persistence: TokenPersistence = InMemoryTokenStore()
|
||||
) -> AppModel {
|
||||
let client = LiveDSClient(base: base, persistence: persistence)
|
||||
let router = AppAIComposition.realRouter(base: base) { await client.currentAccessToken() }
|
||||
return AppModel(client: client, ai: AIService(router: router), base: base)
|
||||
}
|
||||
|
||||
/// 앱 시작 1회 (single-shot — 새 창/뷰 재생성의 .task 재발화에도 중복 실행 안 함):
|
||||
/// refresh 쿠키로 토큰 복귀 시도. 401(쿠키 없음/만료) = 조용히 loggedOut(정상 신규 로그인 흐름) /
|
||||
/// 그 외(서버 도달 불가 등) = loggedOut + 사유를 loginError 로 노출(no-silent-fallback) /
|
||||
/// task 취소(창 닫힘) = 미시도로 복원해 다음 appear 에서 재시도.
|
||||
public func bootstrap() async {
|
||||
guard !didBootstrap else { return }
|
||||
didBootstrap = true
|
||||
// authPhase 는 초기값 .checking 그대로 사용 — 여기서 리셋하지 않아 ready 세션의 UI 플립 방지.
|
||||
do {
|
||||
let token = try await client.refresh().accessToken
|
||||
accessToken = token
|
||||
authPhase = .ready
|
||||
await loadInitial()
|
||||
} catch let e as DSError where e.isAuthExpired {
|
||||
authPhase = .loggedOut
|
||||
} catch {
|
||||
if Task.isCancelled {
|
||||
didBootstrap = false
|
||||
return
|
||||
}
|
||||
authPhase = .loggedOut
|
||||
loginError = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 정식 로그인 (POST /auth/login → JWT). totp 는 개행/내부 공백 제거 후 비어 있으면 미전송.
|
||||
public func login(username: String, password: String, totp: String?) async {
|
||||
loginError = nil
|
||||
do {
|
||||
let code = totp.map {
|
||||
$0.replacingOccurrences(of: " ", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
let response = try await client.login(
|
||||
username: username,
|
||||
password: password,
|
||||
totpCode: (code?.isEmpty ?? true) ? nil : code
|
||||
)
|
||||
accessToken = response.accessToken
|
||||
authPhase = .ready
|
||||
await loadInitial()
|
||||
} catch {
|
||||
loginError = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
}
|
||||
}
|
||||
|
||||
public func loadInitial() async {
|
||||
await guarded { self.accessToken = (try? await self.client.login(username: "hyungi", password: "x", totpCode: nil).accessToken) ?? "" }
|
||||
await guarded { self.tree = try await self.client.documentTree() }
|
||||
await guarded { self.stats = try await self.client.categoryCounts() }
|
||||
await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items }
|
||||
@@ -88,11 +159,26 @@ public final class AppModel {
|
||||
|
||||
public func downloadURL(for doc: DocumentResponse) -> URL? {
|
||||
guard doc.hasDownloadableOriginal, !accessToken.isEmpty else { return nil }
|
||||
return DSDownload.fileURL(base: .publicTLS, documentID: doc.id, accessToken: accessToken)
|
||||
return DSDownload.fileURL(base: base, documentID: doc.id, accessToken: accessToken)
|
||||
}
|
||||
|
||||
private func guarded(_ work: () async throws -> Void) async {
|
||||
do { try await work() }
|
||||
catch { errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
|
||||
do {
|
||||
try await work()
|
||||
} catch let e as DSError where e.isAuthExpired {
|
||||
// LiveDSClient 내부 refresh+재시도까지 실패(refresh 쿠키 만료/폐기) — 세션 종료로 강등.
|
||||
authPhase = .loggedOut
|
||||
loginError = "세션이 만료되었습니다. 다시 로그인하세요."
|
||||
} catch {
|
||||
errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
}
|
||||
await syncAccessToken()
|
||||
}
|
||||
|
||||
/// 401 회전(LiveDSClient 내부 refresh) 후 다운로드 ?token= 사본 재동기화 — guarded 가 모든
|
||||
/// 데이터 호출의 깔때기이므로 회전 발생 지점 직후 항상 거친다. 토큰 단일 소스 = TokenProvider.
|
||||
private func syncAccessToken() async {
|
||||
guard let live = client as? LiveDSClient, let t = await live.currentAccessToken() else { return }
|
||||
if t != accessToken { accessToken = t }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ public final class LiveDSClient: DSClient, @unchecked Sendable {
|
||||
|
||||
public func setAccessToken(_ token: String) async { await tokens.set(token) }
|
||||
|
||||
/// realRouter 의 ask 토큰 closure 용 — TokenProvider 단일 소스 (401 refresh 회전 반영).
|
||||
public func currentAccessToken() async -> String? { await tokens.current() }
|
||||
|
||||
// MARK: - Request building / sending
|
||||
|
||||
private func makeRequest(_ endpoint: DSEndpoint, token: String?) throws -> URLRequest {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import XCTest
|
||||
@testable import AppFeature
|
||||
import DSKit
|
||||
|
||||
/// 라이브 결선 인증 상태기계 검증 — 네트워크 0 (Fixture/stub 만).
|
||||
/// bootstrap: refresh 쿠키 복귀 성공=ready / 실패=loggedOut. login: 성공=ready+로드 / 401=에러 노출.
|
||||
final class AppModelAuthTests: XCTestCase {
|
||||
|
||||
@MainActor
|
||||
private func makeModel(client: any DSClient) -> AppModel {
|
||||
AppModel(client: client, ai: AIService(router: AppAIComposition.mockRouter()))
|
||||
}
|
||||
|
||||
// refresh 성공(쿠키 복귀 시나리오 — Fixture 가 fixture 토큰 반환) → ready + 초기 데이터 로드
|
||||
@MainActor
|
||||
func testBootstrapRefreshSuccessGoesReady() async {
|
||||
let model = AppModel.preview
|
||||
await model.bootstrap()
|
||||
XCTAssertEqual(model.authPhase, .ready)
|
||||
XCTAssertFalse(model.accessToken.isEmpty)
|
||||
XCTAssertFalse(model.documentList.isEmpty, "ready 진입 시 초기 로드까지 수행해야 함")
|
||||
}
|
||||
|
||||
// refresh 실패(쿠키 없음/만료) → loggedOut, 데이터 미로드
|
||||
@MainActor
|
||||
func testBootstrapRefreshFailureGoesLoggedOut() async {
|
||||
let model = makeModel(client: AuthStubClient(refreshFails: true))
|
||||
await model.bootstrap()
|
||||
XCTAssertEqual(model.authPhase, .loggedOut)
|
||||
XCTAssertTrue(model.accessToken.isEmpty)
|
||||
XCTAssertTrue(model.documentList.isEmpty)
|
||||
}
|
||||
|
||||
// loggedOut → login 성공 → ready + 초기 로드
|
||||
@MainActor
|
||||
func testLoginSuccessTransitionsToReady() async {
|
||||
let model = makeModel(client: AuthStubClient(refreshFails: true))
|
||||
await model.bootstrap()
|
||||
XCTAssertEqual(model.authPhase, .loggedOut)
|
||||
await model.login(username: "hyungi", password: "pw", totp: nil)
|
||||
XCTAssertEqual(model.authPhase, .ready)
|
||||
XCTAssertFalse(model.accessToken.isEmpty)
|
||||
XCTAssertNil(model.loginError)
|
||||
XCTAssertFalse(model.documentList.isEmpty)
|
||||
}
|
||||
|
||||
// login 401 → loginError 노출 + loggedOut 유지 + 토큰 없음
|
||||
@MainActor
|
||||
func testLoginFailureSurfacesErrorAndStaysLoggedOut() async {
|
||||
let model = makeModel(client: AuthStubClient(refreshFails: true, loginFails: true))
|
||||
await model.bootstrap()
|
||||
await model.login(username: "hyungi", password: "wrong", totp: nil)
|
||||
XCTAssertEqual(model.authPhase, .loggedOut)
|
||||
XCTAssertNotNil(model.loginError)
|
||||
XCTAssertTrue(model.accessToken.isEmpty)
|
||||
}
|
||||
|
||||
// totp 공백/빈 문자열 → totpCode nil 로 전송 (서버는 미설정 계정에 totp 필드 자체를 안 받는 게 안전)
|
||||
@MainActor
|
||||
func testLoginSendsNilForBlankTotp() async {
|
||||
let stub = AuthStubClient(refreshFails: true)
|
||||
let model = makeModel(client: stub)
|
||||
await model.login(username: "u", password: "p", totp: " ")
|
||||
XCTAssertNotNil(stub.recordedLogin, "login 이 호출돼야 함")
|
||||
XCTAssertNil(stub.recordedLogin?.totp, "공백 totp 는 nil 로 정규화")
|
||||
await model.login(username: "u", password: "p", totp: "123456")
|
||||
XCTAssertEqual(stub.recordedLogin?.totp, "123456")
|
||||
}
|
||||
|
||||
// totp 붙여넣기 잔여물(개행/그룹 공백) 정규화 — "123 456\n" → "123456"
|
||||
@MainActor
|
||||
func testLoginNormalizesTotpNewlineAndSpaces() async {
|
||||
let stub = AuthStubClient(refreshFails: true)
|
||||
let model = makeModel(client: stub)
|
||||
await model.login(username: "u", password: "p", totp: "123 456\n")
|
||||
XCTAssertEqual(stub.recordedLogin?.totp, "123456")
|
||||
await model.login(username: "u", password: "p", totp: " \n ")
|
||||
XCTAssertNil(stub.recordedLogin?.totp, "개행+공백뿐이면 nil")
|
||||
}
|
||||
|
||||
// bootstrap single-shot — 뷰 재생성(.task 재발화)에도 refresh 1회만, ready 유지
|
||||
@MainActor
|
||||
func testBootstrapIsSingleShot() async {
|
||||
let stub = AuthStubClient()
|
||||
let model = makeModel(client: stub)
|
||||
await model.bootstrap()
|
||||
XCTAssertEqual(model.authPhase, .ready)
|
||||
await model.bootstrap() // 새 창 appear 시뮬레이션
|
||||
XCTAssertEqual(model.authPhase, .ready, "재진입이 checking 으로 리셋하면 안 됨")
|
||||
XCTAssertEqual(stub.refreshCount, 1, "refresh 는 1회만")
|
||||
}
|
||||
|
||||
// bootstrap transport 실패(서버 도달 불가) → loggedOut + 사유 노출 (무언 금지)
|
||||
@MainActor
|
||||
func testBootstrapTransportFailureExposesReason() async {
|
||||
let model = makeModel(client: AuthStubClient(refreshTransportFails: true))
|
||||
await model.bootstrap()
|
||||
XCTAssertEqual(model.authPhase, .loggedOut)
|
||||
XCTAssertNotNil(model.loginError, "transport 실패 사유가 로그인 화면에 노출돼야 함")
|
||||
}
|
||||
|
||||
// 사용 중 세션 만료(내부 refresh+재시도까지 실패) → ready 에서 loggedOut 으로 강등
|
||||
@MainActor
|
||||
func testAuthExpiredDuringUseDemotesToLoggedOut() async {
|
||||
let stub = AuthStubClient()
|
||||
let model = makeModel(client: stub)
|
||||
await model.bootstrap()
|
||||
XCTAssertEqual(model.authPhase, .ready)
|
||||
stub.dataAuthExpired = true // 이후 데이터 호출은 401 (refresh 만료 시나리오)
|
||||
await model.openDocument(1)
|
||||
XCTAssertEqual(model.authPhase, .loggedOut)
|
||||
XCTAssertNotNil(model.loginError)
|
||||
}
|
||||
|
||||
// live 팩토리: LiveDSClient 구성 + base 보존 (네트워크 호출 없음 — 구성만)
|
||||
@MainActor
|
||||
func testLiveFactoryComposition() {
|
||||
let model = AppModel.live(base: .tailscale)
|
||||
XCTAssertTrue(model.client is LiveDSClient)
|
||||
XCTAssertEqual(model.base.url.absoluteString, DSBaseURL.tailscale.url.absoluteString)
|
||||
}
|
||||
}
|
||||
|
||||
/// FixtureDSClient 위임 + 인증 동작만 시나리오 제어하는 테스트 스텁 (네트워크 0).
|
||||
/// 테스트 단일 task 에서 직렬 사용 — 가변 기록 프로퍼티는 @unchecked Sendable 로 허용.
|
||||
final class AuthStubClient: DSClient, @unchecked Sendable {
|
||||
private let inner = FixtureDSClient()
|
||||
private let refreshFails: Bool
|
||||
private let refreshTransportFails: Bool
|
||||
private let loginFails: Bool
|
||||
private(set) var recordedLogin: (username: String, totp: String?)?
|
||||
private(set) var refreshCount = 0
|
||||
/// true 면 이후 데이터 호출이 401 (사용 중 세션 만료 시나리오 — LiveDSClient 내부 재시도 실패에 해당)
|
||||
var dataAuthExpired = false
|
||||
|
||||
init(refreshFails: Bool = false, refreshTransportFails: Bool = false, loginFails: Bool = false) {
|
||||
self.refreshFails = refreshFails
|
||||
self.refreshTransportFails = refreshTransportFails
|
||||
self.loginFails = loginFails
|
||||
}
|
||||
|
||||
private func gateData() throws {
|
||||
if dataAuthExpired { throw DSError.unauthorized(message: nil) }
|
||||
}
|
||||
|
||||
// Auth — 시나리오 제어 지점
|
||||
func login(username: String, password: String, totpCode: String?) async throws -> AccessTokenResponse {
|
||||
recordedLogin = (username, totpCode)
|
||||
if loginFails { throw DSError.unauthorized(message: "아이디 또는 비밀번호가 올바르지 않습니다") }
|
||||
return try await inner.login(username: username, password: password, totpCode: totpCode)
|
||||
}
|
||||
func refresh() async throws -> AccessTokenResponse {
|
||||
refreshCount += 1
|
||||
if refreshTransportFails { throw DSError.transport(underlying: "Could not connect to the server") }
|
||||
if refreshFails { throw DSError.unauthorized(message: "refresh failed") }
|
||||
return try await inner.refresh()
|
||||
}
|
||||
func me() async throws -> UserResponse { try await inner.me() }
|
||||
func logout() async throws { try await inner.logout() }
|
||||
|
||||
// 이하 전부 Fixture 위임 (dataAuthExpired 게이트 경유)
|
||||
func documents(_ query: DocumentListQuery) async throws -> DocumentListResponse { try gateData(); return try await inner.documents(query) }
|
||||
func document(id: Int) async throws -> DocumentDetailResponse { try gateData(); return try await inner.document(id: id) }
|
||||
func documentContent(id: Int) async throws -> DocumentContentResponse { try await inner.documentContent(id: id) }
|
||||
func documentTree() async throws -> [DomainTreeNode] { try await inner.documentTree() }
|
||||
func categoryCounts() async throws -> CategoryCounts { try await inner.categoryCounts() }
|
||||
func duplicates() async throws -> DuplicatesResponse { try await inner.duplicates() }
|
||||
func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { try await inner.patchDocument(id: id, update) }
|
||||
func putContent(id: Int, content: String) async throws { try await inner.putContent(id: id, content: content) }
|
||||
func deleteDocument(id: Int) async throws { try await inner.deleteDocument(id: id) }
|
||||
func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await inner.search(q: q, mode: mode, page: page, debug: debug) }
|
||||
func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await inner.ask(q: q, limit: limit, backend: backend, debug: debug) }
|
||||
func memos(_ query: MemoListQuery) async throws -> MemoListResponse { try await inner.memos(query) }
|
||||
func memo(id: Int) async throws -> MemoResponse { try await inner.memo(id: id) }
|
||||
func createMemo(_ create: MemoCreate) async throws -> MemoResponse { try await inner.createMemo(create) }
|
||||
func patchMemo(id: Int, _ update: MemoUpdate) async throws -> MemoResponse { try await inner.patchMemo(id: id, update) }
|
||||
func pinMemo(id: Int, pinned: Bool) async throws -> MemoResponse { try await inner.pinMemo(id: id, pinned: pinned) }
|
||||
func archiveMemo(id: Int, archived: Bool) async throws -> MemoResponse { try await inner.archiveMemo(id: id, archived: archived) }
|
||||
func toggleMemoTask(id: Int, taskIndex: Int, checked: Bool) async throws -> MemoResponse { try await inner.toggleMemoTask(id: id, taskIndex: taskIndex, checked: checked) }
|
||||
func deleteMemo(id: Int) async throws { try await inner.deleteMemo(id: id) }
|
||||
func digest(date: String?, country: String?) async throws -> DigestResponse { try await inner.digest(date: date, country: country) }
|
||||
}
|
||||
Reference in New Issue
Block a user