Files
Gas-Tank-AR-App/GasTankARTests/LoginTests.swift

138 lines
4.2 KiB
Swift

import Foundation
import Testing
@testable import GasTankAR
@MainActor
struct LoginTests {
@Test(arguments: ["", " ", "AB", " AB\n"])
func invalidCodeDoesNotReachService(_ code: String) async {
let service = RecordingAuthenticationService()
let useCase = DefaultLoginUseCase(authenticationService: service)
await #expect(throws: LoginError.invalidSalesCode) {
try await useCase.execute(salesCode: code)
}
#expect(service.receivedCodes.isEmpty)
}
@Test
func normalizesCodeBeforeAuthentication() async throws {
let service = RecordingAuthenticationService()
let useCase = DefaultLoginUseCase(authenticationService: service)
let session = try await useCase.execute(salesCode: " mkr\n")
#expect(service.receivedCodes == ["MKR"])
#expect(session.salesCode == "MKR")
}
@Test
func serviceFailurePropagates() async {
let useCase = DefaultLoginUseCase(
authenticationService: StubAuthenticationService(simulatedError: .rejected, delay: .zero)
)
await #expect(throws: LoginError.rejected) {
try await useCase.execute(salesCode: "MKR")
}
}
@Test
func successfulLoginReturnsSessionAndResetsLoading() async {
let model = LoginViewModel(loginUseCase: StubLoginUseCase(error: nil))
model.salesCode = "MKR"
let session = await model.signIn()
#expect(session == LoginSession(salesCode: "MKR"))
#expect(!model.isLoading)
#expect(model.errorMessage == nil)
}
@Test
func failureIsDisplayedAndEditingClearsIt() async {
let model = LoginViewModel(loginUseCase: StubLoginUseCase(error: .rejected))
model.salesCode = "MKR"
let session = await model.signIn()
#expect(session == nil)
#expect(model.errorMessage != nil)
#expect(!model.isLoading)
model.salesCode = "ABC"
#expect(model.errorMessage == nil)
#expect(model.canSubmit)
}
@Test
func concurrentSubmissionIsIgnored() async {
let useCase = SuspendedLoginUseCase()
let model = LoginViewModel(loginUseCase: useCase)
model.salesCode = "MKR"
let first = Task { await model.signIn() }
// Wait for a deterministic suspension point instead of a timed delay.
while useCase.continuation == nil {
await Task.yield()
}
#expect(model.isLoading)
#expect(!model.canSubmit)
let duplicate = await model.signIn()
#expect(duplicate == nil)
#expect(useCase.callCount == 1)
useCase.continuation?.resume(returning: LoginSession(salesCode: "MKR"))
let result = await first.value
#expect(result != nil)
#expect(!model.isLoading)
}
@Test
func cancelledLoginDoesNotReturnSessionOrShowError() async {
let useCase = SuspendedLoginUseCase()
let model = LoginViewModel(loginUseCase: useCase)
model.salesCode = "MKR"
let task = Task { await model.signIn() }
while useCase.continuation == nil {
await Task.yield()
}
task.cancel()
useCase.continuation?.resume(returning: LoginSession(salesCode: "MKR"))
let result = await task.value
#expect(result == nil)
#expect(model.errorMessage == nil)
#expect(!model.isLoading)
}
}
@MainActor
private final class RecordingAuthenticationService: AuthenticationService {
private(set) var receivedCodes: [String] = []
func signIn(salesCode: String) async throws -> LoginSession {
receivedCodes.append(salesCode)
return LoginSession(salesCode: salesCode)
}
}
private struct StubLoginUseCase: LoginUseCase {
let error: LoginError?
func execute(salesCode: String) async throws -> LoginSession {
if let error { throw error }
return LoginSession(salesCode: salesCode)
}
}
@MainActor
private final class SuspendedLoginUseCase: LoginUseCase {
var continuation: CheckedContinuation<LoginSession, Never>?
private(set) var callCount = 0
func execute(salesCode: String) async throws -> LoginSession {
callCount += 1
return await withCheckedContinuation { continuation = $0 }
}
}