mirror of
https://github.com/nicoverbruggen/phpmon.git
synced 2026-04-01 17:20:09 +02:00
✨ Add testable WebApi
This commit is contained in:
@@ -79,7 +79,7 @@
|
||||
039C29192E8AA314007F5FAB /* TestableWebApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C29172E8AA311007F5FAB /* TestableWebApi.swift */; };
|
||||
039C291A2E8AA314007F5FAB /* TestableWebApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C29172E8AA311007F5FAB /* TestableWebApi.swift */; };
|
||||
039C291B2E8AA314007F5FAB /* TestableWebApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C29172E8AA311007F5FAB /* TestableWebApi.swift */; };
|
||||
039C291D2E8AA39A007F5FAB /* TestableApiTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C291C2E8AA399007F5FAB /* TestableApiTest.swift */; };
|
||||
039C291D2E8AA39A007F5FAB /* TestableWebApiTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039C291C2E8AA399007F5FAB /* TestableWebApiTest.swift */; };
|
||||
039E1D792E5F0F300072D13D /* ValetUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039E1D782E5F0F2C0072D13D /* ValetUpgrader.swift */; };
|
||||
039E1D7A2E5F0F300072D13D /* ValetUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039E1D782E5F0F2C0072D13D /* ValetUpgrader.swift */; };
|
||||
039E1D7B2E5F0F300072D13D /* ValetUpgrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039E1D782E5F0F2C0072D13D /* ValetUpgrader.swift */; };
|
||||
@@ -1036,7 +1036,7 @@
|
||||
0392CDEA2EB25371009176DA /* SecurePopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurePopoverView.swift; sourceTree = "<group>"; };
|
||||
0396160C2E74A61B002DD7F6 /* LoggableEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggableEvent.swift; sourceTree = "<group>"; };
|
||||
039C29172E8AA311007F5FAB /* TestableWebApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestableWebApi.swift; sourceTree = "<group>"; };
|
||||
039C291C2E8AA399007F5FAB /* TestableApiTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestableApiTest.swift; sourceTree = "<group>"; };
|
||||
039C291C2E8AA399007F5FAB /* TestableWebApiTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestableWebApiTest.swift; sourceTree = "<group>"; };
|
||||
039E1D782E5F0F2C0072D13D /* ValetUpgrader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValetUpgrader.swift; sourceTree = "<group>"; };
|
||||
03B675E82EBA30D200EE04A9 /* NSImageExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSImageExtension.swift; sourceTree = "<group>"; };
|
||||
03BFF5262E312C39007F96FA /* Startup+Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Startup+Timers.swift"; sourceTree = "<group>"; };
|
||||
@@ -1455,7 +1455,7 @@
|
||||
039C291E2E8AA39B007F5FAB /* Api */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
039C291C2E8AA399007F5FAB /* TestableApiTest.swift */,
|
||||
039C291C2E8AA399007F5FAB /* TestableWebApiTest.swift */,
|
||||
);
|
||||
path = Api;
|
||||
sourceTree = "<group>";
|
||||
@@ -3630,7 +3630,7 @@
|
||||
C409349E298EE8E900D25014 /* AppUpdater.swift in Sources */,
|
||||
C4AF9F7D275454A900D44ED0 /* ValetVersionExtractorTest.swift in Sources */,
|
||||
C4B56362276AB0A500F12CCB /* VersionExtractorTest.swift in Sources */,
|
||||
039C291D2E8AA39A007F5FAB /* TestableApiTest.swift in Sources */,
|
||||
039C291D2E8AA39A007F5FAB /* TestableWebApiTest.swift in Sources */,
|
||||
C4B585452770FE3900DA4FBE /* RealCommand.swift in Sources */,
|
||||
C4F780C525D80B75000DBC97 /* MenuBarImageGenerator.swift in Sources */,
|
||||
C4F780B725D80B5D000DBC97 /* App.swift in Sources */,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import Foundation
|
||||
|
||||
extension TimeInterval {
|
||||
static func milliseconds(_ value: Double) -> TimeInterval { value / 1000 }
|
||||
static func seconds(_ value: Double) -> TimeInterval { value }
|
||||
static func minutes(_ value: Double) -> TimeInterval { value * 60 }
|
||||
static func hours(_ value: Double) -> TimeInterval { value * 3600 }
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
//
|
||||
// RealApi.swift
|
||||
// RealWebApi.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 30/09/2025.
|
||||
// Copyright © 2025 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class RealWebApi: WebApiProtocol {
|
||||
var container: Container
|
||||
|
||||
init(container: Container) {
|
||||
self.container = container
|
||||
}
|
||||
|
||||
func get(_ url: URL, withHeaders: HttpHeaders, withTimeout: TimeInterval) async throws -> WebApiResponse {
|
||||
return WebApiResponse(statusCode: 200, headers: [:], data: Data())
|
||||
}
|
||||
|
||||
func post(_ url: URL, withHeaders: HttpHeaders, withData: String, withTimeout: TimeInterval) async throws -> WebApiResponse {
|
||||
return WebApiResponse(statusCode: 200, headers: [:], data: Data())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,73 @@
|
||||
import Foundation
|
||||
|
||||
class TestableWebApi: WebApiProtocol {
|
||||
private var fakeResponses: [URL: FakeWebApiResponse] = [:]
|
||||
private var fakeGetResponses: [URL: FakeWebApiResponse] = [:]
|
||||
private var fakePostResponses: [URL: FakeWebApiResponse] = [:]
|
||||
private var slow: Bool = false
|
||||
|
||||
init(responses: [URL: FakeWebApiResponse]) {
|
||||
self.fakeResponses = responses
|
||||
public func setSlowMode(_ slow: Bool) {
|
||||
self.slow = slow
|
||||
}
|
||||
|
||||
public func hasResponse(for url: URL) -> Bool {
|
||||
return fakeResponses.keys.contains(url)
|
||||
init(
|
||||
getResponses: [URL: FakeWebApiResponse],
|
||||
postResponses: [URL: FakeWebApiResponse]
|
||||
) {
|
||||
self.fakeGetResponses = getResponses
|
||||
self.fakePostResponses = postResponses
|
||||
}
|
||||
|
||||
public func getResponse(for url: URL) -> FakeWebApiResponse {
|
||||
return fakeResponses[url]!
|
||||
public func hasGetResponse(for url: URL) -> Bool {
|
||||
return fakeGetResponses.keys.contains(url)
|
||||
}
|
||||
|
||||
public func hasPostResponse(for url: URL) -> Bool {
|
||||
return fakePostResponses.keys.contains(url)
|
||||
}
|
||||
|
||||
func get(
|
||||
_ url: URL,
|
||||
withHeaders headers: HttpHeaders = [:],
|
||||
withTimeout timeout: TimeInterval = .seconds(10)
|
||||
) async throws -> WebApiResponse {
|
||||
if hasGetResponse(for: url) {
|
||||
let response = fakeGetResponses[url]!
|
||||
|
||||
if response.requestDuration > timeout {
|
||||
if slow {
|
||||
await delay(seconds: timeout)
|
||||
}
|
||||
throw WebApiError.timedOut
|
||||
} else {
|
||||
if slow {
|
||||
await delay(seconds: response.requestDuration)
|
||||
}
|
||||
return response.toWebApiResponse()
|
||||
}
|
||||
} else {
|
||||
throw WebApiError.invalidURL
|
||||
}
|
||||
}
|
||||
|
||||
func post(
|
||||
_ url: URL,
|
||||
withHeaders headers: HttpHeaders = [:],
|
||||
withData data: String,
|
||||
withTimeout timeout: TimeInterval
|
||||
) async throws -> WebApiResponse {
|
||||
if hasPostResponse(for: url) {
|
||||
let response = fakePostResponses[url]!
|
||||
|
||||
if response.requestDuration > timeout {
|
||||
await delay(seconds: timeout)
|
||||
throw WebApiError.timedOut
|
||||
} else {
|
||||
await delay(seconds: response.requestDuration)
|
||||
return response.toWebApiResponse()
|
||||
}
|
||||
} else {
|
||||
throw WebApiError.invalidURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +83,33 @@ struct FakeWebApiResponse {
|
||||
let statusCode: Int
|
||||
let headers: [String: String]
|
||||
let data: Data?
|
||||
let requestDuration: TimeInterval
|
||||
|
||||
init(statusCode: Int, headers: [String: String], text: String) {
|
||||
init(
|
||||
statusCode: Int,
|
||||
headers: [String: String],
|
||||
text: String,
|
||||
duration: TimeInterval
|
||||
) {
|
||||
self.statusCode = statusCode
|
||||
self.headers = headers
|
||||
self.data = text.data(using: .utf8)
|
||||
self.requestDuration = duration
|
||||
}
|
||||
|
||||
var text: String {
|
||||
return String(data: self.data!, encoding: .utf8) ?? ""
|
||||
guard let data = self.data else {
|
||||
return ""
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
func toWebApiResponse() -> WebApiResponse {
|
||||
WebApiResponse(
|
||||
statusCode: self.statusCode,
|
||||
headers: self.headers,
|
||||
data: self.data
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
//
|
||||
// ApiProtocol.swift
|
||||
// WebApiProtocol.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 30/09/2025.
|
||||
// Copyright © 2025 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
protocol WebApiProtocol {
|
||||
import Foundation
|
||||
|
||||
typealias HttpHeaders = [String: String]
|
||||
|
||||
enum WebApiError: Error {
|
||||
case invalidURL
|
||||
case networkError
|
||||
case timedOut
|
||||
}
|
||||
|
||||
struct WebApiResponse {
|
||||
let statusCode: Int
|
||||
let headers: HttpHeaders
|
||||
let data: Data?
|
||||
|
||||
var plainText: String? {
|
||||
guard let data = self.data else {
|
||||
assertionFailure("Response data is unexpectedly empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
assertionFailure("Response unexpectedly cannot be decoded")
|
||||
return nil
|
||||
}
|
||||
|
||||
return string
|
||||
}
|
||||
}
|
||||
|
||||
protocol WebApiProtocol {
|
||||
func get(
|
||||
_ url: URL,
|
||||
withHeaders headers: HttpHeaders,
|
||||
withTimeout timeout: TimeInterval
|
||||
) async throws -> WebApiResponse
|
||||
|
||||
func post(
|
||||
_ url: URL,
|
||||
withHeaders headers: HttpHeaders,
|
||||
withData data: String,
|
||||
withTimeout timeout: TimeInterval
|
||||
) async throws -> WebApiResponse
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ extension Container {
|
||||
shell: [String: BatchFakeShellOutput] = [:],
|
||||
files: [String: FakeFile] = [:],
|
||||
commands: [String: String] = [:],
|
||||
apiResponses: [URL: FakeWebApiResponse] = [:]
|
||||
getResponses: [URL: FakeWebApiResponse] = [:],
|
||||
postResponses: [URL: FakeWebApiResponse] = [:]
|
||||
) -> Container {
|
||||
// Create a new container
|
||||
let container = Container()
|
||||
@@ -31,7 +32,8 @@ extension Container {
|
||||
shellExpectations: shell,
|
||||
fileSystemFiles: files,
|
||||
commands: commands,
|
||||
fakeApiResponses: apiResponses
|
||||
webApiGetResponses: getResponses,
|
||||
webApiPostResponses: postResponses
|
||||
)
|
||||
|
||||
// Return the newly created container
|
||||
|
||||
@@ -86,12 +86,16 @@ class Container {
|
||||
shellExpectations: [String: BatchFakeShellOutput] = [:],
|
||||
fileSystemFiles: [String: FakeFile] = [:],
|
||||
commands: [String: String] = [:],
|
||||
fakeApiResponses: [URL: FakeWebApiResponse] = [:]
|
||||
webApiGetResponses: [URL: FakeWebApiResponse] = [:],
|
||||
webApiPostResponses: [URL: FakeWebApiResponse] = [:]
|
||||
) {
|
||||
self.shell = TestableShell(expectations: shellExpectations)
|
||||
self.filesystem = TestableFileSystem(files: fileSystemFiles)
|
||||
self.command = TestableCommand(commands: commands)
|
||||
self.webApi = TestableWebApi(responses: fakeApiResponses)
|
||||
self.webApi = TestableWebApi(
|
||||
getResponses: webApiGetResponses,
|
||||
postResponses: webApiPostResponses
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
//
|
||||
// Untitled.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 29/09/2025.
|
||||
// Copyright © 2025 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
|
||||
struct TestableApiTest {
|
||||
private var container: Container
|
||||
|
||||
init() throws {
|
||||
self.container = Container.fake(apiResponses: [
|
||||
url("https://api.phpmon.test"): FakeWebApiResponse(
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
text: "{\"success\": true}"
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
var WebApi: TestableWebApi {
|
||||
return container.webApi as! TestableWebApi
|
||||
}
|
||||
|
||||
@Test func createFakeApi() {
|
||||
#expect(WebApi.hasResponse(for: url("https://api.phpmon.test")) == true)
|
||||
|
||||
let response = WebApi.getResponse(for: url("https://api.phpmon.test"))
|
||||
|
||||
#expect(response.statusCode == 200)
|
||||
#expect(response.text.contains("success"))
|
||||
}
|
||||
}
|
||||
84
tests/unit/Api/TestableWebApiTest.swift
Normal file
84
tests/unit/Api/TestableWebApiTest.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// TestableWebApiTest.swift
|
||||
// PHP Monitor
|
||||
//
|
||||
// Created by Nico Verbruggen on 29/09/2025.
|
||||
// Copyright © 2025 Nico Verbruggen. All rights reserved.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
|
||||
struct TestableWebApiTest {
|
||||
private var container: Container
|
||||
|
||||
init() throws {
|
||||
self.container = Container.fake(getResponses: [
|
||||
url("https://api.phpmon.test"): FakeWebApiResponse(
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
text: "{\"success\": true}",
|
||||
duration: .milliseconds(150)
|
||||
),
|
||||
url("https://api.phpmon.test/up"): FakeWebApiResponse(
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
text: "{\"success\": true}",
|
||||
duration: .milliseconds(40)
|
||||
),
|
||||
url("https://api.phpmon.test/woop"): FakeWebApiResponse(
|
||||
statusCode: 404,
|
||||
headers: [:],
|
||||
text: "PAGE NOT FOUND",
|
||||
duration: .seconds(2)
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
var WebApi: TestableWebApi {
|
||||
return container.webApi as! TestableWebApi
|
||||
}
|
||||
|
||||
@Test func requestSucceeds() async {
|
||||
#expect(WebApi.hasGetResponse(for: url("https://api.phpmon.test")) == true)
|
||||
|
||||
let response = try! await WebApi.get(
|
||||
url("https://api.phpmon.test/up"),
|
||||
withTimeout: .seconds(1.0)
|
||||
)
|
||||
|
||||
#expect(response.statusCode == 200)
|
||||
#expect(response.plainText!.contains("success"))
|
||||
}
|
||||
|
||||
@Test func requestTimesOut() async {
|
||||
await #expect(throws: WebApiError.timedOut) {
|
||||
try await WebApi.get(
|
||||
url("https://api.phpmon.test/woop"),
|
||||
withTimeout: .seconds(1.0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func requestTimesOutInSlowMode() async {
|
||||
WebApi.setSlowMode(true)
|
||||
|
||||
await #expect(throws: WebApiError.timedOut) {
|
||||
try await WebApi.get(
|
||||
url("https://api.phpmon.test/woop"),
|
||||
withTimeout: .seconds(1.0)
|
||||
)
|
||||
}
|
||||
|
||||
WebApi.setSlowMode(false)
|
||||
}
|
||||
|
||||
@Test func invalidUrl() async {
|
||||
await #expect(throws: WebApiError.invalidURL) {
|
||||
try await WebApi.get(
|
||||
url("https://api.phpmon.test/woop/nice"),
|
||||
withTimeout: .seconds(1.0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
|
||||
@Suite(.serialized)
|
||||
@Suite(.serialized) // serialized due to how unique temp directory works
|
||||
struct RealFileSystemTest {
|
||||
var filesystem: FileSystemProtocol
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
|
||||
@Suite(.serialized)
|
||||
struct TestableFileSystemTest {
|
||||
private var container: Container
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
|
||||
@Suite(.serialized)
|
||||
struct RealShellTest {
|
||||
var container: Container
|
||||
|
||||
@@ -75,6 +74,6 @@ struct RealShellTest {
|
||||
}
|
||||
|
||||
let duration = start.duration(to: .now)
|
||||
#expect(duration < .milliseconds(1500)) // Should complete in ~700ms if parallel
|
||||
#expect(duration < .milliseconds(2000)) // Should complete in ~700ms if parallel
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user