1
0
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:
2025-11-18 14:02:16 +01:00
parent 7a60435421
commit ea6d7ca457
12 changed files with 238 additions and 61 deletions

View File

@@ -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 */,

View File

@@ -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 }

View File

@@ -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())
}
}

View File

@@ -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
)
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
)
}
/**

View File

@@ -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"))
}
}

View 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)
)
}
}
}

View File

@@ -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

View File

@@ -9,7 +9,6 @@
import Testing
import Foundation
@Suite(.serialized)
struct TestableFileSystemTest {
private var container: Container

View File

@@ -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
}
}