300 lines
11 KiB
Swift
300 lines
11 KiB
Swift
#if os(macOS)
|
|
|
|
import Foundation
|
|
import SQLite3
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
struct OpenCodeGoLocalUsageReaderTests {
|
|
@Test
|
|
func `reads local OpenCode Go history into usage windows`() throws {
|
|
let env = try Self.makeEnvironment()
|
|
defer { try? FileManager.default.removeItem(at: env.root) }
|
|
|
|
try Self.writeAuth(to: env.authURL)
|
|
try Self.createDatabase(at: env.databaseURL)
|
|
try Self.insertMessage(
|
|
databaseURL: env.databaseURL,
|
|
createdMs: Self.ms("2026-03-06T11:00:00.000Z"),
|
|
cost: 3.0)
|
|
try Self.insertMessage(
|
|
databaseURL: env.databaseURL,
|
|
createdMs: Self.ms("2026-03-05T12:00:00.000Z"),
|
|
cost: 6.0)
|
|
try Self.insertMessage(
|
|
databaseURL: env.databaseURL,
|
|
createdMs: Self.ms("2026-02-25T07:53:16.000Z"),
|
|
cost: 2.0)
|
|
|
|
let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL)
|
|
let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400))
|
|
|
|
#expect(snapshot.rollingUsagePercent == 25)
|
|
#expect(snapshot.weeklyUsagePercent == 30)
|
|
#expect(snapshot.monthlyUsagePercent == 18.3)
|
|
#expect(snapshot.rollingResetInSec == 14400)
|
|
#expect(snapshot.weeklyResetInSec == 216_000)
|
|
#expect(snapshot.monthlyResetInSec == 1_626_796)
|
|
}
|
|
|
|
@Test
|
|
func `auth without history falls through to web strategy`() throws {
|
|
let env = try Self.makeEnvironment()
|
|
defer { try? FileManager.default.removeItem(at: env.root) }
|
|
|
|
try Self.writeAuth(to: env.authURL)
|
|
|
|
let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL)
|
|
|
|
#expect(throws: OpenCodeGoLocalUsageError.historyUnavailable("database not found")) {
|
|
_ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400))
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `auth with unreadable history falls through to web strategy`() throws {
|
|
let env = try Self.makeEnvironment()
|
|
defer { try? FileManager.default.removeItem(at: env.root) }
|
|
|
|
try Self.writeAuth(to: env.authURL)
|
|
var db: OpaquePointer?
|
|
guard sqlite3_open(env.databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open }
|
|
sqlite3_close(db)
|
|
|
|
let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL)
|
|
|
|
#expect(throws: OpenCodeGoLocalUsageError.self) {
|
|
_ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400))
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `monthly window keeps original anchor after shorter month clamp`() throws {
|
|
let env = try Self.makeEnvironment()
|
|
defer { try? FileManager.default.removeItem(at: env.root) }
|
|
|
|
try Self.writeAuth(to: env.authURL)
|
|
try Self.createDatabase(at: env.databaseURL)
|
|
try Self.insertMessage(
|
|
databaseURL: env.databaseURL,
|
|
createdMs: Self.ms("2026-01-31T00:00:00.000Z"),
|
|
cost: 1.0)
|
|
try Self.insertMessage(
|
|
databaseURL: env.databaseURL,
|
|
createdMs: Self.ms("2026-03-29T10:00:00.000Z"),
|
|
cost: 6.0)
|
|
|
|
let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL)
|
|
let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-29T12:00:00.000Z")) / 1000)
|
|
let snapshot = try reader.fetch(now: now)
|
|
|
|
#expect(snapshot.monthlyUsagePercent == 10)
|
|
#expect(snapshot.monthlyResetInSec == 129_600)
|
|
}
|
|
|
|
@Test
|
|
func `reads step finish parts when message only stores metadata`() throws {
|
|
let env = try Self.makeEnvironment()
|
|
defer { try? FileManager.default.removeItem(at: env.root) }
|
|
|
|
try Self.writeAuth(to: env.authURL)
|
|
try Self.createDatabase(at: env.databaseURL)
|
|
let messageID = try Self.insertMessage(
|
|
databaseURL: env.databaseURL,
|
|
createdMs: Self.ms("2026-03-06T11:00:00.000Z"),
|
|
cost: nil)
|
|
try Self.insertStepFinishPart(
|
|
databaseURL: env.databaseURL,
|
|
messageID: messageID,
|
|
createdMs: Self.ms("2026-03-06T11:00:00.000Z"),
|
|
cost: 3.0)
|
|
|
|
let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL)
|
|
let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400))
|
|
|
|
#expect(snapshot.rollingUsagePercent == 25)
|
|
#expect(snapshot.weeklyUsagePercent == 10)
|
|
#expect(snapshot.monthlyUsagePercent == 5)
|
|
}
|
|
|
|
@Test
|
|
func `does not double count step finish parts when message has cost`() throws {
|
|
let env = try Self.makeEnvironment()
|
|
defer { try? FileManager.default.removeItem(at: env.root) }
|
|
|
|
try Self.writeAuth(to: env.authURL)
|
|
try Self.createDatabase(at: env.databaseURL)
|
|
let messageID = try Self.insertMessage(
|
|
databaseURL: env.databaseURL,
|
|
createdMs: Self.ms("2026-03-06T11:00:00.000Z"),
|
|
cost: 3.0)
|
|
try Self.insertStepFinishPart(
|
|
databaseURL: env.databaseURL,
|
|
messageID: messageID,
|
|
createdMs: Self.ms("2026-03-06T11:00:00.000Z"),
|
|
cost: 3.0)
|
|
|
|
let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL)
|
|
let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400))
|
|
|
|
#expect(snapshot.rollingUsagePercent == 25)
|
|
#expect(snapshot.weeklyUsagePercent == 10)
|
|
#expect(snapshot.monthlyUsagePercent == 5)
|
|
}
|
|
|
|
@Test
|
|
func `missing auth and history is not detected`() throws {
|
|
let env = try Self.makeEnvironment()
|
|
defer { try? FileManager.default.removeItem(at: env.root) }
|
|
|
|
let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL)
|
|
|
|
#expect(throws: OpenCodeGoLocalUsageError.notDetected) {
|
|
_ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400))
|
|
}
|
|
}
|
|
|
|
private static func makeEnvironment() throws -> (root: URL, authURL: URL, databaseURL: URL) {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("OpenCodeGoLocalUsageReaderTests-\(UUID().uuidString)", isDirectory: true)
|
|
let directory = root
|
|
.appendingPathComponent(".local", isDirectory: true)
|
|
.appendingPathComponent("share", isDirectory: true)
|
|
.appendingPathComponent("opencode", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
return (
|
|
root,
|
|
directory.appendingPathComponent("auth.json", isDirectory: false),
|
|
directory.appendingPathComponent("opencode.db", isDirectory: false))
|
|
}
|
|
|
|
private static func writeAuth(to url: URL) throws {
|
|
let data = Data(#"{"opencode-go":{"type":"api-key","key":"go-key"}}"#.utf8)
|
|
try data.write(to: url)
|
|
}
|
|
|
|
private static func createDatabase(at url: URL) throws {
|
|
var db: OpaquePointer?
|
|
guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open }
|
|
defer { sqlite3_close(db) }
|
|
try Self.exec(
|
|
db: db,
|
|
sql: """
|
|
CREATE TABLE message (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
data TEXT NOT NULL,
|
|
time_created INTEGER,
|
|
time_updated INTEGER
|
|
);
|
|
CREATE TABLE part (
|
|
id TEXT PRIMARY KEY,
|
|
message_id TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
data TEXT NOT NULL,
|
|
time_created INTEGER,
|
|
time_updated INTEGER
|
|
);
|
|
""")
|
|
}
|
|
|
|
@discardableResult
|
|
private static func insertMessage(databaseURL: URL, createdMs: Int64, cost: Double?) throws -> String {
|
|
var db: OpaquePointer?
|
|
guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open }
|
|
defer { sqlite3_close(db) }
|
|
|
|
let messageID = UUID().uuidString
|
|
var payload: [String: Any] = [
|
|
"providerID": "opencode-go",
|
|
"role": "assistant",
|
|
"time": ["created": createdMs],
|
|
]
|
|
if let cost {
|
|
payload["cost"] = cost
|
|
}
|
|
let data = try JSONSerialization.data(withJSONObject: payload)
|
|
let json = String(data: data, encoding: .utf8) ?? "{}"
|
|
|
|
var stmt: OpaquePointer?
|
|
guard sqlite3_prepare_v2(
|
|
db,
|
|
"INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
|
|
-1,
|
|
&stmt,
|
|
nil) == SQLITE_OK
|
|
else { throw SQLiteTestError.prepare }
|
|
defer { sqlite3_finalize(stmt) }
|
|
|
|
let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
sqlite3_bind_text(stmt, 1, messageID, -1, transient)
|
|
sqlite3_bind_text(stmt, 2, "session-1", -1, transient)
|
|
sqlite3_bind_text(stmt, 3, json, -1, transient)
|
|
sqlite3_bind_int64(stmt, 4, createdMs)
|
|
sqlite3_bind_int64(stmt, 5, createdMs)
|
|
guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step }
|
|
return messageID
|
|
}
|
|
|
|
private static func insertStepFinishPart(
|
|
databaseURL: URL,
|
|
messageID: String,
|
|
createdMs: Int64,
|
|
cost: Double) throws
|
|
{
|
|
var db: OpaquePointer?
|
|
guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open }
|
|
defer { sqlite3_close(db) }
|
|
|
|
let payload: [String: Any] = [
|
|
"type": "step-finish",
|
|
"cost": cost,
|
|
"tokens": ["input": 1, "output": 1, "total": 2],
|
|
]
|
|
let data = try JSONSerialization.data(withJSONObject: payload)
|
|
let json = String(data: data, encoding: .utf8) ?? "{}"
|
|
|
|
var stmt: OpaquePointer?
|
|
guard sqlite3_prepare_v2(
|
|
db,
|
|
"INSERT INTO part (id, message_id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)",
|
|
-1,
|
|
&stmt,
|
|
nil) == SQLITE_OK
|
|
else { throw SQLiteTestError.prepare }
|
|
defer { sqlite3_finalize(stmt) }
|
|
|
|
let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
sqlite3_bind_text(stmt, 1, UUID().uuidString, -1, transient)
|
|
sqlite3_bind_text(stmt, 2, messageID, -1, transient)
|
|
sqlite3_bind_text(stmt, 3, "session-1", -1, transient)
|
|
sqlite3_bind_text(stmt, 4, json, -1, transient)
|
|
sqlite3_bind_int64(stmt, 5, createdMs)
|
|
sqlite3_bind_int64(stmt, 6, createdMs)
|
|
guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step }
|
|
}
|
|
|
|
private static func exec(db: OpaquePointer?, sql: String) throws {
|
|
var message: UnsafeMutablePointer<CChar>?
|
|
guard sqlite3_exec(db, sql, nil, nil, &message) == SQLITE_OK else {
|
|
sqlite3_free(message)
|
|
throw SQLiteTestError.exec
|
|
}
|
|
}
|
|
|
|
private static func ms(_ iso: String) -> Int64 {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return Int64((formatter.date(from: iso)?.timeIntervalSince1970 ?? 0) * 1000)
|
|
}
|
|
|
|
private enum SQLiteTestError: Error {
|
|
case open
|
|
case prepare
|
|
case step
|
|
case exec
|
|
}
|
|
}
|
|
|
|
#endif
|