157 lines
8.7 KiB
Swift
157 lines
8.7 KiB
Swift
import XCTest
|
|
@testable import Voice
|
|
|
|
private final class FeatureParityURLProtocol: URLProtocol {
|
|
static var handler: ((URLRequest) throws -> (Int, Data))?
|
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
override func startLoading() {
|
|
do {
|
|
let (status, data) = try Self.handler!(request)
|
|
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
|
|
client?.urlProtocol(self, didLoad: data)
|
|
client?.urlProtocolDidFinishLoading(self)
|
|
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
|
}
|
|
override func stopLoading() {}
|
|
}
|
|
|
|
private final class SuspendedFeatureTransport: ChatTransport {
|
|
var handler: (@MainActor (String) async throws -> Data)?
|
|
func request(_ path: String, method: String, body: [String: Any]?, query: [URLQueryItem]) async throws -> Data { try await handler!(path) }
|
|
func events(run: String, after: Int, receive: @escaping @MainActor (ChatEvent) throws -> Void) async throws {}
|
|
func cancel() {}
|
|
}
|
|
|
|
@MainActor
|
|
final class ChatFeatureParityTests: XCTestCase {
|
|
private func makeStore() throws -> (ChatFeatureStore, ChatAPI) {
|
|
guard case .success(let config) = ServerConfig.parse("https://hope.example") else { throw ChatFailure.invalidResponse }
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [FeatureParityURLProtocol.self]
|
|
let api = ChatAPI(config: config, token: "offline-fixture", session: URLSession(configuration: configuration))
|
|
return (ChatFeatureStore(api: api), api)
|
|
}
|
|
private func attachment(status: String = "ready", mime: String = "text/plain") -> ChatAttachment {
|
|
ChatAttachment(id: String(repeating: "a", count: 32), filename: "notes.txt", mime: mime, size: 5, extractionStatus: status, createdAt: 1)
|
|
}
|
|
private func envelope(_ item: ChatAttachment) throws -> Data {
|
|
try JSONSerialization.data(withJSONObject: ["attachment": ["id": item.id, "filename": item.filename, "mime": item.mime, "size": item.size, "extraction_status": item.extractionStatus, "created_at": item.createdAt]])
|
|
}
|
|
func testMetadataRevalidationRejectsUnavailableAndDeletedFiles() async throws {
|
|
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
|
|
let old = attachment()
|
|
let unavailable = try envelope(attachment(status: "unavailable"))
|
|
var metadataStatus = 200
|
|
FeatureParityURLProtocol.handler = { request in
|
|
if request.url?.path == "/api/capabilities" { return (200, Data("{\"features\":{\"attachments\":true}}".utf8)) }
|
|
XCTAssertEqual(request.url?.path, "/api/attachments/\(old.id)")
|
|
return (metadataStatus, unavailable)
|
|
}
|
|
let blocked = await store.validatedAttachment(old)
|
|
XCTAssertNil(blocked)
|
|
XCTAssertTrue(store.error?.contains("unavailable") == true)
|
|
metadataStatus = 404
|
|
let deleted = await store.validatedAttachment(old)
|
|
XCTAssertNil(deleted)
|
|
XCTAssertTrue(store.error?.contains("no longer available") == true)
|
|
}
|
|
func testCapabilityRefreshFailsClosedAndImagesCannotUpload() async throws {
|
|
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
|
|
store.enabled = ["attachments", "image_input"]
|
|
FeatureParityURLProtocol.handler = { _ in (503, Data()) }
|
|
let refreshed = await store.refreshCapabilities()
|
|
XCTAssertFalse(refreshed)
|
|
XCTAssertTrue(store.enabled.isEmpty)
|
|
FeatureParityURLProtocol.handler = { request in
|
|
XCTAssertEqual(request.url?.path, "/api/capabilities", "Unqualified images must never reach upload")
|
|
return (200, Data("{\"features\":{\"attachments\":true,\"image_input\":false}}".utf8))
|
|
}
|
|
let uploaded = await store.upload(Data([1, 2, 3]), filename: "photo.jpg", mime: "image/jpeg")
|
|
XCTAssertNil(uploaded)
|
|
XCTAssertTrue(store.error?.contains("Image input") == true)
|
|
}
|
|
func testLateCapabilityResponseCannotRestoreOldImageQualification() async throws {
|
|
let api = SuspendedFeatureTransport()
|
|
let store = ChatFeatureStore(api: api)
|
|
let firstStarted = expectation(description: "First capability request suspended")
|
|
var firstResponse: CheckedContinuation<Data, Error>?
|
|
var requests = 0
|
|
api.handler = { _ in
|
|
requests += 1
|
|
if requests == 1 {
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
firstResponse = continuation
|
|
firstStarted.fulfill()
|
|
}
|
|
}
|
|
return Data("{\"features\":{\"attachments\":true,\"image_input\":false}}".utf8)
|
|
}
|
|
let first = Task { await store.refreshCapabilities() }
|
|
await fulfillment(of: [firstStarted], timeout: 2)
|
|
let latest = await store.refreshCapabilities()
|
|
XCTAssertTrue(latest)
|
|
firstResponse?.resume(returning: Data("{\"features\":{\"attachments\":true,\"image_input\":true}}".utf8))
|
|
let stale = await first.value
|
|
XCTAssertFalse(stale)
|
|
XCTAssertFalse(store.enabled.contains("image_input"))
|
|
}
|
|
func testBlockedStatusesIncludeUnknownAndImageQualification() {
|
|
for status in ["failed", "encrypted", "unavailable", "pending"] {
|
|
XCTAssertNotNil(ChatFeatureStore.blockedReason(attachment(status: status), imageInput: true))
|
|
}
|
|
XCTAssertNil(ChatFeatureStore.blockedReason(attachment(status: "truncated"), imageInput: false))
|
|
XCTAssertNotNil(ChatFeatureStore.blockedReason(attachment(status: "image", mime: "image/png"), imageInput: false))
|
|
}
|
|
func testPausedTaskAndOptionalRepeatUseContractFields() async throws {
|
|
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
|
|
FeatureParityURLProtocol.handler = { request in
|
|
XCTAssertEqual(request.url?.path, "/api/tasks")
|
|
XCTAssertEqual(request.httpMethod, "POST")
|
|
var bytes = request.httpBody ?? Data()
|
|
if bytes.isEmpty, let stream = request.httpBodyStream {
|
|
stream.open(); defer { stream.close() }
|
|
var buffer = [UInt8](repeating: 0, count: 1024)
|
|
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; bytes.append(contentsOf: buffer.prefix(count)) }
|
|
}
|
|
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bytes) as? [String: Any])
|
|
XCTAssertEqual(body["paused"] as? Bool, true)
|
|
XCTAssertEqual(body["repeat"] as? Int, 3)
|
|
XCTAssertEqual(body["schedule"] as? String, "0 9 * * *")
|
|
return (201, Data("{\"task\":{\"id\":\"aaaaaaaaaaaa\",\"enabled\":false,\"repeat\":3}}".utf8))
|
|
}
|
|
await store.saveTask(nil, name: "Daily", schedule: "0 9 * * *", prompt: "Review", repeatCount: 3, paused: true)
|
|
XCTAssertNil(store.error)
|
|
XCTAssertEqual(store.tasks.first?.enabled, false)
|
|
XCTAssertEqual(store.tasks.first?.repeat, 3)
|
|
}
|
|
func testMemoryConflictRequiresExplicitLatestRevisionAndKeepsDraft() async throws {
|
|
let api = OfflineChatAPI()
|
|
let store = ChatFeatureStore(api: api)
|
|
await store.load()
|
|
let original = try XCTUnwrap(store.memory.first)
|
|
let first = await store.saveMemory(original, content: "Server update")
|
|
XCTAssertTrue(first)
|
|
let localDraft = "Keep my draft"
|
|
let stale = await store.saveMemory(original, content: localDraft)
|
|
XCTAssertFalse(stale)
|
|
XCTAssertTrue(store.error?.contains("Compare") == true)
|
|
let fetched = await store.latestMemory(original)
|
|
let latest = try XCTUnwrap(fetched)
|
|
XCTAssertEqual(latest.content, "Server update")
|
|
XCTAssertEqual(localDraft, "Keep my draft")
|
|
let accepted = await store.saveMemory(latest, content: localDraft)
|
|
XCTAssertTrue(accepted)
|
|
XCTAssertEqual(store.memory.first?.content, localDraft)
|
|
}
|
|
func testClosedStoreCannotReloadOrPreviewAccountData() async throws {
|
|
let (store, api) = try makeStore(); defer { api.cancel(); FeatureParityURLProtocol.handler = nil }
|
|
FeatureParityURLProtocol.handler = { _ in XCTFail("Closed account must not request private data"); return (200, Data()) }
|
|
store.close()
|
|
await store.load()
|
|
await store.preview(attachment())
|
|
XCTAssertTrue(store.enabled.isEmpty)
|
|
XCTAssertNil(store.previewAttachment)
|
|
}
|
|
}
|