diff --git a/AGENTS.md b/AGENTS.md index 1b5f05a..1c691cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,10 @@ DatabaseHandler (SQLite polling) | `Jared/InboundFilterPolicy.swift` | Inbound gate: self-message, body-type, global-disabled + `/enable` bypass; reads `RuntimeFlags` | | `Jared/RuntimeFlags.swift` | Port abstracting Jared's disabled state; `UserDefaultsRuntimeFlags` is the production read | | `Jared/PluginManager.swift` | Manages the list of built-in `RoutingModule`s | -| `Jared/CoreModule.swift` | Built-in commands: `/ping`, `/send`, `/schedule`, `/name`, `/whoami`, `/barf`, etc. | +| `Jared/CoreModule.swift` | Built-in commands: `/ping`, `/send`, `/name`, `/whoami`, `/barf`, etc. Side effects injected via ports (`ContactNameService`, `Clock`, `SendRateLimiter`) so commands are testable | +| `Jared/ContactNameService.swift` | Port hiding `CNContactStore` for `/name`; `CNContactNameService` adapter (no `try!`), fakeable in tests | +| `Jared/Clock.swift` | Async sleep port (`RealClock`) so `/send` delays run instantly under test | +| `Jared/SendRateLimiter.swift` | Per-handle concurrency limiter for `/send`; deterministic, no async timing | | `Jared/InternalModule.swift` | `/help`, `/reload`, `/enable`, `/disable` commands | | `Jared/WebHookManager.swift` | Implements both `MessageDelegate` (notify on all messages) and `RoutingModule` (webhook-defined routes) | | `Jared/JaredWebServer.swift` | REST API web server | diff --git a/Jared.xcodeproj/project.pbxproj b/Jared.xcodeproj/project.pbxproj index 47611ec..62fe67e 100644 --- a/Jared.xcodeproj/project.pbxproj +++ b/Jared.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ B319235824DD319D001904BA /* InternalModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C3244EBF4900CC44C5 /* InternalModule.swift */; }; B319236224DE76A2001904BA /* JaredUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B319236124DE76A2001904BA /* JaredUITests.swift */; }; B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32DE51424D51F9C00AB3A71 /* RouterTests.swift */; }; + EC71CM00000000000000A002 /* CoreModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CM00000000000000A001 /* CoreModuleTests.swift */; }; B32DE51624D522DA00AB3A71 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C1244EBE9A00CC44C5 /* Router.swift */; }; EC70RF00000000000000A004 /* RuntimeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70RF00000000000000A001 /* RuntimeFlags.swift */; }; EC70FP00000000000000A004 /* InboundFilterPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70FP00000000000000A001 /* InboundFilterPolicy.swift */; }; @@ -109,6 +110,13 @@ TM0000010000000000000004 /* TunnelManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TM0000010000000000000005 /* TunnelManagerTests.swift */; }; B3B844D224DA6BBB00DC162A /* JaredWebServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C59283224DD17900116ECB /* JaredWebServer.swift */; }; B3BBA7D11CB3284A00E1749C /* CoreModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39EC17D1CB01FEF002C3161 /* CoreModule.swift */; }; + EC71CS00000000000000A002 /* ContactNameService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CS00000000000000A001 /* ContactNameService.swift */; }; + EC71CS00000000000000A004 /* ContactNameService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CS00000000000000A001 /* ContactNameService.swift */; }; + EC71CK00000000000000A002 /* Clock.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CK00000000000000A001 /* Clock.swift */; }; + EC71CK00000000000000A004 /* Clock.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71CK00000000000000A001 /* Clock.swift */; }; + EC71RL00000000000000A002 /* SendRateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71RL00000000000000A001 /* SendRateLimiter.swift */; }; + EC71RL00000000000000A004 /* SendRateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC71RL00000000000000A001 /* SendRateLimiter.swift */; }; + EC71C000000000000000A010 /* CoreModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39EC17D1CB01FEF002C3161 /* CoreModule.swift */; }; B3BF17C2244EBE9A00CC44C5 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BF17C1244EBE9A00CC44C5 /* Router.swift */; }; EC70RF00000000000000A002 /* RuntimeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70RF00000000000000A001 /* RuntimeFlags.swift */; }; EC70FP00000000000000A002 /* InboundFilterPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70FP00000000000000A001 /* InboundFilterPolicy.swift */; }; @@ -195,6 +203,7 @@ B319236124DE76A2001904BA /* JaredUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JaredUITests.swift; sourceTree = ""; }; B319236324DE76A2001904BA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B32DE51424D51F9C00AB3A71 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; + EC71CM00000000000000A001 /* CoreModuleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreModuleTests.swift; sourceTree = ""; }; B32DE51924D5524300AB3A71 /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; B32DE51B24D552D300AB3A71 /* MockPluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPluginManager.swift; sourceTree = ""; }; B33278511CB2C94700BBDD3A /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; @@ -244,6 +253,9 @@ B37E82811FA85B210069E15D /* SendTextSingleBuddy.scpt */ = {isa = PBXFileReference; lastKnownFileType = file; path = SendTextSingleBuddy.scpt; sourceTree = ""; }; B39A65DF1CC1FB1B003E26B0 /* PluginManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; B39EC17D1CB01FEF002C3161 /* CoreModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreModule.swift; sourceTree = ""; }; + EC71CS00000000000000A001 /* ContactNameService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactNameService.swift; sourceTree = ""; }; + EC71CK00000000000000A001 /* Clock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clock.swift; sourceTree = ""; }; + EC71RL00000000000000A001 /* SendRateLimiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendRateLimiter.swift; sourceTree = ""; }; B39F2B381CB3BCA700C0D35C /* SendText.scpt */ = {isa = PBXFileReference; lastKnownFileType = file; path = SendText.scpt; sourceTree = ""; }; B3A4C61323B867E300B7F009 /* MessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequest.swift; sourceTree = ""; }; B3B7E52324E9EE2000A3FBD1 /* AutomationPermissionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationPermissionState.swift; sourceTree = ""; }; @@ -396,6 +408,7 @@ B347A4AC24DD170C00E93657 /* ActionTest.swift */, B3B844B924D77A4800DC162A /* SendStyleTest.swift */, B32DE51424D51F9C00AB3A71 /* RouterTests.swift */, + EC71CM00000000000000A001 /* CoreModuleTests.swift */, EC70PT00000000000000A001 /* InboundFilterPolicyTests.swift */, B3B844CF24DA6AC800DC162A /* JaredWebServerTest.swift */, TM0000010000000000000005 /* TunnelManagerTests.swift */, @@ -501,6 +514,9 @@ children = ( B3BF17C3244EBF4900CC44C5 /* InternalModule.swift */, B39EC17D1CB01FEF002C3161 /* CoreModule.swift */, + EC71CS00000000000000A001 /* ContactNameService.swift */, + EC71CK00000000000000A001 /* Clock.swift */, + EC71RL00000000000000A001 /* SendRateLimiter.swift */, B3F185F524E51C880051C696 /* CoreModule.xcdatamodeld */, ); name = Modules; @@ -789,6 +805,9 @@ B3A4C61423B867E300B7F009 /* MessageRequest.swift in Sources */, B39A65E01CC1FB1B003E26B0 /* PluginManager.swift in Sources */, B3BBA7D11CB3284A00E1749C /* CoreModule.swift in Sources */, + EC71CS00000000000000A002 /* ContactNameService.swift in Sources */, + EC71CK00000000000000A002 /* Clock.swift in Sources */, + EC71RL00000000000000A002 /* SendRateLimiter.swift in Sources */, B36563EF1F3D68980064B114 /* TouchBarTextFieldExtension.swift in Sources */, B3F185F724E51C880051C696 /* CoreModule.xcdatamodeld in Sources */, B35CE7152196AD4E002F52A7 /* DatabaseHandler.swift in Sources */, @@ -879,6 +898,11 @@ TM0000010000000000000004 /* TunnelManagerTests.swift in Sources */, TM0000010000000000000002 /* TunnelManager.swift in Sources */, B32DE51524D51F9C00AB3A71 /* RouterTests.swift in Sources */, + EC71CM00000000000000A002 /* CoreModuleTests.swift in Sources */, + EC71C000000000000000A010 /* CoreModule.swift in Sources */, + EC71CS00000000000000A004 /* ContactNameService.swift in Sources */, + EC71CK00000000000000A004 /* Clock.swift in Sources */, + EC71RL00000000000000A004 /* SendRateLimiter.swift in Sources */, EC70PT00000000000000A002 /* InboundFilterPolicyTests.swift in Sources */, B3EF1DA4220687DD00953DE7 /* URLProtocolMock.swift in Sources */, B3EF1DA02206849000953DE7 /* WebHookManager.swift in Sources */, diff --git a/Jared/Clock.swift b/Jared/Clock.swift new file mode 100644 index 0000000..12aa40b --- /dev/null +++ b/Jared/Clock.swift @@ -0,0 +1,18 @@ +// +// Clock.swift +// Jared +// +// Async sleep port so timed operations (e.g. `/send` delays) run instantly in tests. +// + +import Foundation + +protocol Clock { + func sleep(seconds: Int) async +} + +struct RealClock: Clock { + func sleep(seconds: Int) async { + try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000) + } +} diff --git a/Jared/ContactNameService.swift b/Jared/ContactNameService.swift new file mode 100644 index 0000000..537f988 --- /dev/null +++ b/Jared/ContactNameService.swift @@ -0,0 +1,58 @@ +// +// ContactNameService.swift +// Jared +// +// Port hiding CNContactStore behind a small interface so `/name` is testable +// without Contacts permission, and contact create/update has no `try!`. +// + +import Foundation +import Contacts + +protocol ContactNameService { + var isAuthorized: Bool { get } + /// Sets the given name for the contact matching `handle`, creating the + /// contact when none exists. Throws if the contact store write fails. + func setGivenName(_ name: String, forHandle handle: String) throws +} + +struct CNContactNameService: ContactNameService { + var isAuthorized: Bool { + CNContactStore.authorizationStatus(for: .contacts) == .authorized + } + + func setGivenName(_ name: String, forHandle handle: String) throws { + let store = CNContactStore() + + let searchPredicate: NSPredicate + if !handle.contains("@") { + searchPredicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: handle)) + } else { + searchPredicate = CNContact.predicateForContacts(matchingEmailAddress: handle) + } + + let found = try store.unifiedContacts( + matching: searchPredicate, + keysToFetch: [CNContactFamilyNameKey as CNKeyDescriptor, CNContactGivenNameKey as CNKeyDescriptor] + ) + + let saveRequest = CNSaveRequest() + if let existing = found.first { + let mutable = existing.mutableCopy() as! CNMutableContact + mutable.givenName = name + saveRequest.update(mutable) + } else { + let newContact = CNMutableContact() + newContact.givenName = name + newContact.note = "Created By jared.app" + if handle.contains("@") { + newContact.emailAddresses = [CNLabeledValue(label: CNLabelHome, value: handle as NSString)] + } else { + newContact.phoneNumbers = [CNLabeledValue(label: "iPhone", value: CNPhoneNumber(stringValue: handle))] + } + saveRequest.add(newContact, toContainerWithIdentifier: nil) + } + + try store.execute(saveRequest) + } +} diff --git a/Jared/CoreModule.swift b/Jared/CoreModule.swift index e220bec..5b2e546 100644 --- a/Jared/CoreModule.swift +++ b/Jared/CoreModule.swift @@ -9,20 +9,30 @@ import Foundation import Cocoa import JaredFramework -import Contacts class CoreModule: RoutingModule { + static let MAXIMUM_CONCURRENT_SENDS = 3 var description: String = NSLocalizedString("CoreDescription") var routes: [Route] = [] var sender: MessageSender - let MAXIMUM_CONCURRENT_SENDS = 3 - var currentSends: [String: Int] = [:] - - required public init(sender: MessageSender) { + private let clock: Clock + private let rateLimiter: SendRateLimiter + private let contacts: ContactNameService + + required convenience public init(sender: MessageSender) { + self.init(sender: sender, clock: RealClock(), + rateLimiter: SendRateLimiter(max: CoreModule.MAXIMUM_CONCURRENT_SENDS), + contacts: CNContactNameService()) + } + + init(sender: MessageSender, clock: Clock = RealClock(), + rateLimiter: SendRateLimiter = SendRateLimiter(max: CoreModule.MAXIMUM_CONCURRENT_SENDS), + contacts: ContactNameService = CNContactNameService()) { self.sender = sender - let appsupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Jared").appendingPathComponent("CoreModule") - try! FileManager.default.createDirectory(at: appsupport, withIntermediateDirectories: true, attributes: nil) - + self.clock = clock + self.rateLimiter = rateLimiter + self.contacts = contacts + let ping = Route(name:"/ping", comparisons: [.startsWith: ["/ping"]], call: {[weak self] in self?.pingCall($0)}, description: NSLocalizedString("pingDescription")) let thankYou = Route(name:"Thank You", comparisons: [.startsWith: [NSLocalizedString("ThanksJaredCommand")]], call: {[weak self] in self?.thanksJared($0)}, description: NSLocalizedString("ThanksJaredResponse")) @@ -49,7 +59,8 @@ class CoreModule: RoutingModule { } func barf(_ incoming: Message) -> Void { - sender.send(String(data: try! JSONEncoder().encode(incoming), encoding: .utf8) ?? "nil", to: incoming.RespondTo()) + let encoded = (try? JSONEncoder().encode(incoming)).flatMap { String(data: $0, encoding: .utf8) } + sender.send(encoded ?? "nil", to: incoming.RespondTo()) } func getWho(_ message: Message) -> Void { @@ -87,97 +98,52 @@ class CoreModule: RoutingModule { return sender.send("Wrong arguments. The third argument must be the message you wish to send.", to: message.RespondTo()) } - guard (currentSends[message.sender.handle] ?? 0) < MAXIMUM_CONCURRENT_SENDS else { - return sender.send("You can only have \(MAXIMUM_CONCURRENT_SENDS) send operations going at once.", to: message.RespondTo()) - } - - if (currentSends[message.sender.handle] == nil) - { - currentSends[message.sender.handle] = 0 + guard rateLimiter.tryAcquire(message.sender.handle) else { + return sender.send("You can only have \(CoreModule.MAXIMUM_CONCURRENT_SENDS) send operations going at once.", to: message.RespondTo()) } - - //Increment the concurrent send counter for this user - currentSends[message.sender.handle] = currentSends[message.sender.handle]! + 1 - + //If there are commas in the message, take the whole message if parameters.count > 4 { textToSend = parameters[3...(parameters.count - 1)].joined(separator: ",") } - - //Go through the repeat loop asynchronously + + let finalText = textToSend Task { [weak self] in - guard let self = self else { return } - for _ in 1...repeatNum { - self.sender.send(textToSend, to: message.RespondTo()) - try? await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000) - } - self.currentSends[message.sender.handle] = (self.currentSends[message.sender.handle] ?? 0) - 1 + await self?.performSend(message, text: finalText, times: repeatNum, delay: delay) } } + + /// Sends `text` to the message's responder `times` times, pausing `delay` + /// seconds between sends, then releases the rate-limiter slot. Extracted so + /// the send loop is awaitable/testable with an injected `Clock`. + func performSend(_ message: Message, text: String, times: Int, delay: Int = 0) async { + for _ in 1...times { + sender.send(text, to: message.RespondTo()) + await clock.sleep(seconds: delay) + } + rateLimiter.release(message.sender.handle) + } func changeName(_ message: Message) { guard let parsedMessage = message.getTextParameters() else { - return sender.send("Inappropriate input type", to:message.RespondTo()) + return sender.send("Inappropriate input type", to: message.RespondTo()) } - - if (parsedMessage.count == 1) { + + guard parsedMessage.count > 1 else { return sender.send("Wrong arguments.", to: message.RespondTo()) } - - guard (CNContactStore.authorizationStatus(for: CNEntityType.contacts) == .authorized) else { + + guard contacts.isAuthorized else { return sender.send("Sorry, I do not have access to contacts.", to: message.RespondTo()) } - let store = CNContactStore() - - let searchPredicate: NSPredicate - if (!(message.sender.handle.contains("@"))) { - searchPredicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: message.sender.handle )) - } else { - searchPredicate = CNContact.predicateForContacts(matchingEmailAddress: message.sender.handle ) - } - - let peopleFound = try! store.unifiedContacts(matching: searchPredicate, keysToFetch:[CNContactFamilyNameKey as CNKeyDescriptor, CNContactGivenNameKey as CNKeyDescriptor]) - - - //We need to create the contact - if (peopleFound.count == 0) { - // Creating a new contact - let newContact = CNMutableContact() - newContact.givenName = parsedMessage[1] - newContact.note = "Created By jared.app" - - //If it contains an at, add the handle as email, otherwise add it as phone - if (message.sender.handle.contains("@")) { - let homeEmail = CNLabeledValue(label: CNLabelHome, value: (message.sender.handle) as NSString) - newContact.emailAddresses = [homeEmail] - } - else { - let iPhonePhone = CNLabeledValue(label: "iPhone", value: CNPhoneNumber(stringValue:message.sender.handle)) - newContact.phoneNumbers = [iPhonePhone] - } - - let saveRequest = CNSaveRequest() - saveRequest.add(newContact, toContainerWithIdentifier:nil) - do { - try store.execute(saveRequest) - } catch { - return sender.send("There was an error saving your contact..", to: message.RespondTo()) - } - - sender.send("Ok, I'll call you \(parsedMessage[1]) from now on.", to: message.RespondTo()) - } - //The contact already exists, modify the value - else { - let mutableContact = peopleFound[0].mutableCopy() as! CNMutableContact - mutableContact.givenName = parsedMessage[1] - - let saveRequest = CNSaveRequest() - saveRequest.update(mutableContact) - try! store.execute(saveRequest) - + + do { + try contacts.setGivenName(parsedMessage[1], forHandle: message.sender.handle) sender.send("Ok, I'll call you \(parsedMessage[1]) from now on.", to: message.RespondTo()) + } catch { + sender.send("There was an error saving your contact..", to: message.RespondTo()) } } diff --git a/Jared/SendRateLimiter.swift b/Jared/SendRateLimiter.swift new file mode 100644 index 0000000..b0f91ee --- /dev/null +++ b/Jared/SendRateLimiter.swift @@ -0,0 +1,33 @@ +// +// SendRateLimiter.swift +// Jared +// +// Per-handle concurrency limiter for `/send` repeat operations. Pulled out of +// CoreModule so the limit is deterministic and testable without async timing. +// + +import Foundation + +final class SendRateLimiter { + private let max: Int + private var inFlight: [String: Int] = [:] + + init(max: Int) { + self.max = max + } + + /// Reserves a slot for `handle`. Returns false (no reservation) if `handle` + /// is already at the maximum concurrent sends. + func tryAcquire(_ handle: String) -> Bool { + let current = inFlight[handle] ?? 0 + guard current < max else { return false } + inFlight[handle] = current + 1 + return true + } + + /// Releases one slot for `handle`. + func release(_ handle: String) { + guard let current = inFlight[handle], current > 0 else { return } + inFlight[handle] = current - 1 + } +} diff --git a/JaredTests/CoreModuleTests.swift b/JaredTests/CoreModuleTests.swift new file mode 100644 index 0000000..ad2dca5 --- /dev/null +++ b/JaredTests/CoreModuleTests.swift @@ -0,0 +1,210 @@ +// +// CoreModuleTests.swift +// JaredTests +// + +import XCTest +import JaredFramework +@testable import Jared + +private struct NonTextBody: MessageBody {} + +private struct InstantClock: Clock { + func sleep(seconds: Int) async {} +} + +private final class FakeContactNameService: ContactNameService { + var isAuthorized: Bool + var errorToThrow: Error? + private(set) var saved: [(name: String, handle: String)] = [] + + init(isAuthorized: Bool = true, errorToThrow: Error? = nil) { + self.isAuthorized = isAuthorized + self.errorToThrow = errorToThrow + } + + func setGivenName(_ name: String, forHandle handle: String) throws { + if let errorToThrow { throw errorToThrow } + saved.append((name, handle)) + } +} + +private struct DummyError: Error {} + +class CoreModuleTests: XCTestCase { + private var mock: JaredMock! + private let me = Person(givenName: "zeke", handle: "zeke@email.com", isMe: true) + private let other = Person(givenName: "taylor", handle: "taylor@swift.org", isMe: false) + + override func setUp() { + mock = JaredMock() + } + + private func module() -> CoreModule { + CoreModule(sender: mock) + } + + private func textMessage(_ text: String) -> Message { + Message(body: TextBody(text), date: Date(), sender: other, recipient: me) + } + + private func lastReply() -> String? { + (mock.calls.last?.body as? TextBody)?.message + } + + func testPingRepliesPong() { + module().pingCall(textMessage("/ping")) + XCTAssertEqual(mock.calls.count, 1) + XCTAssertEqual(lastReply(), NSLocalizedString("PongResponse")) + } + + func testVersionReplies() { + module().getVersion(textMessage("/version")) + XCTAssertEqual(lastReply(), NSLocalizedString("versionResponse")) + } + + func testThanksJaredReplies() { + module().thanksJared(textMessage("thanks")) + XCTAssertEqual(lastReply(), NSLocalizedString("WelcomeResponse")) + } + + func testWhoamiRepliesNameWhenKnown() { + let msg = Message(body: TextBody("/whoami"), date: Date(), + sender: Person(givenName: "Taylor", handle: "t@swift.org", isMe: false), + recipient: me) + module().getWho(msg) + XCTAssertEqual(lastReply(), "Your name is Taylor.") + } + + func testWhoamiRepliesUnknownWhenNoName() { + let msg = Message(body: TextBody("/whoami"), date: Date(), + sender: Person(givenName: nil, handle: "t@swift.org", isMe: false), + recipient: me) + module().getWho(msg) + XCTAssertEqual(lastReply(), "I don't know your name.") + } + + func testBarfRepliesEncodedMessage() { + module().barf(textMessage("/barf")) + XCTAssertEqual(mock.calls.count, 1) + let reply = lastReply() + XCTAssertNotNil(reply) + XCTAssertTrue(reply!.contains("/barf"), "barf echoes encoded message JSON") + } + + // MARK: - /send validation + + func testSendRejectsNonTextBody() { + let msg = Message(body: NonTextBody(), date: Date(), sender: other, recipient: me) + module().sendRepeat(msg) + XCTAssertEqual(lastReply(), "Inappropriate input type.") + } + + func testSendRejectsNonNumericCount() { + module().sendRepeat(textMessage("/send,abc,1,hi")) + XCTAssertEqual(lastReply(), "Wrong argument. The first argument must be the number of message you wish to send") + } + + func testSendRejectsNonNumericDelay() { + module().sendRepeat(textMessage("/send,2,abc,hi")) + XCTAssertEqual(lastReply(), "Wrong argument. The second argument must be the delay of the messages you wish to send") + } + + func testSendRejectsMissingText() { + module().sendRepeat(textMessage("/send,2,1")) + XCTAssertEqual(lastReply(), "Wrong arguments. The third argument must be the message you wish to send.") + } + + func testSendDeliversRepeatedMessages() async { + let mod = CoreModule(sender: mock, clock: InstantClock()) + await mod.performSend(textMessage("/send,3,0,hi"), text: "hi", times: 3) + XCTAssertEqual(mock.calls.count, 3) + XCTAssertEqual(lastReply(), "hi") + } + + func testSendRejectsWhenAtConcurrencyLimit() { + let limiter = SendRateLimiter(max: 3) + _ = limiter.tryAcquire(other.handle) + _ = limiter.tryAcquire(other.handle) + _ = limiter.tryAcquire(other.handle) + let mod = CoreModule(sender: mock, clock: InstantClock(), rateLimiter: limiter) + + mod.sendRepeat(textMessage("/send,1,0,hi")) + + XCTAssertEqual(lastReply(), "You can only have 3 send operations going at once.") + } + + // MARK: - /name + + private func module(contacts: ContactNameService) -> CoreModule { + CoreModule(sender: mock, contacts: contacts) + } + + func testNameRepliesWhenNotAuthorized() { + let mod = module(contacts: FakeContactNameService(isAuthorized: false)) + mod.changeName(textMessage("/name,Taylor")) + XCTAssertEqual(lastReply(), "Sorry, I do not have access to contacts.") + } + + func testNameRejectsMissingArgument() { + let mod = module(contacts: FakeContactNameService()) + mod.changeName(textMessage("/name")) + XCTAssertEqual(lastReply(), "Wrong arguments.") + } + + func testNameRejectsNonTextBody() { + let mod = module(contacts: FakeContactNameService()) + mod.changeName(Message(body: NonTextBody(), date: Date(), sender: other, recipient: me)) + XCTAssertEqual(lastReply(), "Inappropriate input type") + } + + func testNameSavesGivenNameAndConfirms() { + let fake = FakeContactNameService() + let mod = module(contacts: fake) + mod.changeName(textMessage("/name,Taylor")) + XCTAssertEqual(fake.saved.count, 1) + XCTAssertEqual(fake.saved.first?.name, "Taylor") + XCTAssertEqual(fake.saved.first?.handle, other.handle) + XCTAssertEqual(lastReply(), "Ok, I'll call you Taylor from now on.") + } + + func testNameRepliesErrorWhenSaveThrows() { + let mod = module(contacts: FakeContactNameService(errorToThrow: DummyError())) + mod.changeName(textMessage("/name,Taylor")) + XCTAssertEqual(lastReply(), "There was an error saving your contact..") + } + + // MARK: - init + + func testInitRegistersRoutesWithoutFilesystemSideEffects() { + let mod = CoreModule(sender: mock) + XCTAssertFalse(mod.routes.isEmpty) + XCTAssertTrue(mod.routes.contains { $0.name == "/ping" }) + XCTAssertTrue(mod.routes.contains { $0.name == "/send" }) + XCTAssertTrue(mod.routes.contains { $0.name == "/name" }) + } +} + +class SendRateLimiterTests: XCTestCase { + func testAcquiresUpToMaxThenRejects() { + let limiter = SendRateLimiter(max: 3) + XCTAssertTrue(limiter.tryAcquire("a")) + XCTAssertTrue(limiter.tryAcquire("a")) + XCTAssertTrue(limiter.tryAcquire("a")) + XCTAssertFalse(limiter.tryAcquire("a"), "4th concurrent send for same handle is rejected") + } + + func testReleaseFreesSlot() { + let limiter = SendRateLimiter(max: 1) + XCTAssertTrue(limiter.tryAcquire("a")) + XCTAssertFalse(limiter.tryAcquire("a")) + limiter.release("a") + XCTAssertTrue(limiter.tryAcquire("a"), "slot reusable after release") + } + + func testLimitIsPerHandle() { + let limiter = SendRateLimiter(max: 1) + XCTAssertTrue(limiter.tryAcquire("a")) + XCTAssertTrue(limiter.tryAcquire("b"), "different handle has its own quota") + } +}