From 8b724ac7b0dd71b0c02b0ae200bee9e2ec5fbeec Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 13:33:05 +0900 Subject: [PATCH] Harden tests: characterize JSON:API, sessions and keychain; replace double-only tests - Characterization tests for JSONAPIDocument/Resource/Relationship, DataCacheUpdate.loadFrom, where RootPathService differs from Service, unreadable KeychainStore items, and SessionController sign-in/out, permissions, locale sync and privacy/terms confirmation. - Real ShopRepository and AccountPasswordRepository against TestHTTPServer replace the Demo repositories, whose tests only exercised the doubles. - ForgotPassword/ResendConfirmation tests drive the real ViewModels. - Enable the two ShopkeeperTest functions that lacked @Test, and assert the spy fields the ViewModel tests set but never checked. - Make signingOutRemovesCachedResponses deterministic: SessionController takes the URLCache it empties (URLCache.shared by default) and the test uses its own in-memory one. - Test-target lint cleanup: redundant @Suite, isEmpty, failable String(bytes:). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../Sessions/SessionController.swift | 10 +- .../App/InfoPlistTest.swift | 1 - .../App/LocalizationTest.swift | 1 - .../Errors/EntityAdapterErrorTest.swift | 1 - .../Errors/KeychainStoreErrorTest.swift | 1 - .../AccountPasswordRepositoryTest.swift | 69 ++++++ .../Repositories/ShopRepositoryTest.swift | 176 +++++++++++++++ .../Data/ViewModels/TabViewModelTest.swift | 1 - .../DemoAccountPasswordRepository.swift | 51 ----- .../DemoAccountPasswordRepositoryTest.swift | 111 ---------- .../Repositories/DemoItemTagRepository.swift | 118 ---------- .../DemoItemTagRepositoryTest.swift | 114 ---------- .../Repositories/DemoShopRepository.swift | 71 ------ .../Repositories/DemoShopRepositoryTest.swift | 77 ------- .../Extensions/StringExtensionsTest.swift | 7 + .../Models/PaginationMetaTest.swift | 1 - .../Models/ShopkeeperTest.swift | 4 +- .../Networking/APIEndpointTest.swift | 1 - .../Adapters/DataCacheUpdateTest.swift | 123 +++++++++++ .../JSONAPI/JSONAPIDocumentTest.swift | 176 +++++++++++++++ .../Networking/RootPathServicesTest.swift | 72 ++++++ .../Networking/SignUpsServiceTest.swift | 1 - .../Persistence/KeychainStoreTest.swift | 109 +++++++++ .../SessionControllerPermissionsTest.swift | 156 +++++++++++++ .../Sessions/SessionControllerTest.swift | 209 +++++++++++++++++- .../Repositories/TestLoginRepository.swift | 6 + .../Testing/TestHTTPServer.swift | 4 +- .../Testing/WaitUntilTest.swift | 1 - .../App Root/AcceptPrivacyViewModelTest.swift | 1 - .../App Root/AcceptTermsViewModelTest.swift | 1 - .../ForgotPasswordViewModelTest.swift | 127 ++++------- .../UI/App Root/MainContentTest.swift | 1 - .../UI/App Root/MainViewModelTest.swift | 1 - ...onfirmationInstructionsViewModelTest.swift | 148 ++++--------- .../SignInEmailAndPasswordViewModelTest.swift | 5 +- .../UI/App Root/SignUpViewModelTest.swift | 11 +- .../Settings/PasswordEditViewModelTest.swift | 23 +- .../UI/Settings/SettingsViewModelTest.swift | 1 - .../ShopkeeperEditViewModelTest.swift | 12 +- .../Shop Detail/ShopDetailViewModelTest.swift | 1 - .../Shop List/ShopCreateViewModelTest.swift | 5 +- .../UI/Shop List/ShopListViewModelTest.swift | 1 - .../ItemTagDetailViewModelTest.swift | 3 +- .../ItemTagEditViewModelTest.swift | 5 +- .../ItemTagCreateViewModelTest.swift | 7 +- .../ItemTagListViewModelTest.swift | 1 - .../ShopBasicSettingsViewModelTest.swift | 3 +- .../ShopSettingsViewModelTest.swift | 1 - .../Utilities/AppErrorTest.swift | 1 - .../Utilities/CodedErrorTest.swift | 1 - .../Utilities/MessageBusTest.swift | 1 - .../NativeAppTemplateAPIErrorTest.swift | 1 - 52 files changed, 1231 insertions(+), 803 deletions(-) create mode 100644 NativeAppTemplateTests/Data/Repositories/AccountPasswordRepositoryTest.swift create mode 100644 NativeAppTemplateTests/Data/Repositories/ShopRepositoryTest.swift delete mode 100644 NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepository.swift delete mode 100644 NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepositoryTest.swift delete mode 100644 NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepository.swift delete mode 100644 NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepositoryTest.swift delete mode 100644 NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepository.swift delete mode 100644 NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepositoryTest.swift create mode 100644 NativeAppTemplateTests/Networking/Adapters/DataCacheUpdateTest.swift create mode 100644 NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift diff --git a/NativeAppTemplate/Sessions/SessionController.swift b/NativeAppTemplate/Sessions/SessionController.swift index c3ae4a3..4d6dd7c 100644 --- a/NativeAppTemplate/Sessions/SessionController.swift +++ b/NativeAppTemplate/Sessions/SessionController.swift @@ -47,7 +47,7 @@ import Observation shopLimitCount = 0 // URLSession.api keeps no cache, but versions before it used // URLCache.shared, which holds their API responses on disk - URLCache.shared.removeAllCachedResponses() + urlCache.removeAllCachedResponses() } } } @@ -60,6 +60,8 @@ import Observation private(set) var permissionsService: PermissionsService private(set) var meService: MeService private let notificationCenter: NotificationCenter + /// Emptied on sign-out; URLCache.shared outside tests + let urlCache: URLCache /// Removed in deinit, which is nonisolated @ObservationIgnored private nonisolated(unsafe) var sessionExpiryObserver: (any NSObjectProtocol)? @@ -81,15 +83,19 @@ import Observation /// `connectionChanged(to:)` themselves, so no permission requests go out on their own. /// - environment: The API the client talks to. Tests point it at a local server. /// - notificationCenter: Where `Service` announces `.apiSessionDidExpire` + /// - urlCache: The cache emptied on sign-out. Tests pass their own, so another + /// test's sign-out can't empty it meanwhile. init( loginRepository: LoginRepositoryProtocol, monitorsConnection: Bool = true, environment: NativeAppTemplateEnvironment = .prod, - notificationCenter: NotificationCenter = .default + notificationCenter: NotificationCenter = .default, + urlCache: URLCache = .shared ) { self.loginRepository = loginRepository self.environment = environment self.notificationCenter = notificationCenter + self.urlCache = urlCache let shopkeeper = loginRepository.currentShopkeeper let client = Self.makeClient(shopkeeper: shopkeeper, environment: environment) diff --git a/NativeAppTemplateTests/App/InfoPlistTest.swift b/NativeAppTemplateTests/App/InfoPlistTest.swift index 090b154..4970d51 100644 --- a/NativeAppTemplateTests/App/InfoPlistTest.swift +++ b/NativeAppTemplateTests/App/InfoPlistTest.swift @@ -9,7 +9,6 @@ import Testing import UIKit /// Checks the built app's Info.plist (Bundle.main is the app the tests are hosted in) -@Suite struct InfoPlistTest { /// Without NSLocalNetworkUsageDescription, iOS denied the app local network /// access to a development API server on the LAN (NSURLErrorNotConnectedToInternet, diff --git a/NativeAppTemplateTests/App/LocalizationTest.swift b/NativeAppTemplateTests/App/LocalizationTest.swift index 34e5fc4..aeda173 100644 --- a/NativeAppTemplateTests/App/LocalizationTest.swift +++ b/NativeAppTemplateTests/App/LocalizationTest.swift @@ -10,7 +10,6 @@ import Testing /// The app ships in English and Japanese. Every string catalog key must have a /// Japanese translation, so a string added without one fails here instead of /// showing up in English on a Japanese device. -@Suite struct LocalizationTest { /// The catalog tests read the .xcstrings sources from the Mac through #filePath, /// which a simulator can do and a device cannot. They run on the simulator (and CI); diff --git a/NativeAppTemplateTests/Common/Errors/EntityAdapterErrorTest.swift b/NativeAppTemplateTests/Common/Errors/EntityAdapterErrorTest.swift index 2c58e49..bf72f4e 100644 --- a/NativeAppTemplateTests/Common/Errors/EntityAdapterErrorTest.swift +++ b/NativeAppTemplateTests/Common/Errors/EntityAdapterErrorTest.swift @@ -6,7 +6,6 @@ @testable import NativeAppTemplate import Testing -@Suite struct EntityAdapterErrorTest { @Test func errorCodes() { diff --git a/NativeAppTemplateTests/Common/Errors/KeychainStoreErrorTest.swift b/NativeAppTemplateTests/Common/Errors/KeychainStoreErrorTest.swift index 0a974e4..a798564 100644 --- a/NativeAppTemplateTests/Common/Errors/KeychainStoreErrorTest.swift +++ b/NativeAppTemplateTests/Common/Errors/KeychainStoreErrorTest.swift @@ -7,7 +7,6 @@ import Foundation import Testing -@Suite struct KeychainStoreErrorTest { @Test func errorCodes() { diff --git a/NativeAppTemplateTests/Data/Repositories/AccountPasswordRepositoryTest.swift b/NativeAppTemplateTests/Data/Repositories/AccountPasswordRepositoryTest.swift new file mode 100644 index 0000000..5e46faa --- /dev/null +++ b/NativeAppTemplateTests/Data/Repositories/AccountPasswordRepositoryTest.swift @@ -0,0 +1,69 @@ +// +// AccountPasswordRepositoryTest.swift +// NativeAppTemplate +// + +import Foundation +@testable import NativeAppTemplate +import Testing + +/// AccountPasswordRepository with the real AccountPasswordService and +/// UpdateAccountPasswordRequest, against a local server +@MainActor +@Suite(.serialized) +struct AccountPasswordRepositoryTest { + private let updatePassword = UpdatePassword( + currentPassword: "current-password", + password: "new-password", + passwordConfirmation: "new-password" + ) + + private func makeRepository(_ server: TestHTTPServer) -> AccountPasswordRepository { + AccountPasswordRepository( + accountPasswordService: AccountPasswordService(networkClient: server.client(accountId: "acc-1")) + ) + } + + /// UpdateAccountPasswordRequest: PATCH /shopkeeper/account/password under Service's + /// /{accountId}/api/v1, with UpdatePassword.toJson's body + @Test + func updatePatchesThePasswordWithTheSession() async throws { + let server = try await TestHTTPServer.start { _ in TestHTTPServer.json([String: Any]()) } + defer { server.stop() } + + try await makeRepository(server).update(updatePassword: updatePassword) + + let request = try #require(server.requests.first) + #expect(server.requests.count == 1) + #expect(request.method == "PATCH") + #expect(request.path == "/acc-1/api/v1/shopkeeper/account/password") + // The value TestHTTPServer.client sets + #expect(request.headers["access-token"] == "token") + let body = try #require(try JSONSerialization.jsonObject(with: request.body) as? [String: [String: String]]) + #expect(body == ["shopkeeper": [ + "current_password": "current-password", + "password": "new-password", + "password_confirmation": "new-password" + ]]) + } + + /// The API's error reaches the caller (PasswordEditViewModel shows it) + @Test + func failedUpdateThrowsTheAPIError() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(status: 422, ["error_message": "Current password is invalid"]) + } + defer { server.stop() } + + let error = try await requestFailed { + try await makeRepository(server).update(updatePassword: updatePassword) + } + + guard case let .requestFailed(_, statusCode, message) = error else { + Issue.record("Expected requestFailed, got \(error)") + return + } + #expect(statusCode == 422) + #expect(message == "Current password is invalid") + } +} diff --git a/NativeAppTemplateTests/Data/Repositories/ShopRepositoryTest.swift b/NativeAppTemplateTests/Data/Repositories/ShopRepositoryTest.swift new file mode 100644 index 0000000..8d3f717 --- /dev/null +++ b/NativeAppTemplateTests/Data/Repositories/ShopRepositoryTest.swift @@ -0,0 +1,176 @@ +// +// ShopRepositoryTest.swift +// NativeAppTemplate +// + +import Foundation +@testable import NativeAppTemplate +import Testing + +/// ShopRepository with the real ShopsService and requests, against a local server. +/// Bodies follow ShopSerializer (name, description, time_zone, item_tags_count, +/// completed_item_tags_count) and ShopsController#index's meta (limit_count, created_shops_count). +@MainActor +@Suite(.serialized) +struct ShopRepositoryTest { + private nonisolated static func shop(id: String, name: String = "Shop") -> [String: Any] { + [ + "id": id, + "type": "shop", + "attributes": [ + "name": name, + "description": "", + "time_zone": "Tokyo", + "item_tags_count": 3, + "completed_item_tags_count": 1 + ] as [String: Any] + ] + } + + private func makeRepository(_ server: TestHTTPServer) -> ShopRepository { + ShopRepository(shopsService: ShopsService(networkClient: server.client(accountId: "acc-1"))) + } + + @Test + func reloadLoadsTheShopsAndTheLimits() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json([ + "data": [Self.shop(id: "s1", name: "First"), Self.shop(id: "s2", name: "Second")], + "meta": ["limit_count": 5, "created_shops_count": 2] + ]) + } + defer { server.stop() } + let repository = makeRepository(server) + + repository.reload() + await waitUntil { repository.state != .loading } + + #expect(repository.state == .hasData) + #expect(repository.shops.map(\.name) == ["First", "Second"]) + #expect(repository.shops.first?.itemTagsCount == 3) + #expect(repository.shops.first?.completedItemTagsCount == 1) + #expect(repository.limitCount == 5) + #expect(repository.createdShopsCount == 2) + #expect(server.requests.first?.method == "GET") + #expect(server.requests.first?.path == "/acc-1/api/v1/shopkeeper/shops") + } + + /// GetShopsRequest requires both meta fields; without them the whole reload fails + @Test + func reloadWithoutTheMetaFails() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(["data": [Self.shop(id: "s1")], "meta": ["limit_count": 5]]) + } + defer { server.stop() } + let repository = makeRepository(server) + + repository.reload() + await waitUntil { repository.state != .loading } + + #expect(repository.state == .failed) + #expect(repository.shops.isEmpty) + } + + /// fetchDetail upserts: appends a shop it does not have, replaces one it has + @Test + func fetchDetailAppendsOrReplaces() async throws { + let server = try await TestHTTPServer.start { request in + request.path.hasSuffix("/s1") + ? TestHTTPServer.json(["data": Self.shop(id: "s1", name: "Renamed")]) + : TestHTTPServer.json(["data": Self.shop(id: "s9", name: "New")]) + } + defer { server.stop() } + let repository = makeRepository(server) + repository.shops = [Shop(id: "s1", name: "Old", description: "", timeZone: "Tokyo")] + + _ = try await repository.fetchDetail(id: "s1") + _ = try await repository.fetchDetail(id: "s9") + + #expect(repository.shops.map(\.id) == ["s1", "s9"]) + #expect(repository.shops.map(\.name) == ["Renamed", "New"]) + } + + /// create POSTs Shop.toJsonForCreate and returns the created shop, but does not + /// add it to `shops` (ShopListView reloads after creating) + @Test + func createSendsTheShopAndLeavesTheListAlone() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(status: 201, ["data": Self.shop(id: "s2", name: "Created")]) + } + defer { server.stop() } + let repository = makeRepository(server) + repository.shops = [Shop(id: "s1", name: "Existing", description: "", timeZone: "Tokyo")] + + let created = try await repository.create( + shop: Shop(id: "", name: "Created", description: "Desc", timeZone: "Osaka") + ) + + #expect(created.id == "s2") + #expect(repository.shops.map(\.id) == ["s1"]) + let request = try #require(server.requests.first) + #expect(request.method == "POST") + #expect(request.path == "/acc-1/api/v1/shopkeeper/shops") + let body = try #require(try JSONSerialization.jsonObject(with: request.body) as? [String: [String: String]]) + #expect(body == ["shop": ["name": "Created", "description": "Desc", "time_zone": "Osaka"]]) + } + + /// update replaces the shop in `shops` when it is there + @Test + func updateReplacesTheListedShop() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(["data": Self.shop(id: "s1", name: "Updated")]) + } + defer { server.stop() } + let repository = makeRepository(server) + repository.shops = [Shop(id: "s1", name: "Old", description: "", timeZone: "Tokyo")] + + _ = try await repository.update( + id: "s1", + shop: Shop(id: "s1", name: "Updated", description: "", timeZone: "Tokyo") + ) + + #expect(repository.shops.map(\.name) == ["Updated"]) + #expect(server.requests.first?.method == "PATCH") + #expect(server.requests.first?.path == "/acc-1/api/v1/shopkeeper/shops/s1") + } + + /// destroy sends DELETE but keeps `shops` as is (the list reloads afterwards) + @Test + func destroyLeavesTheListAlone() async throws { + let server = try await TestHTTPServer.start { _ in TestHTTPServer.response(status: 204) } + defer { server.stop() } + let repository = makeRepository(server) + repository.shops = [Shop(id: "s1", name: "Shop", description: "", timeZone: "Tokyo")] + + try await repository.destroy(id: "s1") + + #expect(server.requests.first?.method == "DELETE") + #expect(server.requests.first?.path == "/acc-1/api/v1/shopkeeper/shops/s1") + #expect(repository.shops.map(\.id) == ["s1"]) + } + + @Test + func failedUpdateThrowsAndKeepsTheShop() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(status: 422, ["error_message": "Name can't be blank"]) + } + defer { server.stop() } + let repository = makeRepository(server) + repository.shops = [Shop(id: "s1", name: "Old", description: "", timeZone: "Tokyo")] + + let error = try await requestFailed { + _ = try await repository.update( + id: "s1", + shop: Shop(id: "s1", name: "", description: "", timeZone: "Tokyo") + ) + } + + guard case let .requestFailed(_, statusCode, message) = error else { + Issue.record("Expected requestFailed, got \(error)") + return + } + #expect(statusCode == 422) + #expect(message == "Name can't be blank") + #expect(repository.shops.map(\.name) == ["Old"]) + } +} diff --git a/NativeAppTemplateTests/Data/ViewModels/TabViewModelTest.swift b/NativeAppTemplateTests/Data/ViewModels/TabViewModelTest.swift index 995f1b6..b22b328 100644 --- a/NativeAppTemplateTests/Data/ViewModels/TabViewModelTest.swift +++ b/NativeAppTemplateTests/Data/ViewModels/TabViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct TabViewModelTest { @Test func initialState() { diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepository.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepository.swift deleted file mode 100644 index 4af48a1..0000000 --- a/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepository.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// DemoAccountPasswordRepository.swift -// NativeAppTemplate -// - -import Foundation -@testable import NativeAppTemplate - -@MainActor -final class DemoAccountPasswordRepository: AccountPasswordRepositoryProtocol { - var lastUpdatePassword: UpdatePassword? - var shouldThrowError = false - var errorMessage = "Invalid current password" - - required init(accountPasswordService: AccountPasswordService) {} - - func update(updatePassword: UpdatePassword) async throws { - lastUpdatePassword = updatePassword - - if shouldThrowError { - throw NativeAppTemplateAPIError.requestFailed(nil, 422, errorMessage) - } - - // Simulate validation - if updatePassword.currentPassword.isEmpty { - throw NativeAppTemplateAPIError.requestFailed(nil, 422, "Current password is required") - } - - if updatePassword.password.isEmpty { - throw NativeAppTemplateAPIError.requestFailed(nil, 422, "New password is required") - } - - if updatePassword.password != updatePassword.passwordConfirmation { - throw NativeAppTemplateAPIError.requestFailed(nil, 422, "Password confirmation does not match") - } - - if updatePassword.password.count < 8 { - throw NativeAppTemplateAPIError.requestFailed(nil, 422, "Password must be at least 8 characters long") - } - - // Success case - password updated - } - - // MARK: - Test Helpers - - func resetState() { - lastUpdatePassword = nil - shouldThrowError = false - errorMessage = "Invalid current password" - } -} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepositoryTest.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepositoryTest.swift deleted file mode 100644 index 6f91b7b..0000000 --- a/NativeAppTemplateTests/Demo/Data/Repositories/DemoAccountPasswordRepositoryTest.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// DemoAccountPasswordRepositoryTest.swift -// NativeAppTemplate -// - -@testable import NativeAppTemplate -import Testing - -@Suite -struct DemoAccountPasswordRepositoryTest { - @MainActor - struct Tests { - let repository = DemoAccountPasswordRepository(accountPasswordService: AccountPasswordService()) - - @Test - func updatePasswordSuccess() async throws { - repository.resetState() - - let updatePassword = UpdatePassword( - currentPassword: "currentPassword123", - password: "newPassword123", - passwordConfirmation: "newPassword123" - ) - - await #expect(throws: Never.self) { - try await repository.update(updatePassword: updatePassword) - } - - #expect(repository.lastUpdatePassword?.currentPassword == "currentPassword123") - #expect(repository.lastUpdatePassword?.password == "newPassword123") - #expect(repository.lastUpdatePassword?.passwordConfirmation == "newPassword123") - } - - @Test - func updatePasswordWithEmptyCurrentPassword() async throws { - repository.resetState() - - let updatePassword = UpdatePassword( - currentPassword: "", - password: "newPassword123", - passwordConfirmation: "newPassword123" - ) - - await #expect(throws: NativeAppTemplateAPIError.self) { - try await repository.update(updatePassword: updatePassword) - } - } - - @Test - func updatePasswordWithEmptyNewPassword() async throws { - repository.resetState() - - let updatePassword = UpdatePassword( - currentPassword: "currentPassword123", - password: "", - passwordConfirmation: "" - ) - - await #expect(throws: NativeAppTemplateAPIError.self) { - try await repository.update(updatePassword: updatePassword) - } - } - - @Test - func updatePasswordWithMismatchedConfirmation() async throws { - repository.resetState() - - let updatePassword = UpdatePassword( - currentPassword: "currentPassword123", - password: "newPassword123", - passwordConfirmation: "differentPassword123" - ) - - await #expect(throws: NativeAppTemplateAPIError.self) { - try await repository.update(updatePassword: updatePassword) - } - } - - @Test - func updatePasswordWithShortPassword() async throws { - repository.resetState() - - let updatePassword = UpdatePassword( - currentPassword: "currentPassword123", - password: "short", - passwordConfirmation: "short" - ) - - await #expect(throws: NativeAppTemplateAPIError.self) { - try await repository.update(updatePassword: updatePassword) - } - } - - @Test - func updatePasswordWithForcedError() async throws { - repository.resetState() - repository.shouldThrowError = true - repository.errorMessage = "Custom error message" - - let updatePassword = UpdatePassword( - currentPassword: "currentPassword123", - password: "newPassword123", - passwordConfirmation: "newPassword123" - ) - - await #expect(throws: NativeAppTemplateAPIError.self) { - try await repository.update(updatePassword: updatePassword) - } - } - } -} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepository.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepository.swift deleted file mode 100644 index d4e357a..0000000 --- a/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepository.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// DemoItemTagRepository.swift -// NativeAppTemplate -// - -import Foundation -@testable import NativeAppTemplate -import Testing - -@MainActor -final class DemoItemTagRepository: ItemTagRepositoryProtocol { - var itemTags: [ItemTag] = [] - var state: DataState = .initial - var paginationMeta: PaginationMeta? - var isLoadingMore = false - - required init(itemTagsService: ItemTagsService) {} - - func findBy(id: String) -> ItemTag { - itemTags.first { $0.id == id }! - } - - func reload(shopId: String) { - state = .loading - - let allItemTags = fetchAll() - itemTags = allItemTags.filter { $0.shopId == shopId } - - state = .hasData - } - - func reloadPage(shopId: String, page: Int) { - reload(shopId: shopId) - } - - func loadNextPage(shopId: String) { - // No-op for demo - } - - func fetchAll(shopId: String) async throws -> [ItemTag] { - let allItemTags = fetchAll() - return allItemTags.filter { $0.shopId == shopId } - } - - func fetchDetail(id: String) async throws -> ItemTag { - itemTags.first { $0.id == id }! - } - - func create(shopId: String, itemTag: ItemTag) async throws -> ItemTag { - itemTags.append(itemTag) - return itemTag - } - - func update(id: String, itemTag: ItemTag) async throws -> ItemTag { - let index = itemTags.firstIndex { $0.id == id }! - itemTags[index] = itemTag - - return itemTag - } - - func destroy(id: String) async throws { - itemTags.removeAll { $0.id == id } - } - - func complete(id: String) async throws -> ItemTag { - var itemTag = itemTags.first { $0.id == id }! - itemTag.state = .completed - itemTag.completedAt = .now - - let index = itemTags.firstIndex { $0.id == id }! - itemTags[index] = itemTag - - return itemTag - } - - func idle(id: String) async throws -> ItemTag { - var itemTag = itemTags.first { $0.id == id }! - itemTag.state = .idled - itemTag.completedAt = nil - - let index = itemTags.firstIndex { $0.id == id }! - itemTags[index] = itemTag - - return itemTag - } - - private func fetchAll() -> [ItemTag] { - [ - mockItemTag(id: "1", shopId: "1", name: "A001"), - mockItemTag(id: "2", shopId: "1", name: "A002"), - mockItemTag(id: "3", shopId: "1", name: "A003"), - mockItemTag(id: "4", shopId: "2", name: "A001"), - mockItemTag(id: "5", shopId: "2", name: "A002"), - mockItemTag(id: "6", shopId: "2", name: "A003"), - mockItemTag(id: "7", shopId: "2", name: "A004") - ] - } - - // MARK: - Helpers - - private func mockItemTag( - id: String = UUID().uuidString, - shopId: String = UUID().uuidString, - name: String = "Mock ItemTag" - ) -> ItemTag { - ItemTag( - id: id, - shopId: shopId, - name: name, - description: "", - position: 1, - state: .idled, - createdAt: .now, - completedAt: nil, - shopName: "Mock ItemTag" - ) - } -} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepositoryTest.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepositoryTest.swift deleted file mode 100644 index ec21dc8..0000000 --- a/NativeAppTemplateTests/Demo/Data/Repositories/DemoItemTagRepositoryTest.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// DemoItemTagRepositoryTest.swift -// NativeAppTemplate -// - -@testable import NativeAppTemplate -import Testing - -@Suite -struct DemoItemTagRepositoryTest { - @MainActor - struct Tests { - let repository = DemoItemTagRepository(itemTagsService: ItemTagsService()) - - @Test - func findBy() { - repository.reload(shopId: "1") - - let itemTags = repository.findBy(id: "1") - #expect(itemTags.name == "A001") - } - - @Test - func reload() { - repository.reload(shopId: "1") - - #expect(repository.itemTags.count == 3) - #expect(repository.state == .hasData) - } - - @Test - func fetchAll() async throws { - let itemTags = try await repository.fetchAll(shopId: "1") - - #expect(itemTags.count == 3) - } - - @Test - func fetchDetail() async throws { - repository.reload(shopId: "1") - - let itemTag = try await repository.fetchDetail(id: "1") - #expect(itemTag.name == "A001") - } - - @Test - func create() async throws { - let shopId = "1" - repository.reload(shopId: shopId) - - let newName = "A099" - let newItemTag = ItemTag( - shopId: shopId, - name: newName, - description: "", - position: 1, - state: .idled, - createdAt: .now, - completedAt: nil, - shopName: "Mock ItemTag" - ) - - let createdItemTag = try await repository.create(shopId: shopId, itemTag: newItemTag) - #expect(createdItemTag.name == newName) - #expect(repository.itemTags.count == 4) - } - - @Test - func update() async throws { - repository.reload(shopId: "1") - - var itemTag = repository.findBy(id: "1") - let newName = "B001" - itemTag.name = newName - let updatedItemTag = try await repository.update(id: "1", itemTag: itemTag) - #expect(updatedItemTag.name == newName) - } - - @Test - func destroy() async throws { - repository.reload(shopId: "1") - - try await repository.destroy(id: "1") - #expect(!repository.itemTags.contains { $0.id == "1" }) - } - - @Test - func complete() async throws { - repository.reload(shopId: "1") - - var itemTag = repository.findBy(id: "1") - itemTag.completedAt = nil - _ = try await repository.update(id: "1", itemTag: itemTag) - - let completedItemTag = try await repository.complete(id: "1") - #expect(completedItemTag.state == ItemTagState.completed) - #expect(completedItemTag.completedAt != nil) - } - - @Test - func idle() async throws { - repository.reload(shopId: "1") - - var itemTag = repository.findBy(id: "1") - itemTag.state = .completed - itemTag.completedAt = .now - _ = try await repository.update(id: "1", itemTag: itemTag) - - let idledItemTag = try await repository.idle(id: "1") - #expect(idledItemTag.state == .idled) - #expect(idledItemTag.completedAt == nil) - } - } -} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepository.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepository.swift deleted file mode 100644 index 4bb1c76..0000000 --- a/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepository.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// DemoShopRepository.swift -// NativeAppTemplate -// - -import Foundation -@testable import NativeAppTemplate - -@MainActor -final class DemoShopRepository: ShopRepositoryProtocol { - var shops: [Shop] = [] - var state: DataState = .initial - var limitCount: Int = 10 - var createdShopsCount: Int = 0 - var isEmpty: Bool { - shops.isEmpty - } - - required init(shopsService: ShopsService) {} - - func findBy(id: String) -> Shop { - shops.first { $0.id == id }! - } - - func reload() { - state = .loading - shops = [ - mockShop(id: "1", name: "Shop 1"), - mockShop(id: "2", name: "Shop 2"), - mockShop(id: "3", name: "Shop 3"), - mockShop(id: "4", name: "Shop 4"), - mockShop(id: "5", name: "Shop 5") - ] - createdShopsCount = shops.count - state = .hasData - } - - func fetchDetail(id: String) async throws -> Shop { - shops.first { $0.id == id }! - } - - func create(shop: Shop) async throws -> Shop { - shops.append(shop) - createdShopsCount += 1 - return shop - } - - func update(id: String, shop: Shop) async throws -> Shop { - let index = shops.firstIndex { $0.id == id }! - shops[index] = shop - - return shop - } - - func destroy(id: String) async throws { - shops.removeAll { $0.id == id } - } - - // MARK: - Helpers - - private func mockShop(id: String = UUID().uuidString, name: String = "Mock Shop") -> Shop { - Shop( - id: id, - name: name, - description: "This is a mock shop for testing", - timeZone: "Tokyo", - itemTagsCount: 10, - completedItemTagsCount: 3 - ) - } -} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepositoryTest.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepositoryTest.swift deleted file mode 100644 index 85563ad..0000000 --- a/NativeAppTemplateTests/Demo/Data/Repositories/DemoShopRepositoryTest.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// DemoShopRepositoryTest.swift -// NativeAppTemplate -// - -@testable import NativeAppTemplate -import Testing - -@Suite -struct DemoShopRepositoryTest { - @MainActor - struct Tests { - let repository = DemoShopRepository(shopsService: ShopsService()) - - @Test - func findBy() { - repository.reload() - - let shop = repository.findBy(id: "1") - #expect(shop.name == "Shop 1") - } - - @Test - func reload() { - repository.reload() - - #expect(repository.shops.count == 5) - #expect(repository.state == .hasData) - } - - @Test - func fetchDetail() async throws { - repository.reload() - - let shop = try await repository.fetchDetail(id: "1") - #expect(shop.name == "Shop 1") - } - - @Test - func create() async throws { - repository.reload() - - let newName = "New Shop" - let newShop = Shop( - id: "99", - name: newName, - description: "A new shop", - timeZone: "Tokyo", - itemTagsCount: 0, - completedItemTagsCount: 0 - ) - - let createdShop = try await repository.create(shop: newShop) - #expect(createdShop.name == newName) - #expect(repository.shops.count == 6) - } - - @Test - func update() async throws { - repository.reload() - - var shop = repository.findBy(id: "1") - let newName = "New Shop" - shop.name = newName - let updatedShop = try await repository.update(id: "1", shop: shop) - #expect(updatedShop.name == newName) - } - - @Test - func destroy() async throws { - repository.reload() - - try await repository.destroy(id: "1") - #expect(!repository.shops.contains { $0.id == "1" }) - } - } -} diff --git a/NativeAppTemplateTests/Extensions/StringExtensionsTest.swift b/NativeAppTemplateTests/Extensions/StringExtensionsTest.swift index efe3481..1aeafd7 100644 --- a/NativeAppTemplateTests/Extensions/StringExtensionsTest.swift +++ b/NativeAppTemplateTests/Extensions/StringExtensionsTest.swift @@ -28,6 +28,13 @@ struct StringExtensionsTest { ("@domain.com", false), ("user@", false), ("user@.com", false), + // Moved from the ForgotPassword/ResendConfirmation ViewModel tests, which + // checked String directly instead of the ViewModels + ("user+tag@domain.co.uk", true), + ("user@subdomain.domain.org", true), + ("123@domain.com", true), + ("user.domain.com", false), + ("user space@domain.com", false), ]) func isValidEmail(email: String, expected: Bool) { #expect(email.isValidEmail == expected) diff --git a/NativeAppTemplateTests/Models/PaginationMetaTest.swift b/NativeAppTemplateTests/Models/PaginationMetaTest.swift index b51b565..56e264d 100644 --- a/NativeAppTemplateTests/Models/PaginationMetaTest.swift +++ b/NativeAppTemplateTests/Models/PaginationMetaTest.swift @@ -6,7 +6,6 @@ @testable import NativeAppTemplate import Testing -@Suite struct PaginationMetaTest { @Test func initWithValues() { diff --git a/NativeAppTemplateTests/Models/ShopkeeperTest.swift b/NativeAppTemplateTests/Models/ShopkeeperTest.swift index d0fa6a4..b9eff11 100644 --- a/NativeAppTemplateTests/Models/ShopkeeperTest.swift +++ b/NativeAppTemplateTests/Models/ShopkeeperTest.swift @@ -42,7 +42,7 @@ struct ShopkeeperTest { #expect(shopkeeperDictionary["expiry"] == shopkeeper.expiry) } - func shopkeeperDictionaryHasRequiredFields() { + @Test func shopkeeperDictionaryHasRequiredFields() { var invalidDictionary = shopkeeperDictionary invalidDictionary.removeValue(forKey: "id") let shopkeeper = Shopkeeper(dictionary: invalidDictionary) @@ -50,7 +50,7 @@ struct ShopkeeperTest { #expect(shopkeeper == nil) } - func additionalEntriesInTheDictionaryAreIgnored() { + @Test func additionalEntriesInTheDictionaryAreIgnored() { var overSpecifiedDictionary = shopkeeperDictionary overSpecifiedDictionary["extra_field"] = "some-guff" let shopkeeper = Shopkeeper(dictionary: overSpecifiedDictionary) diff --git a/NativeAppTemplateTests/Networking/APIEndpointTest.swift b/NativeAppTemplateTests/Networking/APIEndpointTest.swift index da96770..43ef918 100644 --- a/NativeAppTemplateTests/Networking/APIEndpointTest.swift +++ b/NativeAppTemplateTests/Networking/APIEndpointTest.swift @@ -7,7 +7,6 @@ import Foundation @testable import NativeAppTemplate import Testing -@Suite struct APIEndpointTest { let info: [String: Any] = [ "NativeAppTemplateAPIScheme": "http", diff --git a/NativeAppTemplateTests/Networking/Adapters/DataCacheUpdateTest.swift b/NativeAppTemplateTests/Networking/Adapters/DataCacheUpdateTest.swift new file mode 100644 index 0000000..bb22134 --- /dev/null +++ b/NativeAppTemplateTests/Networking/Adapters/DataCacheUpdateTest.swift @@ -0,0 +1,123 @@ +// +// DataCacheUpdateTest.swift +// NativeAppTemplate +// + +import Foundation +@testable import NativeAppTemplate +import Testing + +/// Characterizes DataCacheUpdate.loadFrom(document:). No request calls it today +/// (every Request adapts `doc.data` itself), so this pins what the layer does +/// before it is simplified or removed. +/// +/// Attributes follow ShopAdapter (name, time_zone) and ShopkeeperAdapter +/// (email, name, time_zone), the fields they require. +struct DataCacheUpdateTest { + private func document(_ json: String) throws -> JSONAPIDocument { + try JSONAPIDocument(JSONSerialization.jsonObject(with: Data(json.utf8))) + } + + private static let shopAttributes = #"{"name": "Shop", "time_zone": "Tokyo"}"# + private static let shopkeeperAttributes = #"{"email": "a@example.com", "name": "Name", "time_zone": "Tokyo"}"# + + /// Shops and shopkeepers go into their own lists, data before included + @Test + func resourcesAreSortedByTypeWithDataFirst() throws { + let doc = try document(#""" + {"data": [{"id": "s1", "type": "shop", "attributes": \#(Self.shopAttributes)}], + "included": [ + {"id": "k1", "type": "shopkeeper", "attributes": \#(Self.shopkeeperAttributes)}, + {"id": "s2", "type": "shop", "attributes": \#(Self.shopAttributes)} + ]} + """#) + + let cacheUpdate = try DataCacheUpdate.loadFrom(document: doc) + + #expect(cacheUpdate.shops.map(\.id) == ["s1", "s2"]) + #expect(cacheUpdate.shopkeepers.map(\.id) == ["k1"]) + #expect(cacheUpdate.shopkeepers.first?.email == "a@example.com") + } + + /// Only shop and shopkeeper are adapted: an item_tag (a known EntityType) and an + /// unknown type are skipped without an error + @Test + func otherTypesAreSkipped() throws { + let doc = try document(#""" + {"data": [ + {"id": "t1", "type": "item_tag", "attributes": {}}, + {"id": "x1", "type": "unknown", "attributes": {}}, + {"id": "s1", "type": "shop", "attributes": \#(Self.shopAttributes)} + ]} + """#) + + let cacheUpdate = try DataCacheUpdate.loadFrom(document: doc) + + #expect(cacheUpdate.shops.map(\.id) == ["s1"]) + #expect(cacheUpdate.shopkeepers.isEmpty) + } + + /// An included resource the adapter rejects fails the whole document + @Test + func invalidIncludedResourceFailsTheDocument() throws { + let doc = try document(#""" + {"data": [{"id": "s1", "type": "shop", "attributes": \#(Self.shopAttributes)}], + "included": [{"id": "k1", "type": "shopkeeper", "attributes": {"name": "No email"}}]} + """#) + + #expect(throws: EntityAdapterError.invalidOrMissingAttributes) { + try DataCacheUpdate.loadFrom(document: doc) + } + } + + /// The data resources' relationships are collected twice: once by the data pass + /// (from the resources themselves) and again by the included pass (passed in as + /// additional relationships). A shop with one shopkeeper and two item tags: + /// 1 + 2 = 3 relationships, collected twice = 6. + /// A relationship whose target has no EntityType (account) is dropped. + @Test + func dataRelationshipsAreCollectedTwice() throws { + let doc = try document(#""" + {"data": {"id": "s1", "type": "shop", "attributes": \#(Self.shopAttributes), "relationships": { + "shopkeeper": {"data": {"id": "k1", "type": "shopkeeper"}}, + "item_tags": {"data": [{"id": "t1", "type": "item_tag"}, {"id": "t2", "type": "item_tag"}]}, + "account": {"data": {"id": "a1", "type": "account"}} + }}} + """#) + + let cacheUpdate = try DataCacheUpdate.loadFrom(document: doc) + + #expect(cacheUpdate.relationships.count == 6) + // Each relationship's name is the key in `relationships`, from the shop + #expect(cacheUpdate.relationships.allSatisfy { $0.from.id == "s1" && $0.from.type == .shop }) + let pairs = cacheUpdate.relationships.map { "\($0.name)->\($0.to.id)" }.sorted() + #expect(pairs == [ + "item_tags->t1", "item_tags->t1", "item_tags->t2", "item_tags->t2", + "shopkeeper->k1", "shopkeeper->k1" + ]) + } + + /// Relationships of a resource without an EntityType are not collected + @Test + func relationshipsOfAnUnknownTypeAreDropped() throws { + let doc = try document(#""" + {"data": {"id": "x1", "type": "unknown", "relationships": { + "shopkeeper": {"data": {"id": "k1", "type": "shopkeeper"}} + }}} + """#) + + let cacheUpdate = try DataCacheUpdate.loadFrom(document: doc) + + #expect(cacheUpdate.relationships.isEmpty) + } + + /// merged(with:) appends the other update's lists after its own + @Test + func mergedAppendsInOrder() { + let first = DataCacheUpdate(shops: [Shop(id: "s1", name: "A", description: "", timeZone: "Tokyo")]) + let second = DataCacheUpdate(shops: [Shop(id: "s2", name: "B", description: "", timeZone: "Tokyo")]) + + #expect(first.merged(with: second).shops.map(\.id) == ["s1", "s2"]) + #expect(second.merged(with: first).shops.map(\.id) == ["s2", "s1"]) + } +} diff --git a/NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift b/NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift new file mode 100644 index 0000000..9e2b2a7 --- /dev/null +++ b/NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift @@ -0,0 +1,176 @@ +// +// JSONAPIDocumentTest.swift +// NativeAppTemplate +// + +import Foundation +@testable import NativeAppTemplate +import Testing + +/// Characterizes how JSONAPIDocument, JSONAPIResource and JSONAPIRelationship read a +/// JSON:API document (https://jsonapi.org/format/#document-structure) as the API's +/// jsonapi-serializer gem writes it: `data` is an object for one resource and an array +/// for a collection, `relationships` map a name to `{ "data": identifier | [identifier] | null }`. +struct JSONAPIDocumentTest { + private func document(_ json: String) throws -> JSONAPIDocument { + try JSONAPIDocument(JSONSerialization.jsonObject(with: Data(json.utf8))) + } + + @Test + func singleResourceDataBecomesAOneElementArray() throws { + let doc = try document(#"{"data": {"id": "1", "type": "shop", "attributes": {"name": "A"}}}"#) + + #expect(doc.data.count == 1) + #expect(doc.data.first?.id == "1") + #expect(doc.data.first?.type == "shop") + #expect(doc.data.first?.attributes["name"] as? String == "A") + #expect(doc.included.isEmpty) + } + + @Test + func collectionDataKeepsItsOrder() throws { + let doc = try document(#"{"data": [{"id": "2", "type": "shop"}, {"id": "1", "type": "shop"}]}"#) + + #expect(doc.data.map(\.id) == ["2", "1"]) + } + + /// `included` is an array in JSON:API; a single object is read as one resource too + @Test + func includedIsReadAsArrayOrObject() throws { + let array = try document(#"{"data": [], "included": [{"id": "k", "type": "shopkeeper"}]}"#) + let object = try document(#"{"data": [], "included": {"id": "k", "type": "shopkeeper"}}"#) + + #expect(array.included.map(\.id) == ["k"]) + #expect(object.included.map(\.id) == ["k"]) + } + + /// The API's GET /shopkeeper/shops meta (GetShopsRequest reads both) + @Test + func metaIsKeptAsIs() throws { + let doc = try document(#"{"data": [], "meta": {"limit_count": 99, "created_shops_count": 3}}"#) + + #expect(doc.meta["limit_count"] as? Int == 99) + #expect(doc.meta["created_shops_count"] as? Int == 3) + } + + /// Only string links that parse as URLs are kept; a links object + /// (`{"href": ...}`, allowed by JSON:API) and null are dropped + @Test + func onlyStringLinksAreKept() throws { + let doc = try document(#""" + {"data": {"id": "1", "type": "shop", "links": {"self": "https://example.com/shops/1", "related": {"href": "https://example.com"}}}, + "links": {"next": "https://example.com/shops?page=2", "prev": null}} + """#) + + let next = try #require(URL(string: "https://example.com/shops?page=2")) + let selfLink = try #require(URL(string: "https://example.com/shops/1")) + #expect(doc.links == ["next": next]) + #expect(doc.data.first?.links == ["self": selfLink]) + } + + /// A resource's own meta is kept + @Test + func resourceMetaIsKept() throws { + let doc = try document(#"{"data": {"id": "1", "type": "shop", "meta": {"position": 2}}}"#) + + #expect(doc.data.first?.meta["position"] as? Int == 2) + } + + /// Resources in `data` and `included` point back to their document (weakly) + @Test + func resourcesKnowTheirDocument() throws { + let doc = try document(#""" + {"data": {"id": "1", "type": "shop"}, "included": [{"id": "k", "type": "shopkeeper"}]} + """#) + + #expect(doc.data.first?.parent === doc) + #expect(doc.included.first?.parent === doc) + } + + /// A missing id or type reads as "" (not the property's initial "0"), which + /// matches no EntityType + @Test + func resourceWithoutIdOrTypeHasEmptyStringsAndNoEntityID() throws { + let doc = try document(#"{"data": {"attributes": {"name": "A"}}}"#) + let resource = try #require(doc.data.first) + + #expect(resource.id.isEmpty) + #expect(resource.type.isEmpty) + #expect(resource.entityType == nil) + #expect(resource.entityID == nil) + } + + /// EntityType(from:) knows the API's snake_case type names (the serializers' + /// set_type: shop, shopkeeper, shopkeeper_sign_in, item_tag) + @Test(arguments: [ + ("shop", EntityType.shop), + ("shopkeeper", .shopkeeper), + ("shopkeeper_sign_in", .shopkeeperSignIn), + ("item_tag", .itemTag) + ]) + func entityTypeFromTypeName(type: String, expected: EntityType) throws { + let doc = try document(#"{"data": {"id": "7", "type": "\#(type)"}}"#) + let entityID = try #require(doc.data.first?.entityID) + + #expect(entityID.type == expected) + #expect(entityID.id == "7") + } + + /// Only those exact names: camelCase, capitalized, or a type EntityType does not + /// list (the paid app's account) has none + @Test(arguments: ["itemTag", "Shop", "account"]) + func otherTypeNamesHaveNoEntityType(type: String) throws { + let doc = try document(#"{"data": {"id": "1", "type": "\#(type)"}}"#) + + #expect(doc.data.first?.entityType == nil) + #expect(doc.data.first?.entityID == nil) + } + + /// To-one, to-many and null relationships + @Test + func toOneToManyAndNullRelationships() throws { + let doc = try document(#""" + {"data": {"id": "1", "type": "shop", "relationships": { + "shopkeeper": {"data": {"id": "k1", "type": "shopkeeper"}}, + "item_tags": {"data": [{"id": "t1", "type": "item_tag"}, {"id": "t2", "type": "item_tag"}]}, + "owner": {"data": null, "meta": {"count": 0}} + }}} + """#) + let relationships = try #require(doc.data.first?.relationships) + // Not uniqueKeysWithValues: a duplicate name would trap and end the test run + let byName = Dictionary(relationships.map { ($0.type, $0) }) { first, _ in first } + + // The relationship's `type` is its name, not the related resource's type + #expect(relationships.count == 3) + #expect(Set(byName.keys) == ["shopkeeper", "item_tags", "owner"]) + #expect(byName["shopkeeper"]?.data.map(\.id) == ["k1"]) + #expect(byName["shopkeeper"]?.data.first?.type == "shopkeeper") + #expect(byName["item_tags"]?.data.map(\.id) == ["t1", "t2"]) + #expect(byName["owner"]?.data.isEmpty == true) + #expect(byName["owner"]?.meta["count"] as? Int == 0) + // Resource identifiers inside a relationship have no document + #expect(byName["shopkeeper"]?.data.first?.parent == nil) + } + + /// A relationship that is not an object is kept with no name and no data + @Test + func nonObjectRelationshipHasNoNameOrData() throws { + let doc = try document(#"{"data": {"id": "1", "type": "shop", "relationships": {"shopkeeper": "k1"}}}"#) + let relationship = try #require(doc.data.first?.relationships.first) + + #expect(doc.data.first?.relationships.count == 1) + #expect(relationship.type.isEmpty) + #expect(relationship.data.isEmpty) + } + + /// Anything but a JSON object (e.g. an array body) is an empty document, not an error + @Test + func nonObjectJsonIsAnEmptyDocument() throws { + let doc = try document(#"[{"id": "1", "type": "shop"}]"#) + + #expect(doc.data.isEmpty) + #expect(doc.included.isEmpty) + #expect(doc.meta.isEmpty) + #expect(doc.links.isEmpty) + } +} diff --git a/NativeAppTemplateTests/Networking/RootPathServicesTest.swift b/NativeAppTemplateTests/Networking/RootPathServicesTest.swift index f2c4d1a..3552bc0 100644 --- a/NativeAppTemplateTests/Networking/RootPathServicesTest.swift +++ b/NativeAppTemplateTests/Networking/RootPathServicesTest.swift @@ -131,4 +131,76 @@ struct RootPathServicesTest { // server.client() is signed in, so the session headers go along #expect(server.requests.first?.headers["access-token"] == "token") } + + // MARK: - Where RootPathService differs from Service (characterization) + + /// A JSON error body without error_message has no message, as in Service + @Test + func errorStatusWithoutErrorMessageHasNoMessage() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(status: 422, ["errors": ["Email has already been taken"]]) + } + defer { server.stop() } + + let error = try await requestFailed { + _ = try await SignUpsService(networkClient: signedOut(server)) + .sendConfirmationInstruction(sendConfirmation: SendConfirmation(email: "a@example.com")) + } + + guard case let .requestFailed(_, statusCode, message) = error else { + Issue.record("Expected requestFailed, got \(error)") + return + } + #expect(statusCode == 422) + #expect(message == nil) + } + + /// Unlike Service, RootPathService does not announce devise_token_auth's 401 + /// (no error_message) as an expired session, even when signed in: e.g. + /// SessionController's locale sync (SignUpsService.updateLocale, PATCH + /// /shopkeeper_auth) with a rejected token does not sign out. + /// The body is devise_token_auth's, as in ServiceTest. + @Test + func rejectedTokenIsNotAnnouncedAsAnExpiredSession() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(status: 401, ["errors": ["You need to sign in or sign up before continuing."]]) + } + defer { server.stop() } + let token = "expired-\(UUID().uuidString)" + + var error: NativeAppTemplateAPIError? + let tokens = await expiredSessionTokens { + error = try? await requestFailed { + _ = try await SignUpsService(networkClient: server.client(authToken: token)).updateLocale("ja") + } + } + + #expect(!tokens.contains(token)) + guard case let .requestFailed(_, statusCode, _) = error else { + Issue.record("Expected requestFailed, got \(String(describing: error))") + return + } + #expect(statusCode == 401) + } + + /// RootPathService.prepare builds the URL from the path alone: a request's + /// query items are dropped (Service adds them). No devise_token_auth request has any. + @Test + func queryItemsAreNotSent() throws { + let baseURL = try #require(URL(string: "http://127.0.0.1:1")) + let client = NativeAppTemplateAPI( + environment: NativeAppTemplate.NativeAppTemplateEnvironment(baseURL: baseURL), + authToken: "", + client: "", + expiry: "", + uid: "", + accountId: "account-id" + ) + + // GetItemTagsRequest.queryItems is [page=2] + let urlRequest = try SessionsService(networkClient: client) + .prepare(request: GetItemTagsRequest(shopId: "shop-1", page: 2)) + + #expect(urlRequest.url?.absoluteString == "http://127.0.0.1:1/shopkeeper/shops/shop-1/item_tags") + } } diff --git a/NativeAppTemplateTests/Networking/SignUpsServiceTest.swift b/NativeAppTemplateTests/Networking/SignUpsServiceTest.swift index abdee33..1945f5d 100644 --- a/NativeAppTemplateTests/Networking/SignUpsServiceTest.swift +++ b/NativeAppTemplateTests/Networking/SignUpsServiceTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct SignUpsServiceTest { let client = NativeAppTemplateAPI( authToken: "token", diff --git a/NativeAppTemplateTests/Persistence/KeychainStoreTest.swift b/NativeAppTemplateTests/Persistence/KeychainStoreTest.swift index 23b2ac5..be9b358 100644 --- a/NativeAppTemplateTests/Persistence/KeychainStoreTest.swift +++ b/NativeAppTemplateTests/Persistence/KeychainStoreTest.swift @@ -15,6 +15,14 @@ private struct TestStringKeychainStore: KeychainStore { typealias DataType = NSString } +/// Its own item, so the app's saved session (LoggedInShopkeeperKeychainStore) is left alone +private struct TestLoggedInShopkeeperKeychainStore: KeychainStore { + var account = "KeychainStoreTestLoggedInShopkeeperAccount" + var service = "com.nativeapptemplate.NativeAppTemplate.KeychainStoreTestService" + + typealias DataType = LoggedInShopkeeper +} + /// Verifies `KeychainStore` reads and writes items exactly as KeychainAccess v4.2.2 did, /// using `KeychainOracle` (raw `SecItem*` calls with KeychainAccess's attributes) as the reference. @Suite(.serialized) @@ -90,4 +98,105 @@ struct KeychainStoreTest { func removeMissingItemDoesNotThrow() throws { try store.remove() } + + // MARK: - Unreadable items (characterization) + + /// The KeychainStoreError case `retrieve()` throws, with an archiveFailure's + /// payload when that is a KeychainStoreError too + private func retrieveError(_ retrieve: () throws -> Any) -> String? { + func name(_ error: KeychainStoreError) -> String { + switch error { + case .secCallFailed: "secCallFailed" + case .notFound: "notFound" + case .badData: "badData" + case let .archiveFailure(underlying as KeychainStoreError): "archiveFailure(\(name(underlying)))" + case .archiveFailure: "archiveFailure" + } + } + + do { + _ = try retrieve() + return nil + } catch let error as KeychainStoreError { + return name(error) + } catch { + return "\(error)" + } + } + + /// Bytes that are no keyed archive: NSKeyedUnarchiver throws, and retrieve() + /// wraps that in archiveFailure + @Test + func retrieveOfNonArchiveBytesIsAnArchiveFailure() { + let status = KeychainOracle.add(Data("not an archive".utf8), service: store.service, account: store.account) + #expect(status == errSecSuccess) + + #expect(retrieveError { try store.retrieve() } == "archiveFailure") + } + + /// An archive of another class (NSNumber where NSString is expected): + /// NSKeyedUnarchiver.unarchivedObject returns nil without throwing, so + /// retrieve() throws badData inside its do block, and the catch wraps that + /// in archiveFailure + @Test + func retrieveOfAnotherClassIsAnArchiveFailureOfBadData() throws { + let archived = try NSKeyedArchiver.archivedData(withRootObject: NSNumber(value: 1), requiringSecureCoding: true) + #expect(KeychainOracle.add(archived, service: store.service, account: store.account) == errSecSuccess) + + #expect(retrieveError { try store.retrieve() } == "archiveFailure(badData)") + } + + /// A keyed archive without a root object: NSKeyedUnarchiver throws + /// ("requested key: 'root'", NSCocoaErrorDomain 4865), so this is archiveFailure + @Test + func retrieveOfAnArchiveWithoutARootIsAnArchiveFailure() { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + archiver.finishEncoding() + let status = KeychainOracle.add(archiver.encodedData, service: store.service, account: store.account) + #expect(status == errSecSuccess) + + #expect(retrieveError { try store.retrieve() } == "archiveFailure") + } + + /// An unreadable item stays in the keychain: retrieve() only reads + @Test + func failedRetrieveKeepsTheItem() { + let status = KeychainOracle.add(Data("not an archive".utf8), service: store.service, account: store.account) + #expect(status == errSecSuccess) + + _ = retrieveError { try store.retrieve() } + + #expect(KeychainOracle.count(service: store.service, account: store.account) == 1) + } + + /// A saved session missing `token` (see LoggedInShopkeeperTest): init?(coder:) + /// returns nil, NSKeyedUnarchiver throws for it, and retrieve() throws + /// archiveFailure, so LoginRepository treats the user as signed out + @Test + func retrieveOfADamagedLoggedInShopkeeperIsAnArchiveFailure() throws { + let shopkeeperStore = TestLoggedInShopkeeperKeychainStore() + KeychainOracle.delete(service: shopkeeperStore.service, account: shopkeeperStore.account) + defer { KeychainOracle.delete(service: shopkeeperStore.service, account: shopkeeperStore.account) } + + /// PartialLoggedInShopkeeper archived under LoggedInShopkeeper's class name + func add(missingKey: String) throws -> OSStatus { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + archiver.setClassName(NSStringFromClass(LoggedInShopkeeper.self), for: PartialLoggedInShopkeeper.self) + archiver.encode(PartialLoggedInShopkeeper(missingKey: missingKey), forKey: NSKeyedArchiveRootObjectKey) + archiver.finishEncoding() + KeychainOracle.delete(service: shopkeeperStore.service, account: shopkeeperStore.account) + return KeychainOracle.add( + archiver.encodedData, + service: shopkeeperStore.service, + account: shopkeeperStore.account + ) + } + + // Guard: with nothing missing, the same archive is read back + #expect(try add(missingKey: "") == errSecSuccess) + #expect(try shopkeeperStore.retrieve().token == "token-value") + + #expect(try add(missingKey: "token") == errSecSuccess) + #expect(retrieveError { try shopkeeperStore.retrieve() } == "archiveFailure") + } } diff --git a/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift b/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift index e878c9a..72d1408 100644 --- a/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift +++ b/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift @@ -177,4 +177,160 @@ struct SessionControllerPermissionsTest { #expect(server.requests.isEmpty) #expect(sessionController.permissionState == .notLoaded) } + + /// Once the permissions are loaded, the next connection change does not ask + /// again (fetchPermissionsIfNeeded) + @Test + func goingOnlineAgainKeepsTheLoadedPermissions() async throws { + let server = try await TestHTTPServer.start { request in + request.path.hasSuffix("/shopkeeper/permissions") + ? TestHTTPServer.json(["data": [], "meta": Self.meta]) + : TestHTTPServer.json([String: Any]()) + } + defer { server.stop() } + let sessionController = makeSessionController(server: server) + sessionController.connectionChanged(to: .online) + await waitUntil { sessionController.didFetchPermissions } + + sessionController.connectionChanged(to: .offline) + sessionController.connectionChanged(to: .online) + // A second request would be sent at once; give it time to arrive + try await Task.sleep(for: .milliseconds(500)) + + #expect(server.requests.filter { $0.path.hasSuffix("/shopkeeper/permissions") }.count == 1) + #expect(sessionController.hasPermissions) + } + + /// shouldUpdateApp: the API's ios_app_version is newer than this build's + /// CFBundleVersion (`Int(appBuild) < iosAppVersion`) + @Test(arguments: [(1, true), (0, false)]) + func newerAppVersionAsksForAnUpdate(versionsAhead: Int, shouldUpdateApp: Bool) async throws { + let build = try #require(Int(Bundle.main.appBuild)) + var meta = Self.meta + meta["ios_app_version"] = build + versionsAhead + nonisolated(unsafe) let responseMeta = meta + let server = try await TestHTTPServer.start { request in + request.path.hasSuffix("/shopkeeper/permissions") + ? TestHTTPServer.json(["data": [], "meta": responseMeta]) + : TestHTTPServer.json([String: Any]()) + } + defer { server.stop() } + let sessionController = makeSessionController(server: server) + + sessionController.connectionChanged(to: .online) + await waitUntil { sessionController.didFetchPermissions } + + #expect(sessionController.shouldUpdateApp == shouldUpdateApp) + } + + // MARK: - Locale sync + + /// The saved shopkeeper's locale differs from the app's language (tests run in + /// English): after the permissions, SignUpsService.updateLocale PATCHes + /// /shopkeeper_auth (devise_token_auth's root path) and the new locale is saved + @Test + func aDifferentLocaleIsSyncedAfterThePermissions() async throws { + let server = try await TestHTTPServer.start { request in + request.path.hasSuffix("/shopkeeper/permissions") + ? TestHTTPServer.json(["data": [], "meta": Self.meta]) + : TestHTTPServer.json([String: Any]()) + } + defer { server.stop() } + var shopkeeper = Self.shopkeeper + shopkeeper.locale = "ja" + loginRepository.currentShopkeeper = shopkeeper + let sessionController = SessionController( + loginRepository: loginRepository, + monitorsConnection: false, + environment: server.client().environment + ) + + sessionController.connectionChanged(to: .online) + await waitUntil { sessionController.shopkeeper?.locale == "en" } + + let request = try #require(server.requests.first { $0.path == "/shopkeeper_auth" }) + #expect(request.method == "PATCH") + #expect(request.headers["access-token"] == "token-1") + #expect(try JSONSerialization.jsonObject(with: request.body) as? [String: String] == ["locale": "en"]) + #expect(loginRepository.currentShopkeeper?.locale == "en") + } + + /// The locale sync goes through RootPathService, which, unlike Service, does not + /// announce devise_token_auth's 401 as an expired session: a rejected token + /// there keeps the session, and the locale stays as it was for the next try + @Test + func rejectedLocaleSyncKeepsTheSession() async throws { + let server = try await TestHTTPServer.start { request in + request.path.hasSuffix("/shopkeeper/permissions") + ? TestHTTPServer.json(["data": [], "meta": Self.meta]) + : TestHTTPServer.json(status: 401, ["errors": ["You need to sign in or sign up before continuing."]]) + } + defer { server.stop() } + var shopkeeper = Self.shopkeeper + shopkeeper.locale = "ja" + // Unique, so a notice can't sign out another test's SessionController + shopkeeper.token = "rejected-\(UUID().uuidString)" + loginRepository.currentShopkeeper = shopkeeper + let sessionController = SessionController( + loginRepository: loginRepository, + monitorsConnection: false, + environment: server.client().environment + ) + + sessionController.connectionChanged(to: .online) + await waitUntil { server.requests.contains { $0.path == "/shopkeeper_auth" } } + // The failed sync's catch runs after the response; give it time to land + try await Task.sleep(for: .milliseconds(200)) + + #expect(sessionController.isLoggedIn) + #expect(!sessionController.didExpireSession) + #expect(sessionController.shopkeeper?.locale == "ja") + #expect(sessionController.hasPermissions) + } + + // MARK: - Privacy policy and terms + + @Test + func confirmingPrivacyAndTermsPatchesMeAndClearsTheFlags() async throws { + let server = try await TestHTTPServer.start { _ in TestHTTPServer.json(["status": 200]) } + defer { server.stop() } + let sessionController = makeSessionController(server: server) + sessionController.shouldUpdatePrivacy = true + sessionController.shouldUpdateTerms = true + + try await sessionController.updateConfirmedPrivacyVersion() + try await sessionController.updateConfirmedTermsVersion() + + #expect(!sessionController.shouldUpdatePrivacy) + #expect(!sessionController.shouldUpdateTerms) + // MeRequest.swift: PATCH /shopkeeper/me/update_confirmed_{privacy,terms}_version, + // under Service's /{accountId}/api/v1 (account-1 above) + #expect(server.requests.map(\.method) == ["PATCH", "PATCH"]) + #expect(server.requests.map(\.path) == [ + "/account-1/api/v1/shopkeeper/me/update_confirmed_privacy_version", + "/account-1/api/v1/shopkeeper/me/update_confirmed_terms_version" + ]) + } + + /// A failed confirmation keeps asking: the flag stays set and the error reaches the caller + @Test + func failedConfirmationKeepsTheFlags() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(status: 500, ["error_message": "boom"]) + } + defer { server.stop() } + let sessionController = makeSessionController(server: server) + sessionController.shouldUpdatePrivacy = true + sessionController.shouldUpdateTerms = true + + await #expect(throws: NativeAppTemplateAPIError.self) { + try await sessionController.updateConfirmedPrivacyVersion() + } + await #expect(throws: NativeAppTemplateAPIError.self) { + try await sessionController.updateConfirmedTermsVersion() + } + + #expect(sessionController.shouldUpdatePrivacy) + #expect(sessionController.shouldUpdateTerms) + } } diff --git a/NativeAppTemplateTests/Sessions/SessionControllerTest.swift b/NativeAppTemplateTests/Sessions/SessionControllerTest.swift index b0181cd..f56483a 100644 --- a/NativeAppTemplateTests/Sessions/SessionControllerTest.swift +++ b/NativeAppTemplateTests/Sessions/SessionControllerTest.swift @@ -43,10 +43,16 @@ struct SessionControllerTest { /// Versions before URLSession.api dropped its cache left API responses in /// URLCache.shared (on disk). Ending the session removes them. + /// + /// The test uses its own in-memory URLCache. With URLCache.shared it failed now + /// and then: the shared cache writes stored responses to disk later, on a queue + /// of its own, so a write could land after removeAllCachedResponses and the + /// response was there again. Other tests signing out at the same time also + /// empty the shared cache, between the store and the check that it is there. @Test func signingOutRemovesCachedResponses() async throws { let loginRepository = TestLoginRepository() - loginRepository.currentShopkeeper = try #require(Shopkeeper( + loginRepository.currentShopkeeper = Shopkeeper( id: "shopkeeper-1", accountId: "account-1", personalAccountId: "account-1", @@ -60,8 +66,13 @@ struct SessionControllerTest { token: "token-1", client: "client-1", expiry: "expiry-1" - )) - let sessionController = SessionController(loginRepository: loginRepository, monitorsConnection: false) + ) + let urlCache = URLCache(memoryCapacity: 1_000_000, diskCapacity: 0, directory: nil) + let sessionController = SessionController( + loginRepository: loginRepository, + monitorsConnection: false, + urlCache: urlCache + ) let url = try #require(URL(string: "https://example.com/\(UUID().uuidString)")) let request = URLRequest(url: url) @@ -71,12 +82,20 @@ struct SessionControllerTest { httpVersion: "HTTP/1.1", headerFields: ["Cache-Control": "max-age=3600"] )) - URLCache.shared.storeCachedResponse(CachedURLResponse(response: response, data: Data("{}".utf8)), for: request) - try #require(URLCache.shared.cachedResponse(for: request) != nil) + urlCache.storeCachedResponse(CachedURLResponse(response: response, data: Data("{}".utf8)), for: request) + try #require(urlCache.cachedResponse(for: request) != nil) try await sessionController.logout() - #expect(URLCache.shared.cachedResponse(for: request) == nil) + #expect(urlCache.cachedResponse(for: request) == nil) + } + + /// The app's SessionController empties the cache the old versions used + @Test + func signOutEmptiesTheSharedCacheByDefault() { + let sessionController = SessionController(loginRepository: TestLoginRepository(), monitorsConnection: false) + + #expect(sessionController.urlCache === URLCache.shared) } // MARK: - Expired session @@ -88,7 +107,7 @@ struct SessionControllerTest { notificationCenter: NotificationCenter ) throws -> (SessionController, TestLoginRepository) { let loginRepository = TestLoginRepository() - loginRepository.currentShopkeeper = try #require(Shopkeeper( + loginRepository.currentShopkeeper = Shopkeeper( id: "shopkeeper-1", accountId: "account-1", personalAccountId: "account-1", @@ -102,7 +121,7 @@ struct SessionControllerTest { token: token, client: "client-1", expiry: "expiry-1" - )) + ) let sessionController = SessionController( loginRepository: loginRepository, monitorsConnection: false, @@ -155,4 +174,178 @@ struct SessionControllerTest { #expect(loginRepository.currentShopkeeper != nil) #expect(!sessionController.didExpireSession) } + + // MARK: - State from the saved session (characterization) + + @Test + func signedOutHasNoSession() { + let sessionController = SessionController(loginRepository: TestLoginRepository(), monitorsConnection: false) + + #expect(sessionController.userState == .notLoggedIn) + #expect(!sessionController.isLoggedIn) + #expect(sessionController.permissionState == .notLoaded) + #expect(!sessionController.hasPermissions) + // NWPathMonitor is off, so nothing has reported the connection yet + #expect(sessionController.sessionState == .unknown) + // makeClient uses "" for every session field when signed out + #expect(sessionController.client.authToken.isEmpty) + #expect(sessionController.client.client.isEmpty) + #expect(sessionController.client.uid.isEmpty) + #expect(sessionController.client.accountId.isEmpty) + } + + /// A saved shopkeeper is signed in, and the client carries its session. The + /// permissions are not loaded (shopLimitCount is still 0), and the fetch waits + /// for the connection (sessionState is .unknown until connectionChanged). + @Test + func savedShopkeeperIsSignedInAndWaitsForThePermissions() throws { + let (sessionController, _) = try signedInSessionController( + token: "token-1", + notificationCenter: NotificationCenter() + ) + + #expect(sessionController.isLoggedIn) + #expect(sessionController.permissionState == .notLoaded) + #expect(!sessionController.hasPermissions) + // The values signedInSessionController saved + #expect(sessionController.client.authToken == "token-1") + #expect(sessionController.client.client == "client-1") + #expect(sessionController.client.expiry == "expiry-1") + #expect(sessionController.client.uid == "test@example.com") + #expect(sessionController.client.accountId == "account-1") + } + + // MARK: - Signing in + + @Test + func loginSignsInWithTheReturnedSession() async throws { + let loginRepository = TestLoginRepository() + let sessionController = SessionController(loginRepository: loginRepository, monitorsConnection: false) + sessionController.didExpireSession = true + + try await sessionController.login(email: "a@example.com", password: "secret") + + #expect(loginRepository.loginCalled) + #expect(sessionController.isLoggedIn) + #expect(sessionController.shopkeeper?.email == "a@example.com") + // TestLoginRepository.login's token and account + #expect(sessionController.client.authToken == "mockToken") + #expect(sessionController.client.accountId == "mockAccountId") + // The expired-session notice is about the session that ended + #expect(!sessionController.didExpireSession) + } + + @Test + func failedLoginStaysSignedOutAndRethrows() async { + let loginRepository = TestLoginRepository() + loginRepository.loginError = NativeAppTemplateAPIError.requestFailed(nil, 401, "Invalid login credentials") + let sessionController = SessionController(loginRepository: loginRepository, monitorsConnection: false) + + await #expect(throws: NativeAppTemplateAPIError.self) { + try await sessionController.login(email: "a@example.com", password: "wrong") + } + + #expect(sessionController.userState == .notLoggedIn) + #expect(sessionController.permissionState == .notLoaded) + #expect(sessionController.shopkeeper == nil) + } + + // MARK: - Signing out + + @Test + func logoutSignsOutAndPopsToRoot() async throws { + let (sessionController, loginRepository) = try signedInSessionController( + token: "token-1", + notificationCenter: NotificationCenter() + ) + + try await sessionController.logout() + + #expect(loginRepository.logoutCalled) + #expect(sessionController.shouldPopToRootView) + #expect(sessionController.userState == .notLoggedIn) + #expect(sessionController.permissionState == .notLoaded) + #expect(sessionController.shopkeeper == nil) + #expect(sessionController.client.authToken.isEmpty) + // A sign-out the user asked for is not an expired session + #expect(!sessionController.didExpireSession) + } + + /// The API call fails while signed in: the error reaches the caller, but the + /// session is cleared anyway and the app pops to root + @Test + func failedLogoutStillSignsOut() async throws { + let (sessionController, loginRepository) = try signedInSessionController( + token: "token-1", + notificationCenter: NotificationCenter() + ) + loginRepository.logoutError = NativeAppTemplateAPIError.requestFailed(nil, 500, nil) + + await #expect(throws: NativeAppTemplateAPIError.self) { + try await sessionController.logout() + } + + #expect(sessionController.shouldPopToRootView) + #expect(sessionController.userState == .notLoggedIn) + #expect(sessionController.permissionState == .notLoaded) + #expect(sessionController.shopkeeper == nil) + #expect(sessionController.client.authToken.isEmpty) + } + + /// updateShopkeeper(nil) clears the saved session and signs out, without popping to root + @Test + func updatingTheShopkeeperToNilSignsOut() throws { + let (sessionController, loginRepository) = try signedInSessionController( + token: "token-1", + notificationCenter: NotificationCenter() + ) + + try sessionController.updateShopkeeper(shopkeeper: nil) + + #expect(!sessionController.isLoggedIn) + #expect(loginRepository.currentShopkeeper == nil) + #expect(sessionController.client.authToken.isEmpty) + #expect(!sessionController.shouldPopToRootView) + } + + /// updateShopkeeper saves the new shopkeeper and points the client at its session + @Test + func updatingTheShopkeeperSavesItAndUpdatesTheClient() throws { + let (sessionController, loginRepository) = try signedInSessionController( + token: "token-1", + notificationCenter: NotificationCenter() + ) + var shopkeeper = try #require(sessionController.shopkeeper) + shopkeeper.name = "Renamed" + shopkeeper.token = "token-2" + + try sessionController.updateShopkeeper(shopkeeper: shopkeeper) + + #expect(loginRepository.updateShopkeeperCalled) + #expect(loginRepository.currentShopkeeper?.name == "Renamed") + #expect(sessionController.shopkeeper?.name == "Renamed") + #expect(sessionController.client.authToken == "token-2") + #expect(sessionController.isLoggedIn) + } + + /// sessionDidExpire needs a token, and only acts while signed in + @Test + func expiryWithoutATokenOrWhenSignedOutDoesNothing() throws { + let (signedIn, loginRepository) = try signedInSessionController( + token: "token-1", + notificationCenter: NotificationCenter() + ) + signedIn.sessionDidExpire(authToken: nil) + + #expect(signedIn.isLoggedIn) + #expect(loginRepository.currentShopkeeper != nil) + #expect(!signedIn.didExpireSession) + + let signedOut = SessionController(loginRepository: TestLoginRepository(), monitorsConnection: false) + // A signed-out client's token is "", so this would match it if not for the isLoggedIn guard + signedOut.sessionDidExpire(authToken: "") + + #expect(!signedOut.didExpireSession) + #expect(!signedOut.shouldPopToRootView) + } } diff --git a/NativeAppTemplateTests/Testing/Repositories/TestLoginRepository.swift b/NativeAppTemplateTests/Testing/Repositories/TestLoginRepository.swift index ae6293a..e8d581c 100644 --- a/NativeAppTemplateTests/Testing/Repositories/TestLoginRepository.swift +++ b/NativeAppTemplateTests/Testing/Repositories/TestLoginRepository.swift @@ -16,8 +16,14 @@ public final class TestLoginRepository: LoginRepositoryProtocol { public init() {} + /// Thrown by `login` when set + public var loginError: Error? + public func login(email: String, password: String) async throws -> Shopkeeper { loginCalled = true + if let loginError { + throw loginError + } guard let shopkeeper = Shopkeeper( id: UUID().uuidString, diff --git a/NativeAppTemplateTests/Testing/TestHTTPServer.swift b/NativeAppTemplateTests/Testing/TestHTTPServer.swift index c9ea8f4..cb61bc3 100644 --- a/NativeAppTemplateTests/Testing/TestHTTPServer.swift +++ b/NativeAppTemplateTests/Testing/TestHTTPServer.swift @@ -109,7 +109,7 @@ final class TestHTTPServer: @unchecked Sendable { /// A response whose body is `object` as JSON static func json(status: Int = 200, _ object: Any) -> String { let data = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])) ?? Data() - return response(status: status, body: String(decoding: data, as: UTF8.self)) + return response(status: status, body: String(bytes: data, encoding: .utf8) ?? "") } private func handle(_ connection: NWConnection) { @@ -142,7 +142,7 @@ final class TestHTTPServer: @unchecked Sendable { let separator = Data("\r\n\r\n".utf8) guard let headerEnd = buffer.range(of: separator) else { return nil } - let head = String(decoding: buffer[..= 2 else { return nil } diff --git a/NativeAppTemplateTests/Testing/WaitUntilTest.swift b/NativeAppTemplateTests/Testing/WaitUntilTest.swift index 2691821..31a084c 100644 --- a/NativeAppTemplateTests/Testing/WaitUntilTest.swift +++ b/NativeAppTemplateTests/Testing/WaitUntilTest.swift @@ -6,7 +6,6 @@ import Testing @MainActor -@Suite struct WaitUntilTest { final class Flag { var isSet = false diff --git a/NativeAppTemplateTests/UI/App Root/AcceptPrivacyViewModelTest.swift b/NativeAppTemplateTests/UI/App Root/AcceptPrivacyViewModelTest.swift index 1a068b4..e713497 100644 --- a/NativeAppTemplateTests/UI/App Root/AcceptPrivacyViewModelTest.swift +++ b/NativeAppTemplateTests/UI/App Root/AcceptPrivacyViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct AcceptPrivacyViewModelTest { let sessionController = TestSessionController() let messageBus = MessageBus() diff --git a/NativeAppTemplateTests/UI/App Root/AcceptTermsViewModelTest.swift b/NativeAppTemplateTests/UI/App Root/AcceptTermsViewModelTest.swift index def3264..9e40618 100644 --- a/NativeAppTemplateTests/UI/App Root/AcceptTermsViewModelTest.swift +++ b/NativeAppTemplateTests/UI/App Root/AcceptTermsViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct AcceptTermsViewModelTest { let sessionController = TestSessionController() let messageBus = MessageBus() diff --git a/NativeAppTemplateTests/UI/App Root/ForgotPasswordViewModelTest.swift b/NativeAppTemplateTests/UI/App Root/ForgotPasswordViewModelTest.swift index e8e775f..ca9b1d1 100644 --- a/NativeAppTemplateTests/UI/App Root/ForgotPasswordViewModelTest.swift +++ b/NativeAppTemplateTests/UI/App Root/ForgotPasswordViewModelTest.swift @@ -8,108 +8,59 @@ import Foundation import Testing @MainActor -@Suite struct ForgotPasswordViewModelTest { let messageBus = MessageBus() + let signUpRepository = TestSignUpRepository() - // Since ForgotPasswordViewModel requires concrete SignUpRepository, - // we'll test the validation logic and basic state management - // The actual network calls would be tested separately - - @Test - func hasInvalidDataWithEmptyEmail() { - // Test the validation logic without network dependency - let email = "" - - // Simulate the validation logic from the ViewModel - let isBlank = email.isBlank - let isInvalid = !email.isValidEmail - let hasInvalidData = isBlank || isInvalid - - #expect(hasInvalidData == true) - } - - @Test - func hasInvalidDataWithInvalidEmail() { - let email = "invalid-email" - - let isBlank = email.isBlank - let isInvalid = !email.isValidEmail - let hasInvalidData = isBlank || isInvalid - - #expect(hasInvalidData == true) - } - - @Test - func hasInvalidDataWithValidEmail() { - let email = "valid@example.com" - - let isBlank = email.isBlank - let isInvalid = !email.isValidEmail - let hasInvalidData = isBlank || isInvalid - - #expect(hasInvalidData == false) - } - - @Test - func isEmailBlankValidation() { - // Test blank email detection - #expect("".isBlank == true) - #expect(" ".isBlank == true) - #expect("test@example.com".isBlank == false) - } - - @Test - func isEmailInvalidValidation() { - // Test email format validation - #expect("".isValidEmail == false) - #expect("invalid".isValidEmail == false) - #expect("invalid@".isValidEmail == false) - #expect("@invalid.com".isValidEmail == false) - #expect("valid@example.com".isValidEmail == true) - #expect("user+tag@domain.org".isValidEmail == true) + private func makeViewModel(email: String = "") -> ForgotPasswordViewModel { + let viewModel = ForgotPasswordViewModel(signUpRepository: signUpRepository, messageBus: messageBus) + viewModel.email = email + return viewModel } - @Test - func emailValidationEdgeCases() { - // Test various email formats - #expect("user.name@domain.com".isValidEmail == true) - #expect("user+tag@domain.co.uk".isValidEmail == true) - #expect("user@subdomain.domain.org".isValidEmail == true) - #expect("123@domain.com".isValidEmail == true) - - // Invalid cases - #expect("user@".isValidEmail == false) - #expect("@domain.com".isValidEmail == false) - #expect("user.domain.com".isValidEmail == false) - #expect("user space@domain.com".isValidEmail == false) + /// hasInvalidData is true for a blank email or one String.isValidEmail rejects; + /// isEmailInvalid only for a non-blank one (the form shows one message at a time) + @Test(arguments: [ + ("", true, true, false), + (" ", true, true, false), + ("invalid-email", true, false, true), + ("valid@example.com", false, false, false) + ]) + func validation(email: String, hasInvalidData: Bool, isEmailBlank: Bool, isEmailInvalid: Bool) { + let viewModel = makeViewModel(email: email) + + #expect(viewModel.hasInvalidData == hasInvalidData) + #expect(viewModel.isEmailBlank == isEmailBlank) + #expect(viewModel.isEmailInvalid == isEmailInvalid) } @Test - func messageBusIntegration() { - // Test MessageBus functionality used by the ViewModel - #expect(messageBus.currentMessage == nil) - #expect(messageBus.messageVisible == false) + func sendingTrimsTheEmailAndDismissesWithASuccessMessage() async { + let viewModel = makeViewModel(email: " a@example.com\n") - let testMessage = Message(level: .success, message: "Test message") - messageBus.post(message: testMessage) + viewModel.sendMeResetPasswordInstructionsTapped() + await waitUntil { viewModel.shouldDismiss } + #expect(signUpRepository.lastSendResetPassword?.email == "a@example.com") + #expect(!viewModel.isSendingResetPasswordInstructions) + // Strings.sentResetPasswordInstruction, kept on screen (autoDismiss: false) #expect(messageBus.currentMessage?.level == .success) - #expect(messageBus.messageVisible == true) - - messageBus.dismiss() - #expect(messageBus.messageVisible == false) + #expect(messageBus.currentMessage?.message == Strings.sentResetPasswordInstruction) + #expect(messageBus.currentMessage?.autoDismiss == false) } + /// The error shown is the fixed Strings.sentResetPasswordInstructionError, not the API's message @Test - func messageTypesForForgotPassword() { - // Test the types of messages that would be posted - let successMessage = Message(level: .success, message: Strings.sentResetPasswordInstruction) - let errorMessage = Message(level: .error, message: "Email not found", autoDismiss: false) + func failedSendStaysWithAnErrorMessage() async { + signUpRepository.error = .requestFailed(nil, 404, "Not found") + let viewModel = makeViewModel(email: "a@example.com") + + viewModel.sendMeResetPasswordInstructionsTapped() + await waitUntil { signUpRepository.sendResetPasswordCalled && !viewModel.isSendingResetPasswordInstructions } - #expect(successMessage.level == .success) - #expect(successMessage.autoDismiss == true) - #expect(errorMessage.level == .error) - #expect(errorMessage.autoDismiss == false) + #expect(!viewModel.shouldDismiss) + #expect(messageBus.currentMessage?.level == .error) + #expect(messageBus.currentMessage?.message == Strings.sentResetPasswordInstructionError) + #expect(messageBus.currentMessage?.autoDismiss == false) } } diff --git a/NativeAppTemplateTests/UI/App Root/MainContentTest.swift b/NativeAppTemplateTests/UI/App Root/MainContentTest.swift index 2d29b3b..2936465 100644 --- a/NativeAppTemplateTests/UI/App Root/MainContentTest.swift +++ b/NativeAppTemplateTests/UI/App Root/MainContentTest.swift @@ -12,7 +12,6 @@ import Testing /// then terms, then permissions, then the connection, then rebuilding, then the /// required app update. @MainActor -@Suite struct MainContentTest { let sessionController = TestSessionController() diff --git a/NativeAppTemplateTests/UI/App Root/MainViewModelTest.swift b/NativeAppTemplateTests/UI/App Root/MainViewModelTest.swift index 01c9699..25a0b48 100644 --- a/NativeAppTemplateTests/UI/App Root/MainViewModelTest.swift +++ b/NativeAppTemplateTests/UI/App Root/MainViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct MainViewModelTest { let sessionController = TestSessionController() let messageBus = MessageBus() diff --git a/NativeAppTemplateTests/UI/App Root/ResendConfirmationInstructionsViewModelTest.swift b/NativeAppTemplateTests/UI/App Root/ResendConfirmationInstructionsViewModelTest.swift index 2c4bbca..a4dc114 100644 --- a/NativeAppTemplateTests/UI/App Root/ResendConfirmationInstructionsViewModelTest.swift +++ b/NativeAppTemplateTests/UI/App Root/ResendConfirmationInstructionsViewModelTest.swift @@ -8,124 +8,62 @@ import Foundation import Testing @MainActor -@Suite -struct ResendConfirmationViewModelTest { +struct ResendConfirmationInstructionsViewModelTest { let messageBus = MessageBus() - - // Since ResendConfirmationInstructionsViewModel requires concrete SignUpRepository, - // we'll test the validation logic and basic state management - // The actual network calls would be tested separately - - @Test - func hasInvalidDataWithEmptyEmail() { - // Test the validation logic without network dependency - let email = "" - - // Simulate the validation logic from the ViewModel - let isBlank = email.isBlank - let isInvalid = !email.isValidEmail - let hasInvalidData = isBlank || isInvalid - - #expect(hasInvalidData == true) - } - - @Test - func hasInvalidDataWithInvalidEmail() { - let email = "invalid-email" - - let isBlank = email.isBlank - let isInvalid = !email.isValidEmail - let hasInvalidData = isBlank || isInvalid - - #expect(hasInvalidData == true) - } - - @Test - func hasInvalidDataWithValidEmail() { - let email = "valid@example.com" - - let isBlank = email.isBlank - let isInvalid = !email.isValidEmail - let hasInvalidData = isBlank || isInvalid - - #expect(hasInvalidData == false) + let signUpRepository = TestSignUpRepository() + + private func makeViewModel(email: String = "") -> ResendConfirmationInstructionsViewModel { + let viewModel = ResendConfirmationInstructionsViewModel( + signUpRepository: signUpRepository, + messageBus: messageBus + ) + viewModel.email = email + return viewModel } - @Test - func isEmailBlankValidation() { - // Test blank email detection - #expect("".isBlank == true) - #expect(" ".isBlank == true) - #expect("test@example.com".isBlank == false) - } - - @Test - func isEmailInvalidValidation() { - // Test email format validation - #expect("".isValidEmail == false) - #expect("invalid".isValidEmail == false) - #expect("invalid@".isValidEmail == false) - #expect("@invalid.com".isValidEmail == false) - #expect("valid@example.com".isValidEmail == true) - #expect("user+tag@domain.org".isValidEmail == true) - } - - @Test - func emailValidationEdgeCases() { - // Test various email formats - #expect("user.name@domain.com".isValidEmail == true) - #expect("user+tag@domain.co.uk".isValidEmail == true) - #expect("user@subdomain.domain.org".isValidEmail == true) - #expect("123@domain.com".isValidEmail == true) - - // Invalid cases - #expect("user@".isValidEmail == false) - #expect("@domain.com".isValidEmail == false) - #expect("user.domain.com".isValidEmail == false) - #expect("user space@domain.com".isValidEmail == false) + /// hasInvalidData is true for a blank email or one String.isValidEmail rejects; + /// isEmailInvalid only for a non-blank one (the form shows one message at a time) + @Test(arguments: [ + ("", true, true, false), + (" ", true, true, false), + ("invalid-email", true, false, true), + ("valid@example.com", false, false, false) + ]) + func validation(email: String, hasInvalidData: Bool, isEmailBlank: Bool, isEmailInvalid: Bool) { + let viewModel = makeViewModel(email: email) + + #expect(viewModel.hasInvalidData == hasInvalidData) + #expect(viewModel.isEmailBlank == isEmailBlank) + #expect(viewModel.isEmailInvalid == isEmailInvalid) } @Test - func messageBusIntegration() { - // Test MessageBus functionality used by the ViewModel - #expect(messageBus.currentMessage == nil) - #expect(messageBus.messageVisible == false) + func sendingTrimsTheEmailAndDismissesWithASuccessMessage() async { + let viewModel = makeViewModel(email: " a@example.com\n") - let testMessage = Message(level: .success, message: "Test message") - messageBus.post(message: testMessage) + viewModel.sendMeConfirmationInstructionsTapped() + await waitUntil { viewModel.shouldDismiss } + #expect(signUpRepository.lastSendConfirmation?.email == "a@example.com") + #expect(!viewModel.isSendingConfirmationInstructions) + // Strings.sentConfirmationInstruction, kept on screen (autoDismiss: false) #expect(messageBus.currentMessage?.level == .success) - #expect(messageBus.messageVisible == true) - - messageBus.dismiss() - #expect(messageBus.messageVisible == false) + #expect(messageBus.currentMessage?.message == Strings.sentConfirmationInstruction) + #expect(messageBus.currentMessage?.autoDismiss == false) } + /// The error shown is the fixed Strings.sentConfirmationInstructionError, not the API's message @Test - func messageTypesForResendConfirmation() { - // Test the types of messages that would be posted - let successMessage = Message(level: .success, message: Strings.sentConfirmationInstruction) - let errorMessage = Message(level: .error, message: "Email not found", autoDismiss: false) - - #expect(successMessage.level == .success) - #expect(successMessage.autoDismiss == true) - #expect(errorMessage.level == .error) - #expect(errorMessage.autoDismiss == false) - } - - @Test - func emailTrimmingLogic() { - // Test whitespace trimming logic that would be used by the ViewModel - let whitespacesAndNewlines = CharacterSet.whitespacesAndNewlines - - let emailWithSpaces = " test@example.com " - let trimmedEmail = emailWithSpaces.trimmingCharacters(in: whitespacesAndNewlines) - - #expect(trimmedEmail == "test@example.com") + func failedSendStaysWithAnErrorMessage() async { + signUpRepository.error = .requestFailed(nil, 404, "Not found") + let viewModel = makeViewModel(email: "a@example.com") - let emailWithNewlines = "\nuser@domain.org\n" - let trimmedNewlines = emailWithNewlines.trimmingCharacters(in: whitespacesAndNewlines) + viewModel.sendMeConfirmationInstructionsTapped() + await waitUntil { signUpRepository.sendConfirmationCalled && !viewModel.isSendingConfirmationInstructions } - #expect(trimmedNewlines == "user@domain.org") + #expect(!viewModel.shouldDismiss) + #expect(messageBus.currentMessage?.level == .error) + #expect(messageBus.currentMessage?.message == Strings.sentConfirmationInstructionError) + #expect(messageBus.currentMessage?.autoDismiss == false) } } diff --git a/NativeAppTemplateTests/UI/App Root/SignInEmailAndPasswordViewModelTest.swift b/NativeAppTemplateTests/UI/App Root/SignInEmailAndPasswordViewModelTest.swift index 3801ff5..9ec21e0 100644 --- a/NativeAppTemplateTests/UI/App Root/SignInEmailAndPasswordViewModelTest.swift +++ b/NativeAppTemplateTests/UI/App Root/SignInEmailAndPasswordViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct SignInEmailAndPasswordViewModelTest { let sessionController = TestSessionController() let messageBus = MessageBus() @@ -20,8 +19,8 @@ struct SignInEmailAndPasswordViewModelTest { messageBus: messageBus ) - #expect(viewModel.email == "") - #expect(viewModel.password == "") + #expect(viewModel.email.isEmpty) + #expect(viewModel.password.isEmpty) #expect(viewModel.isLoggingIn == false) } diff --git a/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift b/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift index e2f9d49..fb49e76 100644 --- a/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift +++ b/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift @@ -9,7 +9,6 @@ import Testing import UIKit @MainActor -@Suite struct SignUpViewModelTest { let signUpRepository = TestSignUpRepository() let messageBus = MessageBus() @@ -29,12 +28,12 @@ struct SignUpViewModelTest { messageBus: messageBus ) - #expect(viewModel.name == "") - #expect(viewModel.email == "") - #expect(viewModel.password == "") + #expect(viewModel.name.isEmpty) + #expect(viewModel.email.isEmpty) + #expect(viewModel.password.isEmpty) #expect(viewModel.selectedTimeZone == Utility.currentTimeZone()) #expect(viewModel.isCreating == false) - #expect(viewModel.errorMessage == "") + #expect(viewModel.errorMessage.isEmpty) #expect(viewModel.isShowingAlert == false) #expect(viewModel.shouldDismiss == false) } @@ -269,7 +268,7 @@ struct SignUpViewModelTest { ) #expect(viewModel.isShowingAlert == false) - #expect(viewModel.errorMessage == "") + #expect(viewModel.errorMessage.isEmpty) viewModel.isShowingAlert = true viewModel.errorMessage = "Test error message" diff --git a/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift b/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift index 659f9f6..08c4977 100644 --- a/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift @@ -9,7 +9,6 @@ import Testing import UIKit @MainActor -@Suite struct PasswordEditViewModelTest { let accountPasswordRepository = TestAccountPasswordRepository( accountPasswordService: AccountPasswordService() @@ -32,9 +31,9 @@ struct PasswordEditViewModelTest { messageBus: messageBus ) - #expect(viewModel.currentPassword == "") - #expect(viewModel.password == "") - #expect(viewModel.passwordConfirmation == "") + #expect(viewModel.currentPassword.isEmpty) + #expect(viewModel.password.isEmpty) + #expect(viewModel.passwordConfirmation.isEmpty) #expect(viewModel.isUpdating == false) #expect(viewModel.shouldDismiss == false) #expect(viewModel.isBusy == false) @@ -130,7 +129,7 @@ struct PasswordEditViewModelTest { } @Test - func updatePasswordSuccess() async { + func updatePasswordSuccess() async throws { let viewModel = PasswordEditViewModel( accountPasswordRepository: accountPasswordRepository, messageBus: messageBus @@ -148,6 +147,11 @@ struct PasswordEditViewModelTest { #expect(messageBus.currentMessage != nil) #expect(messageBus.currentMessage?.level == .success) #expect(messageBus.currentMessage?.message == Strings.passwordUpdated) + // The three fields, as entered, go to the repository + let sent = try #require(accountPasswordRepository.lastUpdatePassword) + #expect(sent.currentPassword == "currentpassword") + #expect(sent.password == "newpassword") + #expect(sent.passwordConfirmation == "newpassword") } @Test @@ -178,7 +182,7 @@ struct PasswordEditViewModelTest { } @Test - func updatePasswordTrimsWhitespace() async { + func updatePasswordTrimsWhitespace() async throws { let viewModel = PasswordEditViewModel( accountPasswordRepository: accountPasswordRepository, messageBus: messageBus @@ -186,13 +190,18 @@ struct PasswordEditViewModelTest { viewModel.currentPassword = " currentpassword " viewModel.password = " newpassword " - viewModel.passwordConfirmation = " newpassword " + viewModel.passwordConfirmation = " newpassword\n" viewModel.updatePassword() await waitUntil { messageBus.currentMessage != nil && !viewModel.isUpdating } #expect(viewModel.shouldDismiss == true) #expect(messageBus.currentMessage?.level == .success) + // Each field without the surrounding whitespace and newlines + let sent = try #require(accountPasswordRepository.lastUpdatePassword) + #expect(sent.currentPassword == "currentpassword") + #expect(sent.password == "newpassword") + #expect(sent.passwordConfirmation == "newpassword") } @Test diff --git a/NativeAppTemplateTests/UI/Settings/SettingsViewModelTest.swift b/NativeAppTemplateTests/UI/Settings/SettingsViewModelTest.swift index 06772fb..479ed8e 100644 --- a/NativeAppTemplateTests/UI/Settings/SettingsViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Settings/SettingsViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct SettingsViewModelTest { let sessionController = TestSessionController() let tabViewModel = TabViewModel() diff --git a/NativeAppTemplateTests/UI/Settings/ShopkeeperEditViewModelTest.swift b/NativeAppTemplateTests/UI/Settings/ShopkeeperEditViewModelTest.swift index 2675e5f..b76b7a2 100644 --- a/NativeAppTemplateTests/UI/Settings/ShopkeeperEditViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Settings/ShopkeeperEditViewModelTest.swift @@ -10,7 +10,6 @@ import Foundation import Testing @MainActor -@Suite struct ShopkeeperEditViewModelTest { // swiftlint:disable:this type_body_length let signUpRepository = TestSignUpRepository() let sessionController = TestSessionController() @@ -236,6 +235,11 @@ struct ShopkeeperEditViewModelTest { // swiftlint:disable:this type_body_length #expect(viewModel.shouldDismiss == true) #expect(messageBus.currentMessage != nil) #expect(messageBus.currentMessage?.level == .success) + // The edited fields go to the repository + #expect(signUpRepository.updateCalled) + #expect(signUpRepository.lastSignUp?.name == "Updated Name") + #expect(signUpRepository.lastSignUp?.email == "updated@example.com") + #expect(signUpRepository.lastSignUp?.timeZone == "Osaka") } @Test @@ -324,13 +328,16 @@ struct ShopkeeperEditViewModelTest { // swiftlint:disable:this type_body_length ) viewModel.name = " Updated Name " - viewModel.email = " updated@example.com " + viewModel.email = " updated@example.com\n" viewModel.updateShopkeeper() await waitUntil { messageBus.currentMessage != nil && !viewModel.isUpdating } #expect(viewModel.shouldDismiss == true) #expect(messageBus.currentMessage?.level == .success) + // Without the surrounding whitespace and newlines + #expect(signUpRepository.lastSignUp?.name == "Updated Name") + #expect(signUpRepository.lastSignUp?.email == "updated@example.com") } @Test @@ -356,6 +363,7 @@ struct ShopkeeperEditViewModelTest { // swiftlint:disable:this type_body_length #expect(messageBus.currentMessage?.level == .success) #expect(messageBus.currentMessage?.message == Strings.shopkeeperDeleted) #expect(sessionController.shopkeeper == nil) + #expect(signUpRepository.destroyCalled) } @Test diff --git a/NativeAppTemplateTests/UI/Shop Detail/ShopDetailViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Detail/ShopDetailViewModelTest.swift index f185188..64e851a 100644 --- a/NativeAppTemplateTests/UI/Shop Detail/ShopDetailViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Detail/ShopDetailViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct ShopDetailViewModelTest { // swiftlint:disable:this type_body_length var shops: [Shop] { [ diff --git a/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift b/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift index c3d3d0c..7f1c00f 100644 --- a/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct ShopCreateViewModelTest { let shopRepository = TestShopRepository( shopsService: ShopsService() @@ -213,8 +212,8 @@ struct ShopCreateViewModelTest { messageBus: messageBus ) - #expect(viewModel.name == "") - #expect(viewModel.description == "") + #expect(viewModel.name.isEmpty) + #expect(viewModel.description.isEmpty) #expect(viewModel.selectedTimeZone == Utility.currentTimeZone()) #expect(viewModel.shouldDismiss == false) } diff --git a/NativeAppTemplateTests/UI/Shop List/ShopListViewModelTest.swift b/NativeAppTemplateTests/UI/Shop List/ShopListViewModelTest.swift index 51e7707..bb97c4b 100644 --- a/NativeAppTemplateTests/UI/Shop List/ShopListViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop List/ShopListViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct ShopListViewModelTest { var shops: [Shop] { [ diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModelTest.swift index 121c1f5..09b2a34 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct ItemTagDetailViewModelTest { let itemTagRepository = TestItemTagRepository( itemTagsService: ItemTagsService() @@ -257,7 +256,7 @@ struct ItemTagDetailViewModelTest { #expect(messageBus.currentMessage != nil) #expect(messageBus.currentMessage?.level == .success) #expect(messageBus.currentMessage?.message == Strings.itemTagDeleted) - #expect(itemTagRepository.itemTags.count == 0) + #expect(itemTagRepository.itemTags.isEmpty) } @Test diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModelTest.swift index e0933b6..34cbccf 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct ItemTagEditViewModelTest { let itemTagRepository = TestItemTagRepository( itemTagsService: ItemTagsService() @@ -38,8 +37,8 @@ struct ItemTagEditViewModelTest { itemTagId: itemTagId ) - #expect(viewModel.name == "") - #expect(viewModel.description == "") + #expect(viewModel.name.isEmpty) + #expect(viewModel.description.isEmpty) #expect(viewModel.isFetching == true) #expect(viewModel.isUpdating == false) #expect(viewModel.shouldDismiss == false) diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagCreateViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagCreateViewModelTest.swift index e436753..a661289 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagCreateViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagCreateViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct ItemTagCreateViewModelTest { let itemTagRepository = TestItemTagRepository( itemTagsService: ItemTagsService() @@ -24,8 +23,8 @@ struct ItemTagCreateViewModelTest { shopId: shopId ) - #expect(viewModel.name == "") - #expect(viewModel.description == "") + #expect(viewModel.name.isEmpty) + #expect(viewModel.description.isEmpty) #expect(viewModel.isCreating == false) #expect(viewModel.shouldDismiss == false) #expect(viewModel.isBusy == false) @@ -173,7 +172,7 @@ struct ItemTagCreateViewModelTest { #expect(messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") #expect(messageBus.currentMessage?.level == .error) #expect(messageBus.currentMessage?.autoDismiss == false) - #expect(itemTagRepository.itemTags.count == 0) + #expect(itemTagRepository.itemTags.isEmpty) } @Test diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagListViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagListViewModelTest.swift index bde0e5a..b62453c 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagListViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagListViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct ItemTagListViewModelTest { var itemTags: [ItemTag] { [ diff --git a/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift index 60b28a2..ae18d3e 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct ShopBasicSettingsViewModelTest { var shops: [Shop] { [ @@ -52,7 +51,7 @@ struct ShopBasicSettingsViewModelTest { await waitUntil { !viewModel.isFetching } viewModel.name = name - #expect(viewModel.hasInvalidData == (name == "" ? true : false)) + #expect(viewModel.hasInvalidData == name.isEmpty) } @Test("Has invalid data when inputting all same data", arguments: ["Shop 1", "New Shop 1"]) diff --git a/NativeAppTemplateTests/UI/Shop Settings/ShopSettingsViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ShopSettingsViewModelTest.swift index 92e66d0..7c6eec4 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ShopSettingsViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ShopSettingsViewModelTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct ShopSettingsViewModelTest { var shops: [Shop] { [ diff --git a/NativeAppTemplateTests/Utilities/AppErrorTest.swift b/NativeAppTemplateTests/Utilities/AppErrorTest.swift index d1198d8..7c71022 100644 --- a/NativeAppTemplateTests/Utilities/AppErrorTest.swift +++ b/NativeAppTemplateTests/Utilities/AppErrorTest.swift @@ -6,7 +6,6 @@ @testable import NativeAppTemplate import Testing -@Suite struct AppErrorTest { @Test func unexpectedErrorCode() { diff --git a/NativeAppTemplateTests/Utilities/CodedErrorTest.swift b/NativeAppTemplateTests/Utilities/CodedErrorTest.swift index c68198c..2fc4102 100644 --- a/NativeAppTemplateTests/Utilities/CodedErrorTest.swift +++ b/NativeAppTemplateTests/Utilities/CodedErrorTest.swift @@ -7,7 +7,6 @@ import Foundation @testable import NativeAppTemplate import Testing -@Suite struct CodedErrorTest { struct TestCodedError: CodedError { var errorCode: String { "NATIVEAPPTEMPLATE-9999" } diff --git a/NativeAppTemplateTests/Utilities/MessageBusTest.swift b/NativeAppTemplateTests/Utilities/MessageBusTest.swift index 0839d85..74e2fd1 100644 --- a/NativeAppTemplateTests/Utilities/MessageBusTest.swift +++ b/NativeAppTemplateTests/Utilities/MessageBusTest.swift @@ -8,7 +8,6 @@ import Foundation import Testing @MainActor -@Suite struct MessageBusTest { @Test func initialState() { diff --git a/NativeAppTemplateTests/Utilities/NativeAppTemplateAPIErrorTest.swift b/NativeAppTemplateTests/Utilities/NativeAppTemplateAPIErrorTest.swift index c156e3a..f77a73a 100644 --- a/NativeAppTemplateTests/Utilities/NativeAppTemplateAPIErrorTest.swift +++ b/NativeAppTemplateTests/Utilities/NativeAppTemplateAPIErrorTest.swift @@ -6,7 +6,6 @@ @testable import NativeAppTemplate import Testing -@Suite struct NativeAppTemplateAPIErrorTest { @Test func requestFailedErrorCode() {