import XCTest @testable import AIStudyApp final class ReadingEventUploadItemTests: XCTestCase { // MARK: - Codable roundtrip func test_codable_roundtrip_without_position() throws { let item = ReadingEventUploadItem( eventId: "evt-001", clientSessionId: "sess-001", materialId: "mat-001", eventType: "material_opened", position: nil, activeSecondsDelta: 0, clientTimestampMs: 1_718_150_400_000, sequence: 1, readingTargetType: "knowledge_source", knowledgeBaseId: "kb-001", platform: "ios", appVersion: "1.0.0", clientTimezoneOffsetMinutes: 480 ) let data = try JSONEncoder().encode(item) let decoded = try JSONDecoder().decode(ReadingEventUploadItem.self, from: data) XCTAssertEqual(decoded.eventId, "evt-001") XCTAssertEqual(decoded.materialId, "mat-001") XCTAssertEqual(decoded.eventType, "material_opened") XCTAssertEqual(decoded.readingTargetType, "knowledge_source") XCTAssertEqual(decoded.knowledgeBaseId, "kb-001") XCTAssertEqual(decoded.platform, "ios") XCTAssertEqual(decoded.appVersion, "1.0.0") XCTAssertEqual(decoded.clientTimezoneOffsetMinutes, 480) } func test_codable_roundtrip_nil_knowledge_base_id() throws { let item = ReadingEventUploadItem( eventId: "evt-002", clientSessionId: "sess-002", materialId: "mat-002", eventType: "heartbeat", position: nil, activeSecondsDelta: 30, clientTimestampMs: 1_718_150_500_000, sequence: 2, readingTargetType: "temporary_file", knowledgeBaseId: nil, platform: "ios", appVersion: "1.0.0", clientTimezoneOffsetMinutes: 480 ) let data = try JSONEncoder().encode(item) let decoded = try JSONDecoder().decode(ReadingEventUploadItem.self, from: data) XCTAssertNil(decoded.knowledgeBaseId) } func test_json_uses_camel_case_keys() throws { let item = ReadingEventUploadItem( eventId: "evt-003", clientSessionId: "sess-003", materialId: "mat-003", eventType: "position_changed", position: nil, activeSecondsDelta: 15, clientTimestampMs: 1_718_150_600_000, sequence: 3, readingTargetType: "knowledge_source", knowledgeBaseId: nil, platform: "ios", appVersion: "1.0.0", clientTimezoneOffsetMinutes: 480 ) let data = try JSONEncoder().encode(item) let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] XCTAssertNotNil(json["eventId"]) XCTAssertNotNil(json["clientSessionId"]) XCTAssertNotNil(json["materialId"]) XCTAssertNotNil(json["eventType"]) XCTAssertNotNil(json["clientTimestampMs"]) XCTAssertNotNil(json["readingTargetType"]) XCTAssertNotNil(json["clientTimezoneOffsetMinutes"]) XCTAssertNil(json["event_id"]) // snake_case should not appear } // MARK: - Batch request/response func test_batch_request_encodes_events_array() throws { let events = [ ReadingEventUploadItem( eventId: "evt-001", clientSessionId: "sess-001", materialId: "mat-001", eventType: "material_opened", position: nil, activeSecondsDelta: 0, clientTimestampMs: 1_718_150_400_000, sequence: 1, readingTargetType: "knowledge_source", knowledgeBaseId: nil, platform: "ios", appVersion: "1.0.0", clientTimezoneOffsetMinutes: 480 ) ] let batch = ReadingEventBatchRequest(events: events) let data = try JSONEncoder().encode(batch) let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] let eventsArray = json["events"] as! [[String: Any]] XCTAssertEqual(eventsArray.count, 1) XCTAssertEqual(eventsArray[0]["eventId"] as? String, "evt-001") } func test_batch_response_decodes() throws { let json = """ { "processed": 3, "duplicate": 1, "failed": 0, "warnings": [ {"eventId": "evt-dup", "code": "DUPLICATE_EVENT", "message": "already processed"} ] } """.data(using: .utf8)! let resp = try JSONDecoder().decode(ReadingEventBatchResponse.self, from: json) XCTAssertEqual(resp.processed, 3) XCTAssertEqual(resp.duplicate, 1) XCTAssertEqual(resp.failed, 0) XCTAssertEqual(resp.warnings?.count, 1) XCTAssertEqual(resp.warnings?.first?.code, "DUPLICATE_EVENT") } }