feat(ios): add native app foundation and unsigned ipa build
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class AIThemeTests: XCTestCase {
|
||||
func testControlDimensionsMatchWebScale() {
|
||||
XCTAssertEqual(AITheme.buttonHeight, 38, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.compactButtonHeight, 34, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.largeButtonHeight, 42, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.buttonRadius, 10, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.iconButtonSize, 40, accuracy: 0.1)
|
||||
}
|
||||
|
||||
func testButtonKindsHaveDistinctBaseColors() {
|
||||
let colors = [
|
||||
AIButtonStyle.Kind.primary,
|
||||
.secondary,
|
||||
.danger
|
||||
].map(\.backgroundColor)
|
||||
XCTAssertEqual(Set(colors).count, 3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class AppMetadataTests: XCTestCase {
|
||||
func testMetadataUsesIPAFoundationContract() {
|
||||
XCTAssertEqual(AppMetadata.name, "MultiSimAdmin")
|
||||
XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin")
|
||||
XCTAssertEqual(AppMetadata.marketingVersion, "0.1.0")
|
||||
XCTAssertEqual(AppMetadata.buildVersion, "1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class ConnectionProbeTests: XCTestCase {
|
||||
func testProbeMapsUpstreamResults() async {
|
||||
let unauthorized = ConnectionProbe(client: MockClient(status: 401))
|
||||
let online = ConnectionProbe(client: MockClient(status: 200))
|
||||
let unavailable = ConnectionProbe(client: MockClient(status: 0))
|
||||
let instance = Self.instance()
|
||||
|
||||
let auth = try? await unauthorized.test(instance)
|
||||
let reachable = try? await online.test(instance)
|
||||
let offline = try? await unavailable.test(instance)
|
||||
|
||||
XCTAssertEqual(auth?.status, .authRequired)
|
||||
XCTAssertEqual(reachable?.status, .online)
|
||||
XCTAssertEqual(offline?.status, .offline)
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
name: "Probe",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
tags: [],
|
||||
groupId: nil,
|
||||
credentialConfigured: false,
|
||||
configRevision: 1,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private struct MockClient: SimAdminClientProtocol {
|
||||
let status: Int
|
||||
|
||||
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
if status == 0 {
|
||||
throw UpstreamError.timeout
|
||||
}
|
||||
return UpstreamResponse(status: status, data: Data("{}".utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
@MainActor
|
||||
final class FleetViewModelTests: XCTestCase {
|
||||
func testRefreshSortsByNameAndReportsStatus() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore()
|
||||
)
|
||||
try await model.add(InstanceInput(
|
||||
name: "Zulu",
|
||||
origin: URL(string: "http://192.168.1.30:3000")!,
|
||||
tags: ["lab"],
|
||||
password: nil
|
||||
))
|
||||
try await model.add(InstanceInput(
|
||||
name: "Alpha",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
tags: [],
|
||||
password: nil
|
||||
))
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.rows.map(\.instance.name), ["Alpha", "Zulu"])
|
||||
XCTAssertEqual(model.rows.map(\.status), [.online, .online])
|
||||
}
|
||||
|
||||
func testAddValidatesPasswordAndDeleteRemovesCredential() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let credentials = NoopCredentialStore()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: credentials
|
||||
)
|
||||
try await model.add(InstanceInput(
|
||||
name: "Secret",
|
||||
origin: URL(string: "http://192.168.1.40:3000")!,
|
||||
password: "pass"
|
||||
))
|
||||
|
||||
let row = try XCTUnwrap(model.rows.first)
|
||||
XCTAssertTrue(row.instance.credentialConfigured)
|
||||
XCTAssertEqual(credentials.saved[row.instance.id], "pass")
|
||||
|
||||
await model.delete([row.instance.id])
|
||||
XCTAssertTrue(model.rows.isEmpty)
|
||||
XCTAssertNil(credentials.saved[row.instance.id])
|
||||
}
|
||||
}
|
||||
|
||||
struct AlwaysOnlineClient: SimAdminClientProtocol {
|
||||
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
UpstreamResponse(status: 200, data: Data("{}".utf8))
|
||||
}
|
||||
}
|
||||
|
||||
final class NoopCredentialStore: CredentialStore {
|
||||
private let lock = NSLock()
|
||||
private var savedStorage: [UUID: String] = [:]
|
||||
private var removedStorage: [UUID] = []
|
||||
|
||||
var saved: [UUID: String] {
|
||||
lock.withLock { savedStorage }
|
||||
}
|
||||
|
||||
var removed: [UUID] {
|
||||
lock.withLock { removedStorage }
|
||||
}
|
||||
|
||||
func get(instanceID: UUID) throws -> String? {
|
||||
saved[instanceID]
|
||||
}
|
||||
|
||||
func set(instanceID: UUID, password: String) throws {
|
||||
lock.withLock { savedStorage[instanceID] = password }
|
||||
}
|
||||
|
||||
func remove(instanceID: UUID) throws {
|
||||
lock.withLock {
|
||||
savedStorage[instanceID] = nil
|
||||
removedStorage.append(instanceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class MemoryInstanceRepository: InstanceRepository {
|
||||
private let lock = NSLock()
|
||||
private var itemsStorage: [Instance] = []
|
||||
|
||||
private var items: [Instance] {
|
||||
get { lock.withLock { itemsStorage } }
|
||||
set { lock.withLock { itemsStorage = newValue } }
|
||||
}
|
||||
|
||||
func list(_: InstanceQuery) async throws -> [Instance] {
|
||||
items.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
func get(_ id: UUID) async throws -> Instance? {
|
||||
items.first { $0.id == id }
|
||||
}
|
||||
|
||||
func create(_ input: InstanceInput) async throws -> Instance {
|
||||
let now = Date()
|
||||
let instance = Instance(
|
||||
id: UUID(),
|
||||
name: input.name,
|
||||
origin: input.origin,
|
||||
tags: input.tags,
|
||||
groupId: input.groupId,
|
||||
credentialConfigured: !(input.password ?? "").isEmpty,
|
||||
configRevision: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
items.append(instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
func update(_: UUID, expectedRevision: Int, _: InstancePatch) async throws -> Instance {
|
||||
throw RepositoryError.notFound
|
||||
}
|
||||
|
||||
func delete(_ id: UUID, expectedRevision: Int) async throws {
|
||||
items.removeAll { $0.id == id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class InstanceRepositoryTests: XCTestCase {
|
||||
private var location: URL!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
location = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("multi-simadmin-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
try? FileManager.default.removeItem(at: location)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testCreateListUpdateAndDeleteInstance() async throws {
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
let input = InstanceInput(
|
||||
name: "Lab Alpha",
|
||||
origin: URL(string: "http://192.168.3.55:3000/")!,
|
||||
tags: ["primary", "lab"],
|
||||
groupId: nil,
|
||||
password: "secret"
|
||||
)
|
||||
|
||||
let created = try await repository.create(input)
|
||||
XCTAssertEqual(created.name, "Lab Alpha")
|
||||
XCTAssertEqual(created.origin.absoluteString, "http://192.168.3.55:3000")
|
||||
XCTAssertEqual(created.tags, ["primary", "lab"])
|
||||
XCTAssertEqual(created.configRevision, 1)
|
||||
XCTAssertTrue(created.credentialConfigured)
|
||||
|
||||
let patch = InstancePatch(name: "Alpha", tags: ["lab"])
|
||||
let updated = try await repository.update(created.id, expectedRevision: 1, patch)
|
||||
XCTAssertEqual(updated.name, "Alpha")
|
||||
XCTAssertEqual(updated.tags, ["lab"])
|
||||
XCTAssertEqual(updated.configRevision, 2)
|
||||
XCTAssertTrue(updated.credentialConfigured)
|
||||
|
||||
try await repository.delete(updated.id, expectedRevision: 2)
|
||||
let afterDelete = try await repository.list(InstanceQuery())
|
||||
XCTAssertTrue(afterDelete.isEmpty)
|
||||
}
|
||||
|
||||
func testRejectsInvalidOriginDuplicateOriginAndStaleRevision() async throws {
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
let origin = URL(string: "http://192.168.1.20:3000")!
|
||||
let input = InstanceInput(name: "One", origin: origin, tags: [], groupId: nil, password: nil)
|
||||
let first = try await repository.create(input)
|
||||
|
||||
do {
|
||||
let nested = InstanceInput(
|
||||
name: "Nested",
|
||||
origin: URL(string: "http://192.168.1.20:3000/admin")!,
|
||||
tags: [],
|
||||
groupId: nil,
|
||||
password: nil
|
||||
)
|
||||
_ = try await repository.create(nested)
|
||||
XCTFail("Expected nested path rejection")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .invalidOrigin)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await repository.create(input)
|
||||
XCTFail("Expected duplicate-origin failure")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .duplicateOrigin)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await repository.update(first.id, expectedRevision: 99, InstancePatch(name: "New"))
|
||||
XCTFail("Expected stale-revision failure")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .revisionConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func testListFiltersBySearchTagAndGroup() async throws {
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
let groupId = UUID()
|
||||
_ = try await repository.create(InstanceInput(
|
||||
name: "Lab Alpha",
|
||||
origin: URL(string: "http://192.168.1.10:3000")!,
|
||||
tags: ["production"],
|
||||
groupId: groupId,
|
||||
password: nil
|
||||
))
|
||||
_ = try await repository.create(InstanceInput(
|
||||
name: "Home Beta",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
tags: ["lab"],
|
||||
groupId: nil,
|
||||
password: nil
|
||||
))
|
||||
|
||||
let search = try await repository.list(InstanceQuery(search: "alpha"))
|
||||
XCTAssertEqual(search.map(\.name), ["Lab Alpha"])
|
||||
let tagged = try await repository.list(InstanceQuery(tags: ["lab"]))
|
||||
XCTAssertEqual(tagged.map(\.name), ["Home Beta"])
|
||||
let grouped = try await repository.list(InstanceQuery(groupId: groupId))
|
||||
XCTAssertEqual(grouped.map(\.name), ["Lab Alpha"])
|
||||
let sorted = try await repository.list(InstanceQuery())
|
||||
XCTAssertEqual(sorted.map(\.name), ["Home Beta", "Lab Alpha"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class InstanceSessionServiceTests: XCTestCase {
|
||||
func testLoginStoresAndClearsSessionCookie() async throws {
|
||||
let store = InstanceSessionStore()
|
||||
let client = MockSessionClient(status: 204)
|
||||
let service = InstanceSessionService(store: store, client: client)
|
||||
let instance = Self.instance()
|
||||
|
||||
let result = try await service.login(instance, password: "secret")
|
||||
XCTAssertTrue(result.authenticated)
|
||||
let cookie = await store.cookie(for: instance.id, origin: instance.origin)
|
||||
XCTAssertEqual(cookie, "simadmin_session=token")
|
||||
|
||||
let rejected = try await service.login(instance, password: "bad")
|
||||
XCTAssertFalse(rejected.authenticated)
|
||||
let removed = await store.cookie(for: instance.id, origin: instance.origin)
|
||||
XCTAssertEqual(removed, nil)
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
name: "Session",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
configRevision: 1,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private struct MockSessionClient: SimAdminClientProtocol {
|
||||
let status: Int
|
||||
|
||||
func send(
|
||||
_ route: UpstreamRoute,
|
||||
baseURL: URL,
|
||||
cookie: String?
|
||||
) async throws -> UpstreamResponse {
|
||||
guard case let .authLogin(submitted) = route else {
|
||||
return UpstreamResponse(status: 200, data: Data("{}.utf8".utf8), setCookie: nil)
|
||||
}
|
||||
let authorized = submitted == "secret"
|
||||
let setCookie = authorized ? "simadmin_session=token; Path=/" : nil
|
||||
return UpstreamResponse(status: authorized ? status : 200, data: Data("{}".utf8), setCookie: setCookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class UpstreamRouteTests: XCTestCase {
|
||||
func testKnownRoutesUseExactPathsMethodsAndPayloads() throws {
|
||||
XCTAssertEqual(UpstreamRoute.health.method, "GET")
|
||||
XCTAssertEqual(UpstreamRoute.health.path, "/api/health")
|
||||
XCTAssertEqual(UpstreamRoute.device.path, "/api/device")
|
||||
XCTAssertEqual(UpstreamRoute.sim.path, "/api/sim")
|
||||
XCTAssertEqual(UpstreamRoute.network.path, "/api/network")
|
||||
XCTAssertEqual(UpstreamRoute.signalStrength.path, "/api/network/signal-strength")
|
||||
XCTAssertEqual(UpstreamRoute.smsList(limit: 100, offset: 0, direction: nil).path,
|
||||
"/api/sms/list?limit=100&offset=0")
|
||||
XCTAssertEqual(UpstreamRoute.smsList(limit: 20, offset: 2, direction: "outgoing").path,
|
||||
"/api/sms/list?limit=20&offset=2&direction=outgoing")
|
||||
|
||||
let login = UpstreamRoute.authLogin(password: "secret")
|
||||
XCTAssertEqual(login.method, "POST")
|
||||
XCTAssertEqual(login.path, "/api/auth/login")
|
||||
let loginObject = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(login.body)) as? [String: String])
|
||||
XCTAssertEqual(loginObject, ["password": "secret"])
|
||||
|
||||
let delete = UpstreamRoute.smsBatchDelete(ids: [2, 1])
|
||||
XCTAssertEqual(delete.method, "POST")
|
||||
XCTAssertEqual(delete.path, "/api/sms/batch-delete")
|
||||
let deleteObject = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: XCTUnwrap(delete.body)) as? [String: [Int]]
|
||||
)
|
||||
XCTAssertEqual(deleteObject, ["ids": [2, 1]])
|
||||
}
|
||||
|
||||
func testSessionStoreIsIsolatedByInstanceAndOrigin() async {
|
||||
let store = InstanceSessionStore()
|
||||
let origin = URL(string: "http://192.168.1.10:3000")!
|
||||
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, origin: origin, cookie: "a")
|
||||
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, origin: origin, cookie: "b")
|
||||
|
||||
let sameID = URL(string: "https://other.example")!
|
||||
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, origin: sameID, cookie: "c")
|
||||
await store.clear(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!)
|
||||
|
||||
let first = await store.cookie(
|
||||
for: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
|
||||
origin: origin
|
||||
)
|
||||
let second = await store.cookie(
|
||||
for: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!,
|
||||
origin: origin
|
||||
)
|
||||
XCTAssertEqual(first, nil)
|
||||
XCTAssertEqual(second, "b")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user