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? 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) } }