Initial commit
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import Foundation
|
||||
import MusicAssistantCore
|
||||
import XCTest
|
||||
|
||||
final class OpenAIClientTests: XCTestCase {
|
||||
func testGenerateSongProjectUsesConfiguredEndpointAndHeaders() async throws {
|
||||
let transport = RecordingOpenAITransport(
|
||||
statusCode: 200,
|
||||
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Generated"))
|
||||
)
|
||||
let configuration = OpenAIClientConfiguration(
|
||||
apiKey: "test-api-key",
|
||||
endpointURL: URL(string: "https://api.example.test/v1/configured")!,
|
||||
model: "configured-model",
|
||||
organizationID: "org-test",
|
||||
projectID: "project-test"
|
||||
)
|
||||
let client = OpenAIClient(
|
||||
configuration: configuration,
|
||||
adapter: MockOpenAIClientAdapter(),
|
||||
transport: transport
|
||||
)
|
||||
|
||||
let result = try await client.generateSongProject(
|
||||
from: SongProjectGenerationRequest(
|
||||
context: AIRequestContext(userInstruction: "Generate this."),
|
||||
discussionMode: .auto
|
||||
)
|
||||
)
|
||||
let request = await transport.recordedRequests.first
|
||||
|
||||
XCTAssertEqual(result.project.title, "Generated")
|
||||
XCTAssertEqual(request?.url, configuration.endpointURL)
|
||||
XCTAssertEqual(request?.httpMethod, "POST")
|
||||
XCTAssertEqual(request?.value(forHTTPHeaderField: "Authorization"), "Bearer test-api-key")
|
||||
XCTAssertEqual(request?.value(forHTTPHeaderField: "OpenAI-Organization"), "org-test")
|
||||
XCTAssertEqual(request?.value(forHTTPHeaderField: "OpenAI-Project"), "project-test")
|
||||
XCTAssertEqual(request?.value(forHTTPHeaderField: "X-Adapter"), "mock")
|
||||
XCTAssertEqual(request?.value(forHTTPHeaderField: "Content-Type"), "application/json")
|
||||
let requestBodyString = await transport.requestBodyString
|
||||
XCTAssertEqual(requestBodyString, "generate|configured-model|Generate this.")
|
||||
}
|
||||
|
||||
func testClientUsesAdapterURLOverrideWhenProvided() async throws {
|
||||
let overrideURL = URL(string: "https://api.example.test/v1/override")!
|
||||
let transport = RecordingOpenAITransport(
|
||||
statusCode: 200,
|
||||
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Updated"))
|
||||
)
|
||||
let client = OpenAIClient(
|
||||
configuration: OpenAIClientConfiguration(
|
||||
apiKey: "test-api-key",
|
||||
endpointURL: URL(string: "https://api.example.test/v1/configured")!
|
||||
),
|
||||
adapter: MockOpenAIClientAdapter(overrideURL: overrideURL),
|
||||
transport: transport
|
||||
)
|
||||
|
||||
_ = try await client.proposeProjectUpdate(
|
||||
from: SongProjectUpdateRequest(
|
||||
context: AIRequestContext(userInstruction: "Update it."),
|
||||
project: SongProject(title: "Original", idea: "Original idea"),
|
||||
allowedScopes: [.lyrics]
|
||||
)
|
||||
)
|
||||
|
||||
let recordedURL = await transport.recordedRequests.first?.url
|
||||
XCTAssertEqual(recordedURL, overrideURL)
|
||||
}
|
||||
|
||||
func testClientRejectsMissingAPIKeyBeforeSendingRequest() async throws {
|
||||
let transport = RecordingOpenAITransport(
|
||||
statusCode: 200,
|
||||
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Never sent"))
|
||||
)
|
||||
let client = OpenAIClient(
|
||||
configuration: OpenAIClientConfiguration(
|
||||
apiKey: " ",
|
||||
endpointURL: URL(string: "https://api.example.test/v1/configured")!
|
||||
),
|
||||
adapter: MockOpenAIClientAdapter(),
|
||||
transport: transport
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await client.reviseLyrics(
|
||||
from: LyricsRevisionRequest(
|
||||
context: AIRequestContext(userInstruction: "Improve."),
|
||||
project: SongProject(title: "Song", idea: "Idea"),
|
||||
sourceLyrics: Lyrics(text: "Draft"),
|
||||
mode: .improve
|
||||
)
|
||||
)
|
||||
XCTFail("Expected missing API key to throw.")
|
||||
} catch let error as OpenAIClientError {
|
||||
XCTAssertEqual(error, .missingAPIKey)
|
||||
}
|
||||
|
||||
let recordedRequestCount = await transport.recordedRequests.count
|
||||
XCTAssertEqual(recordedRequestCount, 0)
|
||||
}
|
||||
|
||||
func testClientReportsUnacceptableStatusWithoutLeakingResponseBody() async throws {
|
||||
let transport = RecordingOpenAITransport(
|
||||
statusCode: 429,
|
||||
responseData: Data("secret server detail".utf8)
|
||||
)
|
||||
let client = OpenAIClient(
|
||||
configuration: OpenAIClientConfiguration(
|
||||
apiKey: "test-api-key",
|
||||
endpointURL: URL(string: "https://api.example.test/v1/configured")!
|
||||
),
|
||||
adapter: MockOpenAIClientAdapter(),
|
||||
transport: transport
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await client.generateSongProject(
|
||||
from: SongProjectGenerationRequest(
|
||||
context: AIRequestContext(userInstruction: "Generate.")
|
||||
)
|
||||
)
|
||||
XCTFail("Expected status error to throw.")
|
||||
} catch let error as OpenAIClientError {
|
||||
XCTAssertEqual(error, .unacceptableStatusCode(429))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor RecordingOpenAITransport: OpenAIHTTPTransport {
|
||||
private(set) var recordedRequests: [URLRequest] = []
|
||||
private let statusCode: Int
|
||||
private let responseData: Data
|
||||
|
||||
init(statusCode: Int, responseData: Data) {
|
||||
self.statusCode = statusCode
|
||||
self.responseData = responseData
|
||||
}
|
||||
|
||||
var requestBodyString: String? {
|
||||
guard let body = recordedRequests.first?.httpBody else { return nil }
|
||||
return String(data: body, encoding: .utf8)
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
recordedRequests.append(request)
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: statusCode,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: nil
|
||||
)!
|
||||
return (responseData, response)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MockOpenAIClientAdapter: OpenAIClientAdapter {
|
||||
var overrideURL: URL?
|
||||
|
||||
func makeProjectGenerationRequest(
|
||||
_ request: SongProjectGenerationRequest,
|
||||
configuration: OpenAIClientConfiguration
|
||||
) throws -> OpenAIClientRequest {
|
||||
OpenAIClientRequest(
|
||||
url: overrideURL,
|
||||
body: Data("generate|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8),
|
||||
additionalHeaders: ["X-Adapter": "mock"]
|
||||
)
|
||||
}
|
||||
|
||||
func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult {
|
||||
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
|
||||
return SongProjectGenerationResult(
|
||||
project: SongProject(title: response.text, idea: response.text)
|
||||
)
|
||||
}
|
||||
|
||||
func makeLyricsRevisionRequest(
|
||||
_ request: LyricsRevisionRequest,
|
||||
configuration: OpenAIClientConfiguration
|
||||
) throws -> OpenAIClientRequest {
|
||||
OpenAIClientRequest(
|
||||
url: overrideURL,
|
||||
body: Data("lyrics|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8)
|
||||
)
|
||||
}
|
||||
|
||||
func decodeLyricsRevisionResult(from data: Data) throws -> LyricsRevisionResult {
|
||||
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
|
||||
return LyricsRevisionResult(lyrics: Lyrics(text: response.text))
|
||||
}
|
||||
|
||||
func makeProjectUpdateRequest(
|
||||
_ request: SongProjectUpdateRequest,
|
||||
configuration: OpenAIClientConfiguration
|
||||
) throws -> OpenAIClientRequest {
|
||||
OpenAIClientRequest(
|
||||
url: overrideURL,
|
||||
body: Data("update|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8)
|
||||
)
|
||||
}
|
||||
|
||||
func decodeProjectUpdateResult(from data: Data) throws -> SongProjectUpdateResult {
|
||||
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
|
||||
return SongProjectUpdateResult(
|
||||
project: SongProject(title: response.text, idea: response.text)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MockOpenAIResponse: Codable {
|
||||
var text: String
|
||||
}
|
||||
Reference in New Issue
Block a user