feat(s3): LiveDSClient + Endpoint + Keychain/TokenProvider (FU-A plumbing)
- DSEndpoint: method/path/query/body single source (trailing slashes preserved, nil query skipped) - KeychainStore + InMemoryTokenStore (TokenPersistence); TokenProvider actor with single-flight refresh (Task handle, cleared on completion) - LiveDSClient: URLSession + shared cookie storage, Bearer injection, 401 -> single-flight refresh -> one retry (never on login/refresh/logout); same DTOs/decoder as fixtures - Tests: endpoint path/method/query/body + single-flight (fires once) + token cache/persist swift build + swift test green (25). Live HTTP path itself is FU-A (needs real backend). Sources/AI untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Access-token persistence. The scaffold/preview/unsigned-build path uses InMemoryTokenStore
|
||||
/// (Keychain on an unsigned binary can prompt or return errSecMissingEntitlement); the signed app
|
||||
/// uses KeychainStore. Only the ACCESS token is stored — refresh rides the HttpOnly cookie.
|
||||
public protocol TokenPersistence: Sendable {
|
||||
func read() -> String?
|
||||
func save(_ token: String) throws
|
||||
func delete() throws
|
||||
}
|
||||
|
||||
public final class InMemoryTokenStore: TokenPersistence, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var token: String?
|
||||
public init() {}
|
||||
public func read() -> String? { lock.withLock { token } }
|
||||
public func save(_ token: String) throws { lock.withLock { self.token = token } }
|
||||
public func delete() throws { lock.withLock { token = nil } }
|
||||
}
|
||||
|
||||
public struct KeychainStore: TokenPersistence, Sendable {
|
||||
private let service: String
|
||||
private let account: String
|
||||
|
||||
public init(service: String = "net.hyungi.ds-app", account: String = "access_token") {
|
||||
self.service = service
|
||||
self.account = account
|
||||
}
|
||||
|
||||
public func read() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||
let data = item as? Data,
|
||||
let token = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
return token
|
||||
}
|
||||
|
||||
public func save(_ token: String) throws {
|
||||
let base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(base as CFDictionary) // upsert
|
||||
var add = base
|
||||
add[kSecValueData as String] = Data(token.utf8)
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
let status = SecItemAdd(add as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw DSError.transport(underlying: "keychain save failed (\(status))")
|
||||
}
|
||||
}
|
||||
|
||||
public func delete() throws {
|
||||
let base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(base as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
/// Concurrency-safe access-token holder with SINGLE-FLIGHT refresh: concurrent 401s must not each
|
||||
/// fire `/auth/refresh`. The coalescing is via a stored `Task` handle (not a bool) — entrants await
|
||||
/// the same in-flight task; it's cleared (success OR failure) when complete. Clearing too early lets a
|
||||
/// follower re-refresh; never clearing means the next expiry won't refresh.
|
||||
public actor TokenProvider {
|
||||
private var cached: String?
|
||||
private let persistence: TokenPersistence
|
||||
private let refresh: @Sendable () async throws -> String
|
||||
private var inFlightRefresh: Task<String, Error>?
|
||||
|
||||
public init(persistence: TokenPersistence, refresh: @escaping @Sendable () async throws -> String) {
|
||||
self.persistence = persistence
|
||||
self.refresh = refresh
|
||||
self.cached = persistence.read()
|
||||
}
|
||||
|
||||
public func current() -> String? { cached ?? persistence.read() }
|
||||
|
||||
public func set(_ token: String) {
|
||||
cached = token
|
||||
try? persistence.save(token)
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
cached = nil
|
||||
try? persistence.delete()
|
||||
}
|
||||
|
||||
public func refreshOnce() async throws -> String {
|
||||
if let task = inFlightRefresh {
|
||||
return try await task.value
|
||||
}
|
||||
let task = Task { try await self.refresh() }
|
||||
inFlightRefresh = task
|
||||
defer { inFlightRefresh = nil }
|
||||
let token = try await task.value
|
||||
cached = token
|
||||
try? persistence.save(token)
|
||||
return token
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
/// Single source of truth mapping each DSClient call to HTTP method + path + query + body.
|
||||
/// Trailing slashes are significant (e.g. `documents/`, `search/`) and are preserved by building the
|
||||
/// URL from the base string rather than appendingPathComponent (which strips them).
|
||||
enum DSEndpoint {
|
||||
case login(String, String, String?)
|
||||
case me
|
||||
case refresh
|
||||
case logout
|
||||
case documents(DocumentListQuery)
|
||||
case document(Int)
|
||||
case documentContent(Int)
|
||||
case documentTree
|
||||
case categoryCounts
|
||||
case duplicates
|
||||
case patchDocument(Int, DocumentUpdate)
|
||||
case putContent(Int, String)
|
||||
case deleteDocument(Int)
|
||||
case search(String, SearchMode?, Int?, Bool?)
|
||||
case ask(String, Int?, String?, Bool?)
|
||||
case memos(MemoListQuery)
|
||||
case memo(Int)
|
||||
case createMemo(MemoCreate)
|
||||
case patchMemo(Int, MemoUpdate)
|
||||
case pinMemo(Int, Bool)
|
||||
case archiveMemo(Int, Bool)
|
||||
case toggleMemoTask(Int, Int, Bool)
|
||||
case deleteMemo(Int)
|
||||
case digest(String?, String?)
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .login, .refresh, .logout, .createMemo: return "POST"
|
||||
case .patchDocument, .patchMemo, .pinMemo, .archiveMemo, .toggleMemoTask: return "PATCH"
|
||||
case .putContent: return "PUT"
|
||||
case .deleteDocument, .deleteMemo: return "DELETE"
|
||||
default: return "GET"
|
||||
}
|
||||
}
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .login: return "auth/login"
|
||||
case .me: return "auth/me"
|
||||
case .refresh: return "auth/refresh"
|
||||
case .logout: return "auth/logout"
|
||||
case .documents: return "documents/"
|
||||
case .document(let id): return "documents/\(id)"
|
||||
case .documentContent(let id): return "documents/\(id)/content"
|
||||
case .documentTree: return "documents/tree"
|
||||
case .categoryCounts: return "documents/stats/category-counts"
|
||||
case .duplicates: return "documents/duplicates"
|
||||
case .patchDocument(let id, _): return "documents/\(id)"
|
||||
case .putContent(let id, _): return "documents/\(id)/content"
|
||||
case .deleteDocument(let id): return "documents/\(id)"
|
||||
case .search: return "search/"
|
||||
case .ask: return "search/ask"
|
||||
case .memos: return "memos/"
|
||||
case .memo(let id): return "memos/\(id)"
|
||||
case .createMemo: return "memos/"
|
||||
case .patchMemo(let id, _): return "memos/\(id)"
|
||||
case .pinMemo(let id, _): return "memos/\(id)/pin"
|
||||
case .archiveMemo(let id, _): return "memos/\(id)/archive"
|
||||
case .toggleMemoTask(let id, let idx, _): return "memos/\(id)/tasks/\(idx)"
|
||||
case .deleteMemo(let id): return "memos/\(id)"
|
||||
case .digest: return "digest"
|
||||
}
|
||||
}
|
||||
|
||||
/// Bearer header applies to everything except login/refresh (refresh rides the HttpOnly cookie).
|
||||
var requiresBearer: Bool {
|
||||
switch self {
|
||||
case .login, .refresh: return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] {
|
||||
var items: [URLQueryItem] = []
|
||||
func add(_ name: String, _ value: String?) { if let value { items.append(URLQueryItem(name: name, value: value)) } }
|
||||
switch self {
|
||||
case .documents(let q):
|
||||
add("page", String(q.page)); add("page_size", String(q.pageSize))
|
||||
add("domain", q.domain); add("sub_group", q.subGroup); add("source", q.source)
|
||||
add("format", q.format); add("review_status", q.reviewStatus); add("category", q.category)
|
||||
case .search(let qq, let mode, let page, let debug):
|
||||
add("q", qq); add("mode", mode?.rawValue); add("page", page.map(String.init)); add("debug", debug.map(String.init))
|
||||
case .ask(let qq, let limit, let backend, let debug):
|
||||
add("q", qq); add("limit", limit.map(String.init)); add("backend", backend); add("debug", debug.map(String.init))
|
||||
case .memos(let q):
|
||||
add("page", String(q.page)); add("page_size", String(q.pageSize))
|
||||
add("pinned", q.pinned.map(String.init)); add("archived", q.archived.map(String.init))
|
||||
case .digest(let date, let country):
|
||||
add("date", date); add("country", country)
|
||||
default:
|
||||
break
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func httpBody(_ encoder: JSONEncoder) throws -> Data? {
|
||||
switch self {
|
||||
case .login(let u, let p, let totp):
|
||||
return try encoder.encode(LoginBody(username: u, password: p, totpCode: totp))
|
||||
case .patchDocument(_, let update):
|
||||
return try encoder.encode(update)
|
||||
case .putContent(_, let content):
|
||||
return try encoder.encode(ContentBody(content: content))
|
||||
case .createMemo(let create):
|
||||
return try encoder.encode(create)
|
||||
case .patchMemo(_, let update):
|
||||
return try encoder.encode(update)
|
||||
case .pinMemo(_, let pinned):
|
||||
return try encoder.encode(PinnedBody(pinned: pinned))
|
||||
case .archiveMemo(_, let archived):
|
||||
return try encoder.encode(ArchivedBody(archived: archived))
|
||||
case .toggleMemoTask(_, _, let checked):
|
||||
return try encoder.encode(CheckedBody(checked: checked))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoginBody: Encodable {
|
||||
let username: String
|
||||
let password: String
|
||||
let totpCode: String?
|
||||
enum CodingKeys: String, CodingKey { case username, password; case totpCode = "totp_code" }
|
||||
}
|
||||
private struct ContentBody: Encodable { let content: String }
|
||||
private struct PinnedBody: Encodable { let pinned: Bool }
|
||||
private struct ArchivedBody: Encodable { let archived: Bool }
|
||||
private struct CheckedBody: Encodable { let checked: Bool }
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
/// Real-network DSClient (FU-A). Same DTOs/decoder as FixtureDSClient, so swapping it in is
|
||||
/// behavior-identical except for I/O. URLSession with shared cookie storage so the HttpOnly refresh
|
||||
/// cookie is replayed on `/auth/refresh`. A 401 on a bearer request triggers a single-flight refresh
|
||||
/// + ONE retry (never on login/refresh/logout, to avoid loops).
|
||||
///
|
||||
/// Immutable stored props (URLSession/decoder shared but never mutated) → @unchecked Sendable.
|
||||
public final class LiveDSClient: DSClient, @unchecked Sendable {
|
||||
private let base: DSBaseURL
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let encoder: JSONEncoder
|
||||
private let tokens: TokenProvider
|
||||
|
||||
public init(base: DSBaseURL = .publicTLS, persistence: TokenPersistence = InMemoryTokenStore()) {
|
||||
let baseURL = base
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieStorage = .shared
|
||||
config.httpShouldSetCookies = true
|
||||
let session = URLSession(configuration: config)
|
||||
let decoder = DSDecoder.make()
|
||||
|
||||
self.base = baseURL
|
||||
self.session = session
|
||||
self.decoder = decoder
|
||||
self.encoder = DSEncoder.make()
|
||||
// Refresh closure captures only Sendable values (no self): raw POST /auth/refresh via cookie.
|
||||
self.tokens = TokenProvider(persistence: persistence) {
|
||||
var request = URLRequest(url: baseURL.url.appendingPathComponent("auth/refresh"))
|
||||
request.httpMethod = "POST"
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
throw DSError.unauthorized(message: "refresh failed")
|
||||
}
|
||||
return try decoder.decode(AccessTokenResponse.self, from: data).accessToken
|
||||
}
|
||||
}
|
||||
|
||||
public func setAccessToken(_ token: String) async { await tokens.set(token) }
|
||||
|
||||
// MARK: - Request building / sending
|
||||
|
||||
private func makeRequest(_ endpoint: DSEndpoint, token: String?) throws -> URLRequest {
|
||||
// Build URL from the base string to preserve trailing slashes; URLComponents percent-encodes.
|
||||
let raw = base.url.absoluteString + "/" + endpoint.path
|
||||
guard var comps = URLComponents(string: raw) else {
|
||||
throw DSError.transport(underlying: "bad URL \(raw)")
|
||||
}
|
||||
if !endpoint.queryItems.isEmpty { comps.queryItems = endpoint.queryItems }
|
||||
guard let url = comps.url else { throw DSError.transport(underlying: "bad URL components") }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = endpoint.method
|
||||
if endpoint.requiresBearer, let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||
if let body = try endpoint.httpBody(encoder) {
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
private func perform(_ endpoint: DSEndpoint) async throws -> Data {
|
||||
let request = try makeRequest(endpoint, token: await tokens.current())
|
||||
let (data, response) = try await dataOrTransport(request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw DSError.transport(underlying: "no HTTP response")
|
||||
}
|
||||
if http.statusCode == 401, endpoint.requiresBearer {
|
||||
// Single-flight refresh + one retry.
|
||||
let newToken = try await tokens.refreshOnce()
|
||||
let retry = try makeRequest(endpoint, token: newToken)
|
||||
let (data2, response2) = try await dataOrTransport(retry)
|
||||
guard let http2 = response2 as? HTTPURLResponse else {
|
||||
throw DSError.transport(underlying: "no HTTP response")
|
||||
}
|
||||
guard (200..<300).contains(http2.statusCode) else { throw DSError.from(status: http2.statusCode, data: data2) }
|
||||
return data2
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else { throw DSError.from(status: http.statusCode, data: data) }
|
||||
return data
|
||||
}
|
||||
|
||||
private func dataOrTransport(_ request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
do { return try await session.data(for: request) }
|
||||
catch { throw DSError.transport(underlying: "\(error)") }
|
||||
}
|
||||
|
||||
private func send<T: Decodable>(_ endpoint: DSEndpoint, as type: T.Type) async throws -> T {
|
||||
let data = try await perform(endpoint)
|
||||
do { return try decoder.decode(T.self, from: data) }
|
||||
catch { throw DSError.decoding("\(endpoint.path): \(error)") }
|
||||
}
|
||||
|
||||
private func sendVoid(_ endpoint: DSEndpoint) async throws { _ = try await perform(endpoint) }
|
||||
|
||||
// MARK: - DSClient
|
||||
|
||||
public func login(username: String, password: String, totpCode: String?) async throws -> AccessTokenResponse {
|
||||
let token: AccessTokenResponse = try await send(.login(username, password, totpCode), as: AccessTokenResponse.self)
|
||||
await tokens.set(token.accessToken)
|
||||
return token
|
||||
}
|
||||
public func me() async throws -> UserResponse { try await send(.me, as: UserResponse.self) }
|
||||
public func refresh() async throws -> AccessTokenResponse {
|
||||
let token: AccessTokenResponse = try await send(.refresh, as: AccessTokenResponse.self)
|
||||
await tokens.set(token.accessToken)
|
||||
return token
|
||||
}
|
||||
public func logout() async throws { try await sendVoid(.logout); await tokens.clear() }
|
||||
|
||||
public func documents(_ query: DocumentListQuery) async throws -> DocumentListResponse { try await send(.documents(query), as: DocumentListResponse.self) }
|
||||
public func document(id: Int) async throws -> DocumentDetailResponse { try await send(.document(id), as: DocumentDetailResponse.self) }
|
||||
public func documentContent(id: Int) async throws -> DocumentContentResponse { try await send(.documentContent(id), as: DocumentContentResponse.self) }
|
||||
public func documentTree() async throws -> [DomainTreeNode] { try await send(.documentTree, as: [DomainTreeNode].self) }
|
||||
public func categoryCounts() async throws -> CategoryCounts { try await send(.categoryCounts, as: CategoryCounts.self) }
|
||||
public func duplicates() async throws -> DuplicatesResponse { try await send(.duplicates, as: DuplicatesResponse.self) }
|
||||
public func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { try await send(.patchDocument(id, update), as: DocumentResponse.self) }
|
||||
public func putContent(id: Int, content: String) async throws { try await sendVoid(.putContent(id, content)) }
|
||||
public func deleteDocument(id: Int) async throws { try await sendVoid(.deleteDocument(id)) }
|
||||
|
||||
public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await send(.search(q, mode, page, debug), as: SearchResponse.self) }
|
||||
public func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await send(.ask(q, limit, backend, debug), as: AskResponse.self) }
|
||||
|
||||
public func memos(_ query: MemoListQuery) async throws -> MemoListResponse { try await send(.memos(query), as: MemoListResponse.self) }
|
||||
public func memo(id: Int) async throws -> MemoResponse { try await send(.memo(id), as: MemoResponse.self) }
|
||||
public func createMemo(_ create: MemoCreate) async throws -> MemoResponse { try await send(.createMemo(create), as: MemoResponse.self) }
|
||||
public func patchMemo(id: Int, _ update: MemoUpdate) async throws -> MemoResponse { try await send(.patchMemo(id, update), as: MemoResponse.self) }
|
||||
public func pinMemo(id: Int, pinned: Bool) async throws -> MemoResponse { try await send(.pinMemo(id, pinned), as: MemoResponse.self) }
|
||||
public func archiveMemo(id: Int, archived: Bool) async throws -> MemoResponse { try await send(.archiveMemo(id, archived), as: MemoResponse.self) }
|
||||
public func toggleMemoTask(id: Int, taskIndex: Int, checked: Bool) async throws -> MemoResponse { try await send(.toggleMemoTask(id, taskIndex, checked), as: MemoResponse.self) }
|
||||
public func deleteMemo(id: Int) async throws { try await sendVoid(.deleteMemo(id)) }
|
||||
|
||||
public func digest(date: String?, country: String?) async throws -> DigestResponse { try await send(.digest(date, country), as: DigestResponse.self) }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import XCTest
|
||||
@testable import DSKit
|
||||
|
||||
/// Offline tests for the live-client plumbing (the live HTTP path itself needs a real backend, FU-A).
|
||||
final class ClientPlumbingTests: XCTestCase {
|
||||
|
||||
func testEndpointPathsAndMethods() {
|
||||
XCTAssertEqual(DSEndpoint.documents(DocumentListQuery()).path, "documents/")
|
||||
XCTAssertEqual(DSEndpoint.documents(DocumentListQuery()).method, "GET")
|
||||
XCTAssertEqual(DSEndpoint.document(4912).path, "documents/4912")
|
||||
XCTAssertEqual(DSEndpoint.documentTree.path, "documents/tree")
|
||||
XCTAssertEqual(DSEndpoint.ask("q", nil, nil, nil).path, "search/ask")
|
||||
XCTAssertEqual(DSEndpoint.pinMemo(5, true).method, "PATCH")
|
||||
XCTAssertEqual(DSEndpoint.pinMemo(5, true).path, "memos/5/pin")
|
||||
XCTAssertEqual(DSEndpoint.toggleMemoTask(5, 2, true).path, "memos/5/tasks/2")
|
||||
XCTAssertEqual(DSEndpoint.putContent(7, "x").method, "PUT")
|
||||
XCTAssertEqual(DSEndpoint.deleteMemo(9).method, "DELETE")
|
||||
}
|
||||
|
||||
func testEndpointBearerRule() {
|
||||
XCTAssertFalse(DSEndpoint.login("u", "p", nil).requiresBearer)
|
||||
XCTAssertFalse(DSEndpoint.refresh.requiresBearer)
|
||||
XCTAssertTrue(DSEndpoint.me.requiresBearer)
|
||||
XCTAssertTrue(DSEndpoint.documents(DocumentListQuery()).requiresBearer)
|
||||
}
|
||||
|
||||
func testEndpointQueryItems() {
|
||||
let items = DSEndpoint.ask("충격시험", 5, "gemma-macmini", nil).queryItems
|
||||
let dict = Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value) })
|
||||
XCTAssertEqual(dict["q"], "충격시험")
|
||||
XCTAssertEqual(dict["backend"], "gemma-macmini")
|
||||
XCTAssertEqual(dict["limit"], "5")
|
||||
XCTAssertNil(dict["debug"]) // nil params are skipped, not sent as "nil"
|
||||
}
|
||||
|
||||
func testEndpointBodies() throws {
|
||||
let encoder = DSEncoder.make()
|
||||
let pinBody = try DSEndpoint.pinMemo(5, true).httpBody(encoder)
|
||||
XCTAssertEqual(String(data: pinBody ?? Data(), encoding: .utf8), #"{"pinned":true}"#)
|
||||
let loginBody = try XCTUnwrap(DSEndpoint.login("hyungi", "pw", nil).httpBody(encoder))
|
||||
let s = String(data: loginBody, encoding: .utf8) ?? ""
|
||||
XCTAssertTrue(s.contains("\"username\":\"hyungi\""))
|
||||
XCTAssertFalse(s.contains("totp_code")) // nil optional omitted
|
||||
XCTAssertNil(try DSEndpoint.me.httpBody(encoder)) // GET has no body
|
||||
}
|
||||
|
||||
/// Single-flight refresh: N concurrent refreshOnce() calls must fire the refresh closure exactly once.
|
||||
func testSingleFlightRefresh() async throws {
|
||||
let counter = CallCounter()
|
||||
let provider = TokenProvider(persistence: InMemoryTokenStore()) {
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
let n = await counter.inc()
|
||||
return "token-\(n)"
|
||||
}
|
||||
let results = await withTaskGroup(of: String.self) { group -> [String] in
|
||||
for _ in 0..<8 { group.addTask { (try? await provider.refreshOnce()) ?? "ERR" } }
|
||||
var collected: [String] = []
|
||||
for await value in group { collected.append(value) }
|
||||
return collected
|
||||
}
|
||||
let calls = await counter.value
|
||||
XCTAssertEqual(calls, 1, "refresh closure must run exactly once under concurrency")
|
||||
XCTAssertTrue(results.allSatisfy { $0 == "token-1" })
|
||||
}
|
||||
|
||||
func testTokenProviderCacheAndPersistence() async {
|
||||
let store = InMemoryTokenStore()
|
||||
let provider = TokenProvider(persistence: store) { "x" }
|
||||
await provider.set("abc")
|
||||
let current = await provider.current()
|
||||
XCTAssertEqual(current, "abc")
|
||||
XCTAssertEqual(store.read(), "abc")
|
||||
await provider.clear()
|
||||
let cleared = await provider.current()
|
||||
XCTAssertNil(cleared)
|
||||
}
|
||||
}
|
||||
|
||||
actor CallCounter {
|
||||
var value = 0
|
||||
func inc() -> Int { value += 1; return value }
|
||||
}
|
||||
Reference in New Issue
Block a user