// // TravelAlbumAPITests.swift // suixinkanTests // import XCTest @testable import suixinkan @MainActor /// 旅拍相册 API 测试。 final class TravelAlbumAPITests: XCTestCase { func testListBuildsPathAndQuery() async throws { let data = envelopeJSON(#"{"total":1,"list":[]}"#) let session = MockURLSession(responses: [data]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) _ = try await api.list(page: 2, pageSize: 30) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/list") let query = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems) XCTAssertEqual(query.first { $0.name == "page" }?.value, "2") XCTAssertEqual(query.first { $0.name == "page_size" }?.value, "30") } func testCreateEncodesBody() async throws { let data = envelopeJSON(#"{"id":8}"#) let session = MockURLSession(responses: [data]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) _ = try await api.create( TravelAlbumCreateRequest( name: "2026-07-07-001", type: 1, orderNumber: nil, materialNum: 2, materialPrice: 10.5, materialPackagePrice: 88, photoPrice: 0 ) ) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/create") let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any] XCTAssertEqual(body?["name"] as? String, "2026-07-07-001") XCTAssertEqual(body?["type"] as? Int, 1) XCTAssertEqual(body?["material_num"] as? Int, 2) XCTAssertEqual(body?["material_price"] as? Double, 10.5) } func testMaterialListAndDeleteAndMpCode() async throws { let materialList = envelopeJSON( #"{"total":1,"list":[{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":2,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"ai_retouch_status":3,"ai_retouch_status_name":"AI已修","ai_refined_url":"https://cdn/refined.jpg","ai_atmosphere_url":"https://cdn/atmosphere.jpg","created_at":"","updated_at":""}]}"# ) let empty = envelopeJSON("{}") let code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#) let session = MockURLSession(responses: [materialList, empty, code]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) let materials = try await api.materialList( userEquityTravelId: 3, page: 1, pageSize: 30, orderBy: 4, isPurchased: 1 ) try await api.deleteAlbum(id: 3) let response = try await api.mpCode(id: 3) XCTAssertEqual(materials.list.first?.aiRetouchStatus, 3) XCTAssertEqual(materials.list.first?.aiRefinedURL, "https://cdn/refined.jpg") XCTAssertEqual(materials.list.first?.aiAtmosphereURL, "https://cdn/atmosphere.jpg") XCTAssertEqual(response.mpCodeOssUrl, "https://cdn/qr.png") XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/travel-album/material-list") let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3") XCTAssertEqual(query?.first { $0.name == "order_by" }?.value, "4") XCTAssertEqual(query?.first { $0.name == "is_purchased" }?.value, "1") XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/travel-album/delete") let body = try JSONSerialization.jsonObject(with: session.requests[1].httpBody!) as? [String: Any] XCTAssertEqual(body?["id"] as? Int, 3) XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code") } func testMaterialInfoBuildsQueryAndDecodesMaterial() async throws { let data = envelopeJSON( #"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":2,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"ai_retouch_status":3,"ai_retouch_status_name":"AI已修","ai_retouch_batch_id":60,"ai_refined_url":"https://cdn/refined.jpg","ai_atmosphere_url":"https://cdn/atmosphere.jpg","created_at":"","updated_at":""}"# ) let session = MockURLSession(responses: [data]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) let material = try await api.materialInfo(userEquityTravelId: 3, materialId: 6) XCTAssertEqual(material.id, 6) XCTAssertEqual(material.aiRetouchBatchId, 60) XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg") let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.httpMethod, "GET") XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/material-info") let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3") XCTAssertEqual(query?.first { $0.name == "material_id" }?.value, "6") } func testBatchDeleteMaterialsBuildsPathAndBody() async throws { let session = MockURLSession(responses: [envelopeJSON("{}")]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) try await api.batchDeleteMaterials(ids: [2031, 2032, 2033]) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual( request.url?.path, "/api/yf-handset-app/photog/travel-album/batch-delete-material" ) let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any] XCTAssertEqual(body?["ids"] as? [Int], [2031, 2032, 2033]) } func testUploadMaterialBuildsPathAndBody() async throws { let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":1,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"created_at":"2026-07-08 10:00:00","updated_at":"2026-07-08 10:00:00"}"#) let session = MockURLSession(responses: [material]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) let response = try await api.uploadMaterial( TravelAlbumUploadMaterialRequest( userEquityTravelId: 3, fileName: "A.JPG", fileUrl: "https://cdn/a.jpg", clientPhotoId: "2a1d96b1-c0cc-489f-9f42-419ff1439a62" ) ) XCTAssertEqual(response.id, 6) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/upload-material") let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any] XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 3) XCTAssertEqual(body?["file_name"] as? String, "A.JPG") XCTAssertEqual(body?["file_url"] as? String, "https://cdn/a.jpg") XCTAssertEqual(body?["client_photo_id"] as? String, "2a1d96b1-c0cc-489f-9f42-419ff1439a62") } func testMaterialClientPhotoIdsBuildsPathQueryAndDecodesResponse() async throws { let data = envelopeJSON(#"{"client_photo_ids":["one","two"]}"#) let session = MockURLSession(responses: [data]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) let response = try await api.materialClientPhotoIds(userEquityTravelId: 3) XCTAssertEqual(response.clientPhotoIds, ["one", "two"]) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.httpMethod, "GET") XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/material-client-photo-ids") let query = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3") } func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws { let data = envelopeJSON( #"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg","before_url":"https://cdn/refined-before.jpg","after_url":"https://cdn/refined-after.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg","before_url":"https://cdn/atmosphere-before.jpg","after_url":"https://cdn/atmosphere-after.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg","before_url":"https://cdn/cover-before.jpg","after_url":"https://cdn/cover-after.jpg"}],"remaining_quota":12}"# ) let session = MockURLSession(responses: [data]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) let response = try await api.aiRetouchTemplates(scenicId: 18) XCTAssertEqual(response.refinedTemplates.first?.name, "清透") XCTAssertEqual(response.refinedTemplates.first?.beforeURL, "https://cdn/refined-before.jpg") XCTAssertEqual(response.refinedTemplates.first?.afterURL, "https://cdn/refined-after.jpg") XCTAssertEqual(response.atmosphereTemplates.first?.id, 2) XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg") XCTAssertEqual(response.remainingQuota, 12) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.httpMethod, "GET") XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-templates") let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems XCTAssertEqual(query?.first { $0.name == "scenic_id" }?.value, "18") } func testSubmitAIRetouchEncodesRequiredAndSelectedOptionalTemplates() async throws { let session = MockURLSession(responses: [jobSubmissionJSON(batchId: 9, albumId: 6)]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) try await api.submitAIRetouch( TravelAlbumAIRetouchRequest( userEquityTravelId: 6, materialIds: [11, 12, 13, 14], refinedTemplateId: 21, atmosphereTemplateId: 22, coverTemplateId: 31 ) ) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch") let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any] XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 6) XCTAssertEqual(body?["material_ids"] as? [Int], [11, 12, 13, 14]) XCTAssertEqual(body?["refined_template_id"] as? Int, 21) XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22) XCTAssertEqual(body?["cover_template_id"] as? Int, 31) XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [ "user_equity_travel_id", "material_ids", "refined_template_id", "atmosphere_template_id", "cover_template_id", ]) } func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws { let session = MockURLSession(responses: [jobSubmissionJSON(batchId: 10, albumId: 6)]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) try await api.submitAIRetouch( TravelAlbumAIRetouchRequest( userEquityTravelId: 6, materialIds: [11], refinedTemplateId: 21, atmosphereTemplateId: nil, coverTemplateId: nil ) ) let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any] XCTAssertNil(body?["atmosphere_template_id"]) XCTAssertNil(body?["cover_template_id"]) XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [ "user_equity_travel_id", "material_ids", "refined_template_id", ]) } func testSubmitAIReretouchEncodesOnlyFieldsRequiredByEachType() async throws { let session = MockURLSession(responses: [ jobSubmissionJSON(batchId: 51, albumId: 6), jobSubmissionJSON(batchId: 52, albumId: 6), jobSubmissionJSON(batchId: 53, albumId: 6), jobSubmissionJSON(batchId: 54, albumId: 6), ]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) try await api.submitAIReretouch( TravelAlbumAIReretouchRequest( id: 11, aiRetouchBatchId: 51, type: .refined, refinedTemplateId: 21, atmosphereTemplateId: nil ) ) try await api.submitAIReretouch( TravelAlbumAIReretouchRequest( id: 12, aiRetouchBatchId: 52, type: .atmosphere, refinedTemplateId: nil, atmosphereTemplateId: 22 ) ) try await api.submitAIReretouch( TravelAlbumAIReretouchRequest( id: 13, aiRetouchBatchId: 53, type: .all, refinedTemplateId: 21, atmosphereTemplateId: 22 ) ) try await api.submitAIReretouch( TravelAlbumAIReretouchRequest( id: 14, aiRetouchBatchId: 54, type: .all, refinedTemplateId: 21, atmosphereTemplateId: nil ) ) let bodies = try session.requests.map { request in try JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any] } XCTAssertEqual(session.requests.map { $0.url?.path }, Array( repeating: "/api/yf-handset-app/photog/travel-album/ai-reretouch", count: 4 )) XCTAssertEqual(Set(bodies[0]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "refined_template_id"]) XCTAssertEqual(Set(bodies[1]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "atmosphere_template_id"]) XCTAssertEqual(Set(bodies[2]?.keys.map { $0 } ?? []), [ "id", "ai_retouch_batch_id", "type", "refined_template_id", "atmosphere_template_id", ]) XCTAssertEqual(Set(bodies[3]?.keys.map { $0 } ?? []), [ "id", "ai_retouch_batch_id", "type", "refined_template_id", ]) XCTAssertEqual(bodies[0]?["type"] as? Int, 1) XCTAssertEqual(bodies[1]?["type"] as? Int, 2) XCTAssertEqual(bodies[2]?["type"] as? Int, 3) XCTAssertEqual(bodies[3]?["type"] as? Int, 3) } func testAIJobListBuildsCursorQueryAndDecodesUnknownStatusSafely() async throws { let data = envelopeJSON( #"{"items":[{"ai_retouch_batch_id":91,"user_equity_travel_id":6,"scope":"album","status":"future_status","album":{"id":6,"name":"九寨沟旅拍","user_phone":"138****0000","cover_url":""},"source_count":1,"outputs":[{"type":"refined","count":1}],"preview_images":[],"progress":{"total":1,"queued":1,"processing":0,"succeeded":0,"failed":0,"canceled":0},"estimated_finish_at":null,"failure_summary":null,"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":null}],"next_cursor":"cursor-2","has_more":true}"# ) let session = MockURLSession(responses: [data]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) let response = try await api.aiRetouchJobList( statusGroup: .inProgress, limit: 20, cursor: "cursor-1" ) XCTAssertEqual(response.items.first?.status, .unknown("future_status")) XCTAssertEqual(response.nextCursor, "cursor-2") let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-job-list") let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems XCTAssertEqual(query?.first { $0.name == "status_group" }?.value, "in_progress") XCTAssertEqual(query?.first { $0.name == "limit" }?.value, "20") XCTAssertEqual(query?.first { $0.name == "cursor" }?.value, "cursor-1") } func testAIJobInfoDecodesPerOutputFailureReason() async throws { let data = envelopeJSON( #"{"ai_retouch_batch_id":91,"user_equity_travel_id":6,"scope":"album","status":"partially_succeeded","album":{"id":6,"name":"九寨沟旅拍","user_phone":"138****0000","cover_url":""},"source_count":1,"outputs":[{"type":"refined","count":1}],"progress":{"total":1,"queued":0,"processing":0,"succeeded":0,"failed":1,"canceled":0},"quota_settlement":{"status":"settled","reserved_units":1,"consumed_units":0,"released_units":1,"cover_units":0},"targets":[{"target_id":1,"source_material":{"id":11,"file_name":"A.JPG","thumbnail_url":""},"input_material_ids":[11],"output_type":"refined","template":{"id":21,"name":"清透"},"status":"failed","result_asset":null,"error":{"code":"FACE_NOT_FOUND","message":"未识别到清晰人脸,请更换照片","retryable":true},"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":"2026-08-14T06:01:00Z"}],"estimated_finish_at":null,"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":"2026-08-14T06:01:00Z","duration_seconds":60}"# ) let session = MockURLSession(responses: [data]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) let detail = try await api.aiRetouchJobInfo(batchId: 91) XCTAssertEqual(detail.status, .partiallySucceeded) XCTAssertEqual(detail.targets.first?.displayFailureMessage, "未识别到清晰人脸,请更换照片") XCTAssertEqual(detail.targets.first?.error?.retryable, true) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-job-info") let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems XCTAssertEqual(query?.first { $0.name == "ai_retouch_batch_id" }?.value, "91") } private func envelopeJSON(_ dataJSON: String) -> Data { """ {"code":100000,"msg":"success","data":\(dataJSON)} """.data(using: .utf8)! } private func jobSubmissionJSON(batchId: Int, albumId: Int) -> Data { envelopeJSON( #"{"ai_retouch_batch_id":\#(batchId),"user_equity_travel_id":\#(albumId),"status":"queued","progress":{"total":3,"queued":3,"processing":0,"succeeded":0,"failed":0,"canceled":0},"created_at":"2026-08-14T06:00:00Z"}"# ) } }