311 lines
11 KiB
Swift
311 lines
11 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
@Suite(.serialized)
|
|
struct ManusProviderTests {
|
|
private static let now = Date(timeIntervalSince1970: 1_744_000_000)
|
|
|
|
private final class LockedArray<Element>: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var values: [Element] = []
|
|
|
|
func append(_ value: Element) {
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
self.values.append(value)
|
|
}
|
|
|
|
func snapshot() -> [Element] {
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
return self.values
|
|
}
|
|
}
|
|
|
|
private struct StubClaudeFetcher: ClaudeUsageFetching {
|
|
func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
|
|
throw ClaudeUsageError.parseFailed("stub")
|
|
}
|
|
|
|
func debugRawProbe(model _: String) async -> String {
|
|
"stub"
|
|
}
|
|
|
|
func detectVersion() -> String? {
|
|
nil
|
|
}
|
|
}
|
|
|
|
private func makeContext(
|
|
settings: ProviderSettingsSnapshot?,
|
|
env: [String: String] = [:]) -> ProviderFetchContext
|
|
{
|
|
ProviderFetchContext(
|
|
runtime: .app,
|
|
sourceMode: .auto,
|
|
includeCredits: false,
|
|
webTimeout: 1,
|
|
webDebugDumpHTML: false,
|
|
verbose: false,
|
|
env: env,
|
|
settings: settings,
|
|
fetcher: UsageFetcher(environment: env),
|
|
claudeFetcher: StubClaudeFetcher(),
|
|
browserDetection: BrowserDetection(cacheTTL: 0))
|
|
}
|
|
|
|
private func stubResponse() -> ManusCreditsResponse {
|
|
ManusCreditsResponse(
|
|
totalCredits: 120,
|
|
freeCredits: 20,
|
|
periodicCredits: 80,
|
|
addonCredits: 10,
|
|
refreshCredits: 30,
|
|
maxRefreshCredits: 300,
|
|
proMonthlyCredits: 100,
|
|
eventCredits: 10,
|
|
nextRefreshTime: Date(timeIntervalSince1970: 1_744_003_600),
|
|
refreshInterval: "daily")
|
|
}
|
|
|
|
private func withIsolatedCacheStore<T>(operation: () async throws -> T) async rethrows -> T {
|
|
let service = "manus-provider-tests-\(UUID().uuidString)"
|
|
return try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
|
KeychainCacheStore.setTestStoreForTesting(true)
|
|
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
|
return try await operation()
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `off mode ignores environment session token`() async {
|
|
let strategy = ManusWebFetchStrategy()
|
|
let settings = ProviderSettingsSnapshot.make(
|
|
manus: ProviderSettingsSnapshot.ManusProviderSettings(
|
|
cookieSource: .off,
|
|
manualCookieHeader: nil))
|
|
let context = self.makeContext(
|
|
settings: settings,
|
|
env: ["MANUS_SESSION_TOKEN": "env-token"])
|
|
|
|
#expect(await strategy.isAvailable(context) == false)
|
|
}
|
|
|
|
@Test
|
|
func `manual mode invalid cookie does not fall back to cache or environment`() async {
|
|
await self.withIsolatedCacheStore {
|
|
CookieHeaderCache.store(
|
|
provider: .manus,
|
|
cookieHeader: "session_id=cached-token",
|
|
sourceLabel: "web")
|
|
|
|
let strategy = ManusWebFetchStrategy()
|
|
let settings = ProviderSettingsSnapshot.make(
|
|
manus: ProviderSettingsSnapshot.ManusProviderSettings(
|
|
cookieSource: .manual,
|
|
manualCookieHeader: "foo=bar"))
|
|
let context = self.makeContext(
|
|
settings: settings,
|
|
env: ["MANUS_SESSION_TOKEN": "env-token"])
|
|
|
|
do {
|
|
_ = try await strategy.fetch(context)
|
|
Issue.record("Expected invalid manual cookie instead of falling back to cache/environment")
|
|
} catch let error as ManusAPIError {
|
|
#expect(error == .invalidCookie)
|
|
} catch {
|
|
Issue.record("Expected ManusAPIError.invalidCookie, got \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `environment token does not populate browser cache`() async throws {
|
|
try await self.withIsolatedCacheStore {
|
|
#if os(macOS)
|
|
ManusCookieImporter.importSessionsOverrideForTesting = { _, _ in
|
|
throw ManusCookieImportError.noCookies
|
|
}
|
|
ManusCookieImporter.importSessionOverrideForTesting = nil
|
|
defer {
|
|
ManusCookieImporter.importSessionsOverrideForTesting = nil
|
|
ManusCookieImporter.importSessionOverrideForTesting = nil
|
|
}
|
|
#endif
|
|
|
|
let strategy = ManusWebFetchStrategy()
|
|
let settings = ProviderSettingsSnapshot.make(
|
|
manus: ProviderSettingsSnapshot.ManusProviderSettings(
|
|
cookieSource: .auto,
|
|
manualCookieHeader: nil))
|
|
let context = self.makeContext(
|
|
settings: settings,
|
|
env: ["MANUS_SESSION_TOKEN": "env-token"])
|
|
let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in
|
|
#expect(token == "env-token")
|
|
return self.stubResponse()
|
|
}
|
|
|
|
_ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
|
|
try await strategy.fetch(context)
|
|
})
|
|
|
|
#expect(CookieHeaderCache.load(provider: .manus) == nil)
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
@Test
|
|
func `invalid browser token falls back to environment token`() async throws {
|
|
try await self.withIsolatedCacheStore {
|
|
let browserCookie = try #require(HTTPCookie(properties: [
|
|
.domain: "manus.im",
|
|
.path: "/",
|
|
.name: "session_id",
|
|
.value: "browser-token",
|
|
.secure: "TRUE",
|
|
]))
|
|
ManusCookieImporter.importSessionOverrideForTesting = { _, _ in
|
|
ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome")
|
|
}
|
|
ManusCookieImporter.importSessionsOverrideForTesting = nil
|
|
defer {
|
|
ManusCookieImporter.importSessionOverrideForTesting = nil
|
|
ManusCookieImporter.importSessionsOverrideForTesting = nil
|
|
}
|
|
|
|
let attempts = LockedArray<String>()
|
|
let strategy = ManusWebFetchStrategy()
|
|
let settings = ProviderSettingsSnapshot.make(
|
|
manus: ProviderSettingsSnapshot.ManusProviderSettings(
|
|
cookieSource: .auto,
|
|
manualCookieHeader: nil))
|
|
let context = self.makeContext(
|
|
settings: settings,
|
|
env: ["MANUS_SESSION_TOKEN": "env-token"])
|
|
let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in
|
|
attempts.append(token)
|
|
if token == "browser-token" {
|
|
throw ManusAPIError.invalidToken
|
|
}
|
|
#expect(token == "env-token")
|
|
return self.stubResponse()
|
|
}
|
|
|
|
_ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
|
|
try await strategy.fetch(context)
|
|
})
|
|
|
|
#expect(attempts.snapshot() == ["browser-token", "env-token"])
|
|
#expect(CookieHeaderCache.load(provider: .manus) == nil)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `browser token populates cache after successful fetch`() async throws {
|
|
try await self.withIsolatedCacheStore {
|
|
let browserCookie = try #require(HTTPCookie(properties: [
|
|
.domain: "manus.im",
|
|
.path: "/",
|
|
.name: "session_id",
|
|
.value: "browser-token",
|
|
.secure: "TRUE",
|
|
]))
|
|
ManusCookieImporter.importSessionOverrideForTesting = { _, _ in
|
|
ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome")
|
|
}
|
|
ManusCookieImporter.importSessionsOverrideForTesting = nil
|
|
defer {
|
|
ManusCookieImporter.importSessionOverrideForTesting = nil
|
|
ManusCookieImporter.importSessionsOverrideForTesting = nil
|
|
}
|
|
|
|
let strategy = ManusWebFetchStrategy()
|
|
let settings = ProviderSettingsSnapshot.make(
|
|
manus: ProviderSettingsSnapshot.ManusProviderSettings(
|
|
cookieSource: .auto,
|
|
manualCookieHeader: nil))
|
|
let context = self.makeContext(settings: settings)
|
|
let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in
|
|
#expect(token == "browser-token")
|
|
return self.stubResponse()
|
|
}
|
|
|
|
_ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
|
|
try await strategy.fetch(context)
|
|
})
|
|
|
|
let cached = CookieHeaderCache.load(provider: .manus)
|
|
#expect(cached?.cookieHeader == "session_id=browser-token")
|
|
}
|
|
}
|
|
#endif
|
|
|
|
@Test
|
|
func `settings reader accepts full cookie header from environment`() {
|
|
let env = ["MANUS_COOKIE": "foo=bar; session_id=env-cookie-token; baz=qux"]
|
|
#expect(ManusSettingsReader.sessionToken(environment: env) == "env-cookie-token")
|
|
}
|
|
|
|
@Test
|
|
func `parse response tolerates sparse live payload`() throws {
|
|
let data = Data("""
|
|
{
|
|
"totalCredits": 2869,
|
|
"freeCredits": 1500,
|
|
"periodicCredits": 1369,
|
|
"proMonthlyCredits": 4000,
|
|
"maxRefreshCredits": 300,
|
|
"nextRefreshTime": "2026-04-13T00:00:00Z",
|
|
"refreshInterval": "daily",
|
|
"userFlag": { "drc16": true }
|
|
}
|
|
""".utf8)
|
|
|
|
let response = try ManusUsageFetcher.parseResponse(data)
|
|
#expect(response.totalCredits == 2869)
|
|
#expect(response.periodicCredits == 1369)
|
|
#expect(response.proMonthlyCredits == 4000)
|
|
#expect(response.refreshCredits == 0)
|
|
#expect(response.addonCredits == 0)
|
|
#expect(response.maxRefreshCredits == 300)
|
|
#expect(response.nextRefreshTime != nil)
|
|
|
|
let snapshot = response.toUsageSnapshot(now: Self.now)
|
|
#expect(snapshot.providerCost == nil)
|
|
#expect(snapshot.primary?.usedPercent ?? 0 > 65)
|
|
#expect(snapshot.primary?.resetDescription == "Total 2,869 • Free 1,500")
|
|
#expect(snapshot.secondary?.usedPercent == 100)
|
|
#expect(snapshot.secondary?.resetDescription == "Daily: 0 / 300")
|
|
}
|
|
|
|
@Test
|
|
func `parse response rejects payload without credits fields`() {
|
|
let data = Data(#"{"error":"unauthorized","message":"session expired"}"#.utf8)
|
|
|
|
#expect(throws: ManusAPIError.self) {
|
|
try ManusUsageFetcher.parseResponse(data)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `parse response accepts wrapped envelope`() throws {
|
|
let data = Data("""
|
|
{
|
|
"data": {
|
|
"totalCredits": 100,
|
|
"proMonthlyCredits": 200,
|
|
"periodicCredits": 50,
|
|
"maxRefreshCredits": 10,
|
|
"refreshCredits": 5
|
|
}
|
|
}
|
|
""".utf8)
|
|
|
|
let response = try ManusUsageFetcher.parseResponse(data)
|
|
#expect(response.totalCredits == 100)
|
|
#expect(response.periodicCredits == 50)
|
|
}
|
|
}
|