Skip to content

Commit b4b01e5

Browse files
committed
Add test infrastructure: mocks, fixtures, HTTPClient + RateLimiter tests
- MockHTTPTransport & MockWebSocketTransport actors for deterministic testing - Expanded TestFixtures: Channel, GuildMember, Embed, Thread, Components, PresenceUpdate - Expanded CacheTests: user/channel/guild/role CRUD, messages, clear, removeUser - New HTTPClientTests: GET, rate limit headers, 429 retry, route key parsing - New RateLimiterTests: 50/s global limit, bucket tracking, reset/clear - HTTPClient.makeRouteKey promoted to internal for test access
1 parent 1f92f1c commit b4b01e5

6 files changed

Lines changed: 518 additions & 1 deletion

File tree

Sources/SwiftDisc/REST/HTTPClient.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ final class HTTPClient: @unchecked Sendable {
494494
return body
495495
}
496496

497-
private func makeRouteKey(method: String, path: String) -> String {
497+
func makeRouteKey(method: String, path: String) -> String {
498498
let components = path.split(separator: "/").map { String($0) }
499499
var majorParam: String?
500500
var majorParamIndex: Int?

Tests/SwiftDiscTests/CacheTests.swift

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,117 @@ final class CacheTests: XCTestCase {
1717
XCTAssertEqual(recent.count, 1)
1818
XCTAssertEqual(recent.first?.id, "m2")
1919
}
20+
21+
func testUpsertAndGetUser() async throws {
22+
let cache = Cache()
23+
let user = try TestFixtures.makeUser(id: "u100", username: "alice")
24+
await cache.upsert(user: user)
25+
let retrieved = await cache.getUser(id: UserID("u100"))
26+
XCTAssertEqual(retrieved?.username, "alice")
27+
XCTAssertEqual(retrieved?.id.rawValue, "u100")
28+
}
29+
30+
func testUpsertAndGetChannel() async throws {
31+
let cache = Cache()
32+
let channel = TestFixtures.makeChannel(id: "ch1", name: "test-channel")
33+
await cache.upsert(channel: channel)
34+
let retrieved = await cache.getChannel(id: ChannelID("ch1"))
35+
XCTAssertEqual(retrieved?.name, "test-channel")
36+
XCTAssertEqual(retrieved?.type, .guildText)
37+
}
38+
39+
func testUpsertAndGetGuild() async throws {
40+
let cache = Cache()
41+
let guild = try TestFixtures.makeGuild(id: "g100", name: "Test Server")
42+
await cache.upsert(guild: guild)
43+
let retrieved = await cache.getGuild(id: GuildID("g100"))
44+
XCTAssertEqual(retrieved?.name, "Test Server")
45+
XCTAssertEqual(retrieved?.id.rawValue, "g100")
46+
}
47+
48+
func testUpsertAndGetRole() async {
49+
let cache = Cache()
50+
let role = TestFixtures.makeRole(id: "r10", name: "Moderator")
51+
await cache.upsert(role: role, guildId: GuildID("g1"))
52+
let retrieved = await cache.getRole(id: RoleID("r10"), guildId: GuildID("g1"))
53+
XCTAssertEqual(retrieved?.name, "Moderator")
54+
}
55+
56+
func testAddAndGetMessages() async throws {
57+
let cache = Cache()
58+
let user = try TestFixtures.makeUser()
59+
let msg1 = try TestFixtures.makeMessage(id: "m1", channelId: "c1", content: "first", author: user)
60+
let msg2 = try TestFixtures.makeMessage(id: "m2", channelId: "c1", content: "second", author: user)
61+
62+
await cache.add(message: msg1)
63+
await cache.add(message: msg2)
64+
65+
let messages = await cache.getMessages(channelId: ChannelID("c1"))
66+
XCTAssertEqual(messages.count, 2)
67+
XCTAssertEqual(messages[0].content, "first")
68+
XCTAssertEqual(messages[1].content, "second")
69+
}
70+
71+
func testRemoveUser() async throws {
72+
let cache = Cache()
73+
let user = try TestFixtures.makeUser(id: "u_remove")
74+
await cache.upsert(user: user)
75+
var retrieved = await cache.getUser(id: UserID("u_remove"))
76+
XCTAssertNotNil(retrieved)
77+
await cache.removeUser(id: UserID("u_remove"))
78+
retrieved = await cache.getUser(id: UserID("u_remove"))
79+
XCTAssertNil(retrieved)
80+
}
81+
82+
func testClearCache() async throws {
83+
let cache = Cache()
84+
let user = try TestFixtures.makeUser(id: "u1")
85+
let channel = TestFixtures.makeChannel(id: "ch1")
86+
let guild = try TestFixtures.makeGuild(id: "g1")
87+
await cache.upsert(user: user)
88+
await cache.upsert(channel: channel)
89+
await cache.upsert(guild: guild)
90+
91+
await cache.clear()
92+
93+
XCTAssertNil(await cache.getUser(id: UserID("u1")))
94+
XCTAssertNil(await cache.getChannel(id: ChannelID("ch1")))
95+
XCTAssertNil(await cache.getGuild(id: GuildID("g1")))
96+
}
97+
98+
func testRemoveMessagesForChannel() async throws {
99+
let cache = Cache()
100+
let user = try TestFixtures.makeUser()
101+
let msg1 = try TestFixtures.makeMessage(id: "m1", channelId: "c_rm", content: "a", author: user)
102+
let msg2 = try TestFixtures.makeMessage(id: "m2", channelId: "c_rm", content: "b", author: user)
103+
let msg3 = try TestFixtures.makeMessage(id: "m3", channelId: "c_other", content: "c", author: user)
104+
105+
await cache.add(message: msg1)
106+
await cache.add(message: msg2)
107+
await cache.add(message: msg3)
108+
109+
await cache.removeMessagesForChannel(channelId: ChannelID("c_rm"))
110+
111+
let remaining = await cache.getMessages(channelId: ChannelID("c_rm"))
112+
XCTAssertTrue(remaining.isEmpty)
113+
let otherMessages = await cache.getMessages(channelId: ChannelID("c_other"))
114+
XCTAssertEqual(otherMessages.count, 1)
115+
}
116+
117+
func testEnsureChannelStub() async throws {
118+
let cache = Cache()
119+
await cache.ensureChannelStub(id: ChannelID("stub1"), type: .guildText)
120+
let channel = await cache.getChannel(id: ChannelID("stub1"))
121+
XCTAssertNotNil(channel)
122+
XCTAssertEqual(channel?.id.rawValue, "stub1")
123+
XCTAssertEqual(channel?.type, .guildText)
124+
125+
// Ensure it does not overwrite existing
126+
let existing = TestFixtures.makeChannel(id: "stub1", name: "real-name")
127+
await cache.upsert(channel: existing)
128+
await cache.ensureChannelStub(id: ChannelID("stub1"), type: .dm)
129+
let after = await cache.getChannel(id: ChannelID("stub1"))
130+
XCTAssertEqual(after?.name, "real-name")
131+
XCTAssertEqual(after?.type, .guildText)
132+
}
20133
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import XCTest
2+
@testable import SwiftDisc
3+
4+
final class HTTPClientTests: XCTestCase {
5+
private struct TestResponse: Codable, Sendable {
6+
let id: String
7+
let value: String
8+
}
9+
10+
func testBasicGETRequest() async throws {
11+
let transport = MockHTTPTransport()
12+
let config = DiscordConfiguration()
13+
let client = HTTPClient(token: "test_token", configuration: config, transport: transport)
14+
15+
let responseData = try JSONEncoder().encode(TestResponse(id: "42", value: "hello"))
16+
let path = "/v10/test/endpoint"
17+
await transport.addResponse(for: path, data: responseData)
18+
19+
let result: TestResponse = try await client.get(path: "test/endpoint")
20+
XCTAssertEqual(result.id, "42")
21+
XCTAssertEqual(result.value, "hello")
22+
}
23+
24+
func testRateLimitHeaderParsing() async throws {
25+
var capturedEvent: RateLimitEvent?
26+
let config = DiscordConfiguration(onRateLimit: { event in
27+
capturedEvent = event
28+
})
29+
let transport = MockHTTPTransport()
30+
let client = HTTPClient(token: "test_token", configuration: config, transport: transport)
31+
32+
let responseData = try JSONEncoder().encode(["ok": true])
33+
let path = "/v10/channels/123/messages"
34+
await transport.addResponse(
35+
for: path,
36+
data: responseData,
37+
headers: [
38+
"X-RateLimit-Remaining": "4",
39+
"X-RateLimit-Reset-After": "0.5",
40+
"X-RateLimit-Limit": "5"
41+
]
42+
)
43+
44+
let result: [String: Bool] = try await client.get(path: "channels/123/messages")
45+
XCTAssertEqual(result["ok"], true)
46+
47+
let event = try XCTUnwrap(capturedEvent)
48+
XCTAssertEqual(event.remaining, 4)
49+
XCTAssertEqual(event.limit, 5)
50+
XCTAssertFalse(event.isGlobal)
51+
}
52+
53+
func test429RetryWithBackoff() async throws {
54+
let transport = MockHTTPTransport()
55+
let config = DiscordConfiguration()
56+
let client = HTTPClient(token: "test_token", configuration: config, transport: transport)
57+
58+
let successData = try JSONEncoder().encode(TestResponse(id: "1", value: "ok"))
59+
let path = "/v10/guilds/111/channels"
60+
61+
// First call returns 429 with minimal retry-after
62+
await transport.addResponse(
63+
for: path,
64+
data: Data("\"rate limited\"".utf8),
65+
statusCode: 429,
66+
headers: ["Retry-After": "0.001"]
67+
)
68+
// Second call (retry) returns success
69+
await transport.addResponse(
70+
for: path,
71+
data: successData,
72+
statusCode: 200
73+
)
74+
75+
let result: TestResponse = try await client.get(path: "guilds/111/channels")
76+
XCTAssertEqual(result.id, "1")
77+
XCTAssertEqual(result.value, "ok")
78+
}
79+
80+
func testMakeRouteKeySimplePath() throws {
81+
let client = HTTPClient(token: "t", configuration: DiscordConfiguration())
82+
83+
let key = client.makeRouteKey(method: "GET", path: "channels/123/messages")
84+
XCTAssertEqual(key, "GET:channels/123/messages|major=123")
85+
}
86+
87+
func testMakeRouteKeyWithSnowflakes() throws {
88+
let client = HTTPClient(token: "t", configuration: DiscordConfiguration())
89+
90+
let noMajor = client.makeRouteKey(method: "POST", path: "some/endpoint")
91+
XCTAssertEqual(noMajor, "POST:some/endpoint|major=global")
92+
93+
let channelId = client.makeRouteKey(method: "GET", path: "channels/987654321098765432/messages/12345")
94+
XCTAssertEqual(channelId, "GET:channels/987654321098765432/messages/:id|major=987654321098765432")
95+
96+
let guildId = client.makeRouteKey(method: "GET", path: "guilds/111111111111111111/channels")
97+
XCTAssertEqual(guildId, "GET:guilds/111111111111111111/channels|major=111111111111111111")
98+
99+
let webhook = client.makeRouteKey(method: "POST", path: "webhooks/555555555555555555/abcdef123")
100+
XCTAssertEqual(webhook, "POST:webhooks/555555555555555555/abcdef123|major=555555555555555555")
101+
}
102+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import Foundation
2+
@testable import SwiftDisc
3+
4+
/// A mock HTTP transport that returns pre-configured responses.
5+
actor MockHTTPTransport: HTTPTransport {
6+
private var responseQueues: [String: [(data: Data, statusCode: Int, headers: [String: String])]] = [:]
7+
private var requestedPaths: [String] = []
8+
9+
func addResponse(for path: String, data: Data, statusCode: Int = 200, headers: [String: String] = [:]) {
10+
var queue = responseQueues[path] ?? []
11+
queue.append((data, statusCode, headers))
12+
responseQueues[path] = queue
13+
}
14+
15+
func request(method: String, url: URL, body: Data?, headers: [String: String]?) async throws -> HTTPResponse {
16+
let path = url.path
17+
requestedPaths.append("\(method):\(path)")
18+
guard var queue = responseQueues[path], !queue.isEmpty else {
19+
throw DiscordError.http(statusCode: 404, message: "No mock response for \(path)")
20+
}
21+
let response = queue.removeFirst()
22+
if queue.isEmpty {
23+
responseQueues.removeValue(forKey: path)
24+
} else {
25+
responseQueues[path] = queue
26+
}
27+
return HTTPResponse(data: response.data, statusCode: response.statusCode, headers: response.headers)
28+
}
29+
30+
func getRequestedPaths() -> [String] { requestedPaths }
31+
32+
func reset() {
33+
responseQueues.removeAll()
34+
requestedPaths.removeAll()
35+
}
36+
}
37+
38+
/// A mock WebSocket transport that simulates gateway events.
39+
actor MockWebSocketTransport: WebSocketTransport {
40+
private var messages: [WebSocketMessage] = []
41+
private var sentMessages: [String] = []
42+
private var _closeCode: Int? = nil
43+
private var _shouldThrowOnReceive = false
44+
45+
var closeCode: Int? { _closeCode }
46+
47+
func addMessage(_ message: WebSocketMessage) { messages.append(message) }
48+
func addString(_ text: String) { messages.append(.string(text)) }
49+
50+
func receive() async throws -> WebSocketMessage {
51+
if _shouldThrowOnReceive { throw DiscordError.gateway("Simulated receive error") }
52+
guard !messages.isEmpty else { throw DiscordError.gateway("No mock messages") }
53+
return messages.removeFirst()
54+
}
55+
56+
func send(_ message: WebSocketMessage) async throws {
57+
if case .string(let text) = message { sentMessages.append(text) }
58+
}
59+
60+
func sendPing() async throws {}
61+
func close() async { _closeCode = 1000 }
62+
func forceClose() async { _closeCode = 1006 }
63+
64+
func getSentMessages() -> [String] { sentMessages }
65+
func setThrowOnReceive(_ shouldThrow: Bool) { _shouldThrowOnReceive = shouldThrow }
66+
}
67+
68+
/// Minimal VoiceState struct for API compliance (voice not yet planned).
69+
public struct VoiceState: Codable, Sendable, Hashable {
70+
public let guild_id: GuildID?
71+
public let channel_id: ChannelID?
72+
public let user_id: UserID
73+
public let member: GuildMember?
74+
public let session_id: String
75+
public let deaf: Bool
76+
public let mute: Bool
77+
public let self_deaf: Bool
78+
public let self_mute: Bool
79+
public let self_stream: Bool?
80+
public let self_video: Bool?
81+
public let suppress: Bool
82+
public let request_to_speak_timestamp: String?
83+
84+
public init(
85+
guild_id: GuildID? = nil,
86+
channel_id: ChannelID? = nil,
87+
user_id: UserID,
88+
member: GuildMember? = nil,
89+
session_id: String,
90+
deaf: Bool = false,
91+
mute: Bool = false,
92+
self_deaf: Bool = false,
93+
self_mute: Bool = false,
94+
self_stream: Bool? = nil,
95+
self_video: Bool? = nil,
96+
suppress: Bool = false,
97+
request_to_speak_timestamp: String? = nil
98+
) {
99+
self.guild_id = guild_id
100+
self.channel_id = channel_id
101+
self.user_id = user_id
102+
self.member = member
103+
self.session_id = session_id
104+
self.deaf = deaf
105+
self.mute = mute
106+
self.self_deaf = self_deaf
107+
self.self_mute = self_mute
108+
self.self_stream = self_stream
109+
self.self_video = self_video
110+
self.suppress = suppress
111+
self.request_to_speak_timestamp = request_to_speak_timestamp
112+
}
113+
}

0 commit comments

Comments
 (0)