f527c63232
- FU-C 멀티파트 업로드(DSClient.uploadDocument + LiveDSClient 401 재시도 공유 + 툴바/상태바) - FU-D 네이티브 다운로드(NSSavePanel + URLSession, ?token= 미노출, 임시파일 정리) - 로그아웃(AppModel.logout 세션 전체 초기화 + 계정 메뉴) - 셸 2-column 재구성: 질문/이드 제거, 홈 코크핏 + 문서 3-pane 컬럼 브라우저 (인스펙터 TL;DR/핵심점/심층/불일치) + 도메인 필터 전체 load-all - 적대 리뷰 반영(stale 401 데모션·다운로드 임시파일 정리·메모 저장 saveMemo 경유·도메인 필터 선택 정합) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
10 KiB
Swift
184 lines
10 KiB
Swift
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 uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse { try await inner.uploadDocument(upload) }
|
|
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) }
|
|
}
|