From 7387e40533c5cc77cd67cb2e97b9b05a89d80b64 Mon Sep 17 00:00:00 2001 From: mimeding <264272563+mimeding@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:44:02 -0300 Subject: [PATCH] Add Telegram manifest contract tests --- .../ManifestContractTests.swift | 129 ++++++++++++++++++ .../osaurus_telegram_tests/ModelsTests.swift | 15 +- .../StreamingTests.swift | 5 +- 3 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 Tests/osaurus_telegram_tests/ManifestContractTests.swift diff --git a/Tests/osaurus_telegram_tests/ManifestContractTests.swift b/Tests/osaurus_telegram_tests/ManifestContractTests.swift new file mode 100644 index 0000000..d83bd57 --- /dev/null +++ b/Tests/osaurus_telegram_tests/ManifestContractTests.swift @@ -0,0 +1,129 @@ +import Foundation +import Testing + +@testable import osaurus_telegram + +@Suite("Plugin Manifest Contract") +struct ManifestContractTests { + + private enum ManifestError: Error { + case entryPointFailed + case nilManifest + case invalidJSON + } + + private struct PluginAPI { + let freeString: (@convention(c) (UnsafePointer?) -> Void) + let initContext: (@convention(c) () -> UnsafeMutableRawPointer?) + let destroy: (@convention(c) (UnsafeMutableRawPointer?) -> Void) + let getManifest: (@convention(c) (UnsafeMutableRawPointer?) -> UnsafePointer?) + } + + private func loadAPI() throws -> PluginAPI { + guard let apiPtr = osaurus_plugin_entry() else { + throw ManifestError.entryPointFailed + } + + let fnPtrSize = MemoryLayout.stride + return PluginAPI( + freeString: apiPtr.load( + fromByteOffset: 0, + as: (@convention(c) (UnsafePointer?) -> Void).self), + initContext: apiPtr.load( + fromByteOffset: fnPtrSize, + as: (@convention(c) () -> UnsafeMutableRawPointer?).self), + destroy: apiPtr.load( + fromByteOffset: fnPtrSize * 2, + as: (@convention(c) (UnsafeMutableRawPointer?) -> Void).self), + getManifest: apiPtr.load( + fromByteOffset: fnPtrSize * 3, + as: (@convention(c) (UnsafeMutableRawPointer?) -> UnsafePointer?).self) + ) + } + + private func loadManifest() throws -> [String: Any] { + let api = try loadAPI() + let ctx = api.initContext() + defer { api.destroy(ctx) } + + guard let cStr = api.getManifest(ctx) else { + throw ManifestError.nilManifest + } + let jsonString = String(cString: cStr) + api.freeString(cStr) + + guard let data = jsonString.data(using: .utf8), + let manifest = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw ManifestError.invalidJSON + } + return manifest + } + + private func capabilities(from manifest: [String: Any]) -> [String: Any] { + manifest["capabilities"] as? [String: Any] ?? [:] + } + + private func toolMap(from manifest: [String: Any]) -> [String: [String: Any]] { + let tools = capabilities(from: manifest)["tools"] as? [[String: Any]] ?? [] + return Dictionary( + uniqueKeysWithValues: tools.compactMap { tool -> (String, [String: Any])? in + guard let id = tool["id"] as? String else { return nil } + return (id, tool) + }) + } + + @Test("manifest has correct plugin identity and v2 routes") + func pluginIdentityAndRoutes() throws { + let manifest = try loadManifest() + #expect(manifest["plugin_id"] as? String == "osaurus.telegram") + #expect(manifest["version"] as? String == "0.1.0") + + let routes = capabilities(from: manifest)["routes"] as? [[String: Any]] ?? [] + let routeIDs = Set(routes.compactMap { $0["id"] as? String }) + #expect(routeIDs == ["webhook", "health"]) + + let byID = Dictionary(uniqueKeysWithValues: routes.map { ($0["id"] as! String, $0) }) + #expect(byID["webhook"]?["auth"] as? String == "verify") + #expect(byID["health"]?["auth"] as? String == "owner") + } + + @Test("manifest declares expected Telegram tools") + func toolIDs() throws { + let map = try toolMap(from: loadManifest()) + #expect( + Set(map.keys) == [ + "telegram_list_chats", "telegram_get_chat_history", "telegram_send", + "telegram_send_file", "telegram_set_reaction", + ]) + } + + @Test("message-sending tools declare required parameters") + func requiredParameters() throws { + let map = try toolMap(from: loadManifest()) + + let sendParams = map["telegram_send"]?["parameters"] as? [String: Any] + let sendRequired = Set(sendParams?["required"] as? [String] ?? []) + #expect(sendRequired == ["chat_id", "text"]) + + let fileParams = map["telegram_send_file"]?["parameters"] as? [String: Any] + let fileRequired = Set(fileParams?["required"] as? [String] ?? []) + #expect(fileRequired == ["chat_id", "file_path"]) + + let reactionParams = map["telegram_set_reaction"]?["parameters"] as? [String: Any] + let reactionRequired = Set(reactionParams?["required"] as? [String] ?? []) + #expect(reactionRequired == ["chat_id", "message_id"]) + } + + @Test("configuration exposes auth, agent, upload, behavior, and prompt sections") + func configSections() throws { + let manifest = try loadManifest() + let config = capabilities(from: manifest)["config"] as? [String: Any] + let sections = config?["sections"] as? [[String: Any]] ?? [] + let titles = Set(sections.compactMap { $0["title"] as? String }) + #expect( + titles == [ + "Bot Configuration", "Agent Settings", "File Upload", "Behavior", "Prompt Customization", + ]) + } +} diff --git a/Tests/osaurus_telegram_tests/ModelsTests.swift b/Tests/osaurus_telegram_tests/ModelsTests.swift index 74760fa..2281124 100644 --- a/Tests/osaurus_telegram_tests/ModelsTests.swift +++ b/Tests/osaurus_telegram_tests/ModelsTests.swift @@ -328,28 +328,27 @@ struct TaskEventPayloadTests { @Test("Decodes TaskDraftEvent") func draftEvent() throws { let json = - "{\"text\":\"Here is a draft response...\",\"title\":\"Draft\",\"parse_mode\":\"HTML\"}" + "{\"title\":\"Draft\",\"draft\":{\"text\":\"Here is a draft response...\",\"parse_mode\":\"HTML\"}}" let event = try #require(parseJSON(json, as: TaskDraftEvent.self)) - #expect(event.text == "Here is a draft response...") #expect(event.title == "Draft") - #expect(event.parse_mode == "HTML") + #expect(event.draft?.text == "Here is a draft response...") + #expect(event.draft?.parse_mode == "HTML") } @Test("Decodes TaskDraftEvent with minimal fields") func draftEventMinimal() throws { - let json = "{\"text\":\"Some draft text\"}" + let json = "{\"draft\":{\"text\":\"Some draft text\"}}" let event = try #require(parseJSON(json, as: TaskDraftEvent.self)) - #expect(event.text == "Some draft text") #expect(event.title == nil) - #expect(event.parse_mode == nil) + #expect(event.draft?.text == "Some draft text") + #expect(event.draft?.parse_mode == nil) } @Test("Decodes empty TaskDraftEvent") func draftEventEmpty() throws { let event = try #require(parseJSON("{}", as: TaskDraftEvent.self)) - #expect(event.text == nil) #expect(event.title == nil) - #expect(event.parse_mode == nil) + #expect(event.draft == nil) } @Test("Handles missing optional fields gracefully") diff --git a/Tests/osaurus_telegram_tests/StreamingTests.swift b/Tests/osaurus_telegram_tests/StreamingTests.swift index e9deb46..be6555d 100644 --- a/Tests/osaurus_telegram_tests/StreamingTests.swift +++ b/Tests/osaurus_telegram_tests/StreamingTests.swift @@ -216,15 +216,16 @@ struct ChatStreamStateTests { @Test("Initializes with empty accumulated text and nil tool name") func initialState() { - let state = ChatStreamState(token: "tok", chatId: "123", draftId: 1) + let state = ChatStreamState(token: "tok", chatId: "123", messageId: 10, draftId: 1) #expect(state.accumulated == "") #expect(state.lastFlushLength == 0) #expect(state.currentToolName == nil) + #expect(state.messageId == 10) } @Test("Accumulates text") func accumulates() { - let state = ChatStreamState(token: "tok", chatId: "123", draftId: 1) + let state = ChatStreamState(token: "tok", chatId: "123", messageId: 10, draftId: 1) state.accumulated += "Hello" state.accumulated += " world" #expect(state.accumulated == "Hello world")