diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index d52b23d..638b575 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -1196,6 +1196,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_ASSET_PATHS = "\"NativeAppTemplate/Preview Content\""; + ENABLE_DEBUG_DYLIB = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; INFOPLIST_FILE = NativeAppTemplate/Info.plist; diff --git a/NativeAppTemplate/App.swift b/NativeAppTemplate/App.swift index 572ae70..0fdcb9e 100644 --- a/NativeAppTemplate/App.swift +++ b/NativeAppTemplate/App.swift @@ -25,6 +25,7 @@ private final class NullSessionController: SessionControllerProtocol { } var shouldPopToRootView: Bool = false + var didExpireSession: Bool = false var shouldUpdateApp: Bool = false var shouldUpdatePrivacy: Bool = false var shouldUpdateTerms: Bool = false diff --git a/NativeAppTemplate/Constants.swift b/NativeAppTemplate/Constants.swift index f9e4d48..ce72d37 100644 --- a/NativeAppTemplate/Constants.swift +++ b/NativeAppTemplate/Constants.swift @@ -297,6 +297,8 @@ enum Strings { static let signInToYourAccount = String(localized: "Sign In to Your Account") static let email = String(localized: "Email") static let password = String(localized: "Password") + static let sessionExpired = String(localized: "Session Expired") + static let sessionExpiredDescription = String(localized: "Your session has expired. Please sign in again.") static var welcomeToApp: String { String(localized: "Welcome to \(appName)") diff --git a/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift index 025c3f5..16c2801 100644 --- a/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift +++ b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift @@ -12,6 +12,11 @@ import SwiftUI var state: DataState = .initial var paginationMeta: PaginationMeta? var isLoadingMore = false + /// The shop whose pages are on screen or loading + private var listedShopId: String? + /// Bumped by every reloadPage, so a page that arrives after another one was + /// asked for (the user opened another shop meanwhile) is dropped + private var listGeneration = 0 required init(itemTagsService: ItemTagsService) { self.itemTagsService = itemTagsService @@ -26,7 +31,8 @@ import SwiftUI return } - if state == .loading { + // A reload of this shop is already on its way + if state == .loading, listedShopId == shopId { return } @@ -37,15 +43,24 @@ import SwiftUI paginationMeta = nil } + // A next page still loading belongs to the old list and will be dropped + isLoadingMore = false + listedShopId = shopId + listGeneration += 1 + let generation = listGeneration state = .loading Task { @MainActor in do { let response = try await itemTagsService.allItemTags(shopId: shopId, page: page) + guard generation == listGeneration else { return } + itemTags = response.itemTags paginationMeta = response.paginationMeta state = .hasData } catch { + guard generation == listGeneration else { return } + state = .failed Failure .fetch(from: Self.self, reason: error.codedDescription) @@ -62,10 +77,14 @@ import SwiftUI } isLoadingMore = true + let generation = listGeneration Task { @MainActor in do { let response = try await itemTagsService.allItemTags(shopId: shopId, page: meta.currentPage + 1) + // The list was reloaded (or another shop opened) meanwhile + guard generation == listGeneration else { return } + // A list that shifted since the last page can repeat tags already // listed; keep the first occurrence so each id appears once, in order let listedIds = Set(itemTags.map(\.id)) @@ -76,6 +95,7 @@ import SwiftUI .fetch(from: Self.self, reason: error.codedDescription) .log() } + guard generation == listGeneration else { return } isLoadingMore = false } diff --git a/NativeAppTemplate/Info.plist b/NativeAppTemplate/Info.plist index 673fc62..61d817f 100644 --- a/NativeAppTemplate/Info.plist +++ b/NativeAppTemplate/Info.plist @@ -24,6 +24,8 @@ LSRequiresIPhoneOS + NSLocalNetworkUsageDescription + Connects to a NativeAppTemplate API server running on your local network during development. NativeAppTemplateAPIDomain $(NATIVEAPPTEMPLATE_API_DOMAIN) NativeAppTemplateAPIPort @@ -38,10 +40,7 @@ UIApplicationSupportsIndirectInputEvents UILaunchScreen - - UIColorName - backgroundColor - + UIRequiredDeviceCapabilities arm64 @@ -57,5 +56,7 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + UIUserInterfaceStyle + Dark diff --git a/NativeAppTemplate/InfoPlist.xcstrings b/NativeAppTemplate/InfoPlist.xcstrings index 520d07c..4ef09fd 100644 --- a/NativeAppTemplate/InfoPlist.xcstrings +++ b/NativeAppTemplate/InfoPlist.xcstrings @@ -36,6 +36,24 @@ } } } + }, + "NSLocalNetworkUsageDescription" : { + "comment" : "Privacy - Local Network Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connects to a NativeAppTemplate API server running on your local network during development." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開発中に、ローカルネットワーク上で動作している NativeAppTemplate API サーバーに接続します。" + } + } + } } }, "version" : "1.0" diff --git a/NativeAppTemplate/Localizable.xcstrings b/NativeAppTemplate/Localizable.xcstrings index 71658da..d936b0b 100644 --- a/NativeAppTemplate/Localizable.xcstrings +++ b/NativeAppTemplate/Localizable.xcstrings @@ -1019,6 +1019,16 @@ } } }, + "Session Expired" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッションの有効期限切れ" + } + } + } + }, "Settings" : { "localizations" : { "ja" : { @@ -1428,6 +1438,16 @@ } } } + }, + "Your session has expired. Please sign in again." : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッションの有効期限が切れました。もう一度サインインしてください。" + } + } + } } }, "version" : "1.0" diff --git a/NativeAppTemplate/Networking/Services/Service.swift b/NativeAppTemplate/Networking/Services/Service.swift index 97b679d..417dfd6 100644 --- a/NativeAppTemplate/Networking/Services/Service.swift +++ b/NativeAppTemplate/Networking/Services/Service.swift @@ -62,17 +62,20 @@ extension Service { let statusCode = (response as? HTTPURLResponse)?.statusCode guard statusCode.map((200 ..< 300).contains) == true else { - var errorMessage: String? - - do { - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let theErrorMessage = json["error_message"] as? String { - errorMessage = theErrorMessage - } - } catch { - // Not JSON (e.g. a proxy's HTML error page): no message to show, - // so the error falls back to its generic description - throw NativeAppTemplateAPIError.requestFailed(nil, statusCode ?? 0, nil) + // Not JSON (e.g. a proxy's HTML error page) or no error_message: no message + // to show, so the error falls back to its generic description + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + let errorMessage = json?["error_message"] as? String + + // devise_token_auth rejects a missing, wrong or expired token with a 401 + // whose body has no error_message. The API's own 401 (a Pundit denial) + // has one and keeps the session. + if statusCode == 401, errorMessage == nil, !networkClient.authToken.isEmpty { + NotificationCenter.default.post( + name: .apiSessionDidExpire, + object: nil, + userInfo: [Notification.authTokenKey: networkClient.authToken] + ) } throw NativeAppTemplateAPIError.requestFailed(nil, statusCode ?? 0, errorMessage) @@ -81,3 +84,15 @@ extension Service { return try request.handle(response: data) } } + +extension Notification.Name { + /// Posted on the main actor when the API rejects a request's access token: a 401 + /// from devise_token_auth, e.g. once the token's lifespan is over. The rejected + /// token is in `userInfo[Notification.authTokenKey]`, so a session that has + /// since signed in again can tell the notice is not about it. + static let apiSessionDidExpire = Notification.Name("NativeAppTemplate.apiSessionDidExpire") +} + +extension Notification { + static let authTokenKey = "authToken" +} diff --git a/NativeAppTemplate/Sessions/SessionController.swift b/NativeAppTemplate/Sessions/SessionController.swift index db8eca5..c3ae4a3 100644 --- a/NativeAppTemplate/Sessions/SessionController.swift +++ b/NativeAppTemplate/Sessions/SessionController.swift @@ -14,6 +14,8 @@ import Observation private(set) var permissionState: PermissionState = .notLoaded private(set) var didFetchPermissions = false var shouldPopToRootView = false + /// Set when the API rejected the session's token and the app signed out + var didExpireSession = false var shouldUpdateApp = false var shouldUpdatePrivacy = false @@ -36,6 +38,13 @@ import Observation } else { userState = .notLoggedIn permissionState = .notLoaded + // These were the signed-out shopkeeper's; the next one's are fetched + // on sign-in, so none of them may show meanwhile + didFetchPermissions = false + shouldUpdateApp = false + shouldUpdatePrivacy = false + shouldUpdateTerms = false + shopLimitCount = 0 // URLSession.api keeps no cache, but versions before it used // URLCache.shared, which holds their API responses on disk URLCache.shared.removeAllCachedResponses() @@ -50,6 +59,9 @@ import Observation private let connectionMonitor = NWPathMonitor() private(set) var permissionsService: PermissionsService private(set) var meService: MeService + private let notificationCenter: NotificationCenter + /// Removed in deinit, which is nonisolated + @ObservationIgnored private nonisolated(unsafe) var sessionExpiryObserver: (any NSObjectProtocol)? var isLoggedIn: Bool { userState == .loggedIn @@ -68,13 +80,16 @@ import Observation /// - monitorsConnection: Starts `NWPathMonitor`; tests pass `false` and call /// `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` init( loginRepository: LoginRepositoryProtocol, monitorsConnection: Bool = true, - environment: NativeAppTemplateEnvironment = .prod + environment: NativeAppTemplateEnvironment = .prod, + notificationCenter: NotificationCenter = .default ) { self.loginRepository = loginRepository self.environment = environment + self.notificationCenter = notificationCenter let shopkeeper = loginRepository.currentShopkeeper let client = Self.makeClient(shopkeeper: shopkeeper, environment: environment) @@ -85,6 +100,25 @@ import Observation if monitorsConnection { prepareConnectionMonitor() } + + // Service posts on the main actor, and a nil queue runs this at once, so the + // session is gone before the failed request's caller sees its error + sessionExpiryObserver = notificationCenter.addObserver( + forName: .apiSessionDidExpire, + object: nil, + queue: nil + ) { [weak self] notification in + let authToken = notification.userInfo?[Notification.authTokenKey] as? String + MainActor.assumeIsolated { + self?.sessionDidExpire(authToken: authToken) + } + } + } + + deinit { + if let sessionExpiryObserver { + notificationCenter.removeObserver(sessionExpiryObserver) + } } func login(email: String, password: String) async throws { @@ -94,6 +128,7 @@ import Observation do { shopkeeper = try await loginRepository.login(email: email, password: password) + didExpireSession = false Event .login(from: Self.self) .log() @@ -131,6 +166,22 @@ import Observation } } + /// The API rejected `authToken` (see `.apiSessionDidExpire`). When that is this + /// session's token, every further request would fail too, so sign out here: the + /// server has no session left to destroy, and the user signs in again. + func sessionDidExpire(authToken: String?) { + guard isLoggedIn, let authToken, authToken == client.authToken else { return } + + Failure + .logout(from: Self.self, reason: "The API rejected the session's token") + .log() + + shouldPopToRootView = true + try? loginRepository.updateShopkeeper(shopkeeper: nil) + shopkeeper = nil + didExpireSession = true + } + func fetchPermissionsIfNeeded() { guard !hasPermissions else { return } @@ -159,15 +210,16 @@ import Observation // Check that we have a logged in user. Otherwise this is pointless guard let shopkeeper = self.shopkeeper else { return } - // Update the user - self.shopkeeper = shopkeeper // Ensure loginRepository is aware, and hence the keychain is updated - try self.loginRepository.updateShopkeeper(shopkeeper: self.shopkeeper) + try self.loginRepository.updateShopkeeper(shopkeeper: shopkeeper) shouldUpdateApp = Int(Bundle.main.appBuild)! < permissionsResponse.iosAppVersion shouldUpdatePrivacy = permissionsResponse.shouldUpdatePrivacy shouldUpdateTerms = permissionsResponse.shouldUpdateTerms shopLimitCount = permissionsResponse.shopLimitCount + // Set here, not by reassigning shopkeeper: its didSet saw the old + // shopLimitCount (0 on the first fetch) and requested them again + permissionState = .loaded didFetchPermissions = true @@ -178,6 +230,9 @@ import Observation .fetch(from: Permissions.self, reason: error.codedDescription) .log() + // An expired session has signed out already; that is not a permissions error + guard isLoggedIn else { return } + self.permissionState = .error } } diff --git a/NativeAppTemplate/Sessions/SessionControllerProtocol.swift b/NativeAppTemplate/Sessions/SessionControllerProtocol.swift index d36dd1a..d75296e 100644 --- a/NativeAppTemplate/Sessions/SessionControllerProtocol.swift +++ b/NativeAppTemplate/Sessions/SessionControllerProtocol.swift @@ -32,6 +32,9 @@ protocol SessionControllerProtocol: AnyObject, Observable, Sendable { var permissionState: PermissionState { get } var shouldPopToRootView: Bool { get set } + /// True once the API rejected the session's token and the app signed out; + /// MainView tells the user and resets it + var didExpireSession: Bool { get set } var shouldUpdateApp: Bool { get set } var shouldUpdatePrivacy: Bool { get set } diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift index d51909b..74d0dfe 100644 --- a/NativeAppTemplate/UI/App Root/MainView.swift +++ b/NativeAppTemplate/UI/App Root/MainView.swift @@ -19,6 +19,18 @@ struct MainView: View { contentView .background(Color.backgroundColor) .overlay(MessageBarView(messageBus: messageBus), alignment: .bottom) + .alert(Strings.sessionExpired, isPresented: sessionExpiredAlertBinding) { + Button(Strings.ok) {} + } message: { + Text(Strings.sessionExpiredDescription) + } + .onChange(of: sessionController.didExpireSession) { + // The alert says why; the failed request's own error would only + // repeat it as a status code + if sessionController.didExpireSession { + messageBus.dismiss() + } + } .onAppear { if viewModel == nil { viewModel = MainViewModel( @@ -34,6 +46,13 @@ struct MainView: View { // MARK: - private private extension MainView { + var sessionExpiredAlertBinding: Binding { + Binding( + get: { sessionController.didExpireSession }, + set: { sessionController.didExpireSession = $0 } + ) + } + @ViewBuilder var contentView: some View { switch MainViewModel.content( sessionController: sessionController, diff --git a/NativeAppTemplate/UI/App Root/SignUpView.swift b/NativeAppTemplate/UI/App Root/SignUpView.swift index b4e522f..fb69f5f 100644 --- a/NativeAppTemplate/UI/App Root/SignUpView.swift +++ b/NativeAppTemplate/UI/App Root/SignUpView.swift @@ -77,7 +77,7 @@ private extension SignUpView { Section { SecureField(Strings.placeholderPassword, text: $viewModel.password) - .textContentType(.password) + .textContentType(SignUpViewModel.passwordContentType) .autocapitalization(.none) .autocorrectionDisabled(true) } header: { diff --git a/NativeAppTemplate/UI/App Root/SignUpViewModel.swift b/NativeAppTemplate/UI/App Root/SignUpViewModel.swift index aae2116..aa536f2 100644 --- a/NativeAppTemplate/UI/App Root/SignUpViewModel.swift +++ b/NativeAppTemplate/UI/App Root/SignUpViewModel.swift @@ -9,6 +9,10 @@ import SwiftUI @Observable @MainActor final class SignUpViewModel { + /// The password field's content type. `.newPassword` lets iOS suggest a strong + /// password and save it with the email; `.password` offers only saved passwords. + static let passwordContentType: UITextContentType = .newPassword + var name = "" var email = "" var password = "" diff --git a/NativeAppTemplate/UI/Settings/PasswordEditView.swift b/NativeAppTemplate/UI/Settings/PasswordEditView.swift index e8125e0..3c058e9 100644 --- a/NativeAppTemplate/UI/Settings/PasswordEditView.swift +++ b/NativeAppTemplate/UI/Settings/PasswordEditView.swift @@ -42,7 +42,7 @@ private extension PasswordEditView { Form { Section { SecureField(Strings.currentPassword, text: $viewModel.currentPassword) - .textContentType(.password) + .textContentType(PasswordEditViewModel.currentPasswordContentType) .autocapitalization(.none) .autocorrectionDisabled(true) } header: { @@ -58,7 +58,7 @@ private extension PasswordEditView { } Section { SecureField(Strings.newPassword, text: $viewModel.password) - .textContentType(.password) + .textContentType(PasswordEditViewModel.newPasswordContentType) .autocapitalization(.none) .autocorrectionDisabled(true) } header: { @@ -81,7 +81,7 @@ private extension PasswordEditView { } Section { SecureField(Strings.confirmNewPassword, text: $viewModel.passwordConfirmation) - .textContentType(.password) + .textContentType(PasswordEditViewModel.newPasswordContentType) .autocapitalization(.none) .autocorrectionDisabled(true) } header: { diff --git a/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift b/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift index 842cd48..f6e7e75 100644 --- a/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift +++ b/NativeAppTemplate/UI/Settings/PasswordEditViewModel.swift @@ -9,6 +9,12 @@ import SwiftUI @Observable @MainActor final class PasswordEditViewModel { + /// The current password is one iOS may have saved; the new password and its + /// confirmation are `.newPassword`, so iOS suggests a strong one and updates the + /// saved password instead of autofilling the old one into them + static let currentPasswordContentType: UITextContentType = .password + static let newPasswordContentType: UITextContentType = .newPassword + var currentPassword = "" var password = "" var passwordConfirmation = "" diff --git a/NativeAppTemplate/Utilities/Utility.swift b/NativeAppTemplate/Utilities/Utility.swift index a7ad2cc..61f7fbb 100644 --- a/NativeAppTemplate/Utilities/Utility.swift +++ b/NativeAppTemplate/Utilities/Utility.swift @@ -8,7 +8,8 @@ import os enum Utility { /// The zone (a key of `timeZones`) to preselect for this device: the one - /// named after the identifier's city, else the first with its standard + /// named exactly after the identifier's city, else one at the device's + /// standard offset whose name contains the city, else the first at that /// offset. The table labels zones by standard offset, so summer time must /// not move the match. static func currentTimeZone(_ timeZone: TimeZone = .current, date: Date = .now) -> String { @@ -22,7 +23,17 @@ enum Utility { let city = (timeZone.identifier.components(separatedBy: "/").last ?? "") .replacingOccurrences(of: "_", with: " ") - if !city.isEmpty, let zone = timeZones.first(where: { $0.key.contains(city) }) { + guard !city.isEmpty else { return firstZoneWithOffset.key } + + // The whole name, at any offset: the table's offset for a city can differ + // from the system's (Dublin, Casablanca), and the city still names the zone + if let zone = timeZones.first(where: { $0.key == city }) { + return zone.key + } + + // Part of a name only among zones at this offset: "West" (Australia/West, + // +08:00) is in "International Date Line West" (-12:00) + if let zone = zonesWithOffset.first(where: { $0.key.contains(city) }) { return zone.key } diff --git a/NativeAppTemplateTests/App/InfoPlistTest.swift b/NativeAppTemplateTests/App/InfoPlistTest.swift new file mode 100644 index 0000000..090b154 --- /dev/null +++ b/NativeAppTemplateTests/App/InfoPlistTest.swift @@ -0,0 +1,63 @@ +// +// InfoPlistTest.swift +// NativeAppTemplate +// + +import Foundation +@testable import NativeAppTemplate +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, + /// "Local network prohibited") without ever showing the permission prompt. + @Test + func declaresTheLocalNetworkUsageDescription() throws { + let info = try #require(Bundle.main.infoDictionary) + let description = try #require(info["NSLocalNetworkUsageDescription"] as? String) + + #expect(!description.isEmpty) + } + + /// UILaunchScreen's UIColorName must name a color set in the asset catalog + /// (Apple's UILaunchScreen reference); a name that is not there is ignored + /// silently and the launch screen falls back to the system background. + /// The app defines its colors in Color+Extensions.swift, not in the asset catalog. + @Test + func everyLaunchScreenColorNameResolves() throws { + let info = try #require(Bundle.main.infoDictionary) + let launchScreen = try #require(info["UILaunchScreen"] as? [String: Any]) + + if let colorName = launchScreen["UIColorName"] as? String { + #expect(UIColor(named: colorName, in: .main, compatibleWith: nil) != nil, "\(colorName) is not in the asset catalog") + } + } + + /// The app is dark mode only (App.swift applies .preferredColorScheme(.dark)), but + /// that only takes effect once SwiftUI is on screen. UIUserInterfaceStyle = Dark + /// keeps the launch screen's system background dark in light mode too. + @Test + func launchesInDarkMode() throws { + let info = try #require(Bundle.main.infoDictionary) + + #expect(info["UIUserInterfaceStyle"] as? String == "Dark") + } + + /// iOS local network privacy identifies an app by its main executable's + /// LC_UUID (Apple TN3179). With ENABLE_DEBUG_DYLIB the Debug main executable + /// is a tiny stub that depends only on the product name, so this app and the + /// paid NativeAppTemplate app (same product name) had the same UUID: the Free + /// app was silently denied local network access and never prompted because + /// the paid app already held the entry. Debug builds must link the code into + /// the main executable, so no .debug.dylib may be in the bundle. + @Test + func mainExecutableIsNotADebugDylibStub() throws { + let executableName = try #require(Bundle.main.executableURL?.lastPathComponent) + let debugDylib = Bundle.main.bundleURL.appendingPathComponent("\(executableName).debug.dylib") + + #expect(!FileManager.default.fileExists(atPath: debugDylib.path), "\(debugDylib.lastPathComponent) is in the app bundle") + } +} diff --git a/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift b/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift index 919e26f..3ccc798 100644 --- a/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift +++ b/NativeAppTemplateTests/Data/Repositories/ItemTagRepositoryPaginationTest.swift @@ -126,4 +126,85 @@ struct ItemTagRepositoryPaginationTest { #expect(repository.state == .failed) #expect(repository.itemTags.isEmpty) } + + // MARK: - Switching shops while a page is loading + + /// Holds shop-A's requests until `releaseA` is signalled; shop-B's are answered + /// at once. Each shop's page lists its own tags (a1, a2 then a3 / b1). + private nonisolated static func shopServer(releaseA: DispatchSemaphore) async throws -> TestHTTPServer { + try await TestHTTPServer.start { request in + if request.path.contains("/shops/shop-A/") { + releaseA.wait() + let page = request.queryItems.first { $0.name == "page" }?.value ?? "1" + return TestHTTPServer.json(JSONAPIFixtures.itemTagPage( + ids: page == "1" ? ["a1", "a2"] : ["a3"], + currentPage: Int(page) ?? 1, + totalPages: 2, + shopId: "shop-A" + )) + } + return TestHTTPServer.json(JSONAPIFixtures.itemTagPage( + ids: ["b1"], + currentPage: 1, + totalPages: 1, + shopId: "shop-B" + )) + } + } + + /// Opening shop B while shop A's first page is still loading: B's list must + /// load B's tags, and A's late page must not replace them + @Test + func reloadingAnotherShopWhileOneIsLoadingShowsThatShop() async throws { + let releaseA = DispatchSemaphore(value: 0) + let server = try await Self.shopServer(releaseA: releaseA) + defer { server.stop() } + let repository = makeRepository(server) + + repository.reloadPage(shopId: "shop-A", page: 1) + await waitUntil { server.requests.count == 1 } + repository.reloadPage(shopId: "shop-B", page: 1) + await waitUntil(timeout: .seconds(10)) { repository.state == .hasData } + + #expect(repository.itemTags.map(\.id) == ["b1"]) + + // A's answer arrives late. Nothing on screen shows it was handled, so give it time + releaseA.signal() + try await Task.sleep(for: .milliseconds(500)) + + #expect(repository.itemTags.map(\.id) == ["b1"]) + #expect(repository.itemTags.allSatisfy { $0.shopId == "shop-B" }) + #expect(repository.state == .hasData) + } + + /// Shop A's next page arriving after the user opened shop B must not be + /// appended to B's list + @Test + func nextPageOfThePreviousShopIsNotAppendedToAnotherShop() async throws { + // Starts at 0 and ends at 0: libdispatch crashes when a semaphore is freed + // below its starting value + let releaseA = DispatchSemaphore(value: 0) + let server = try await Self.shopServer(releaseA: releaseA) + defer { server.stop() } + let repository = makeRepository(server) + // Let page 1 through; page 2 waits + releaseA.signal() + repository.reloadPage(shopId: "shop-A", page: 1) + await waitUntil { repository.state == .hasData } + repository.loadNextPage(shopId: "shop-A") + await waitUntil { server.requests.count == 2 } + + repository.reloadPage(shopId: "shop-B", page: 1) + await waitUntil { repository.state == .hasData && repository.itemTags.contains { $0.id == "b1" } } + releaseA.signal() + // Nothing on screen shows A's late page was handled, so give it time + try await Task.sleep(for: .milliseconds(500)) + + #expect(repository.itemTags.map(\.id) == ["b1"]) + // B's own meta (1 of 1), not A's page 2 + #expect(repository.paginationMeta?.currentPage == 1) + #expect(repository.paginationMeta?.totalPages == 1) + // B's list can load its own next pages + #expect(!repository.isLoadingMore) + } } diff --git a/NativeAppTemplateTests/Networking/ServiceTest.swift b/NativeAppTemplateTests/Networking/ServiceTest.swift index 67bdf4c..39adb5d 100644 --- a/NativeAppTemplateTests/Networking/ServiceTest.swift +++ b/NativeAppTemplateTests/Networking/ServiceTest.swift @@ -180,6 +180,63 @@ struct ServiceTest { #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-2001] NativeAppTemplateAPIError::RequestFailed[Status: 502 | Error: UNKNOWN]") } + + // MARK: - Expired session + + /// devise_token_auth's authenticate_shopkeeper! answers a missing, wrong or expired + /// token with 401 and this body; the local API sends it for + /// `curl -H "access-token: bogus" .../api/v1/shopkeeper/permissions` + private nonisolated(unsafe) static let deviseUnauthenticatedBody: [String: Any] = [ + "errors": ["You need to sign in or sign up before continuing."] + ] + + @Test + func rejectedTokenIsAnnouncedAsAnExpiredSession() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(status: 401, Self.deviseUnauthenticatedBody) + } + defer { server.stop() } + // Unique, so another test's 401 can't be mistaken for this one's + let token = "expired-\(UUID().uuidString)" + + var error: NativeAppTemplateAPIError? + let tokens = await expiredSessionTokens { + error = try? await requestFailed { + _ = try await ItemTagsService(networkClient: server.client(authToken: token)).itemTagDetail(id: "t1") + } + } + + #expect(tokens.filter { $0 == token }.count == 1) + // The request still fails as before, for its caller to handle + guard case let .requestFailed(_, statusCode, _) = error else { + Issue.record("Expected requestFailed, got \(String(describing: error))") + return + } + #expect(statusCode == 401) + } + + /// The API's own 401 (BaseController#user_not_authorized for a Pundit denial) + /// carries an error_message and comes with a valid session; it must not sign + /// the user out + @Test + func authorizationFailureWithAnErrorMessageIsNotAnExpiredSession() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json( + status: 401, + ["code": 401, "error_message": "You are not authorized to perform this action."] + ) + } + defer { server.stop() } + let token = "valid-\(UUID().uuidString)" + + let tokens = await expiredSessionTokens { + _ = try? await requestFailed { + _ = try await ItemTagsService(networkClient: server.client(authToken: token)).itemTagDetail(id: "t1") + } + } + + #expect(!tokens.contains(token)) + } } /// Runs `body` and returns the NativeAppTemplateAPIError it throws @@ -193,3 +250,26 @@ func requestFailed(_ body: () async throws -> Void) async throws -> NativeAppTem Issue.record("Expected a NativeAppTemplateAPIError") throw CancellationError() } + +/// The tokens posted with `.apiSessionDidExpire` on NotificationCenter.default while `body` runs. +/// Service posts on the main actor and the observer has no queue, so it runs synchronously. +@MainActor +func expiredSessionTokens(_ body: () async -> Void) async -> [String] { + final class Tokens: @unchecked Sendable { + var values: [String] = [] + } + let tokens = Tokens() + let observer = NotificationCenter.default.addObserver( + forName: .apiSessionDidExpire, + object: nil, + queue: nil + ) { notification in + if let token = notification.userInfo?[Notification.authTokenKey] as? String { + tokens.values.append(token) + } + } + defer { NotificationCenter.default.removeObserver(observer) } + + await body() + return tokens.values +} diff --git a/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift b/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift index 9196de1..e878c9a 100644 --- a/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift +++ b/NativeAppTemplateTests/Sessions/SessionControllerPermissionsTest.swift @@ -58,8 +58,6 @@ struct SessionControllerPermissionsTest { let sessionController = makeSessionController(server: server) sessionController.connectionChanged(to: .online) - // The first response reassigns shopkeeper before shopLimitCount is set, so its - // didSet starts a second request; the state is .loaded once that one lands await waitUntil { sessionController.didFetchPermissions && sessionController.hasPermissions } let request = try #require(server.requests.first) @@ -74,6 +72,83 @@ struct SessionControllerPermissionsTest { #expect(sessionController.shopLimitCount == 3) } + /// Going online asks for the permissions once. The response used to be applied + /// by reassigning shopkeeper, whose didSet still saw shopLimitCount 0 and sent + /// a second request (and synced the locale twice). + @Test + func permissionsAreFetchedOnce() 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.hasPermissions } + // A second request would be sent as the first response is applied; give it time to arrive + try await Task.sleep(for: .milliseconds(500)) + + #expect(server.requests.filter { $0.path.hasSuffix("/shopkeeper/permissions") }.count == 1) + } + + /// Signing out forgets the permissions of the shopkeeper who left. Otherwise the + /// next sign-in starts with them: its didSet saw shopLimitCount 3 and marked the + /// permissions loaded, so the previous shopkeeper's privacy prompt showed. + @Test + func signingOutForgetsThePermissions() 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.hasPermissions } + // From meta above + try #require(sessionController.shouldUpdatePrivacy) + + try await sessionController.logout() + + // Nothing is known without a session: the properties' initial values + #expect(!sessionController.shouldUpdatePrivacy) + #expect(!sessionController.shouldUpdateTerms) + #expect(!sessionController.shouldUpdateApp) + #expect(sessionController.shopLimitCount == 0) + #expect(!sessionController.didFetchPermissions) + } + + /// End to end: launching with an expired token. The permissions request gets + /// devise_token_auth's 401, and the app signs out instead of showing + /// MainView's permissions error screen. + @Test + func expiredTokenAtLaunchSignsOut() 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() } + var shopkeeper = Self.shopkeeper + // Unique, so the notice can't sign out another test's SessionController + shopkeeper.token = "expired-\(UUID().uuidString)" + loginRepository.currentShopkeeper = shopkeeper + let sessionController = SessionController( + loginRepository: loginRepository, + monitorsConnection: false, + environment: server.client().environment + ) + + sessionController.connectionChanged(to: .online) + await waitUntil { !sessionController.isLoggedIn } + // The failed fetch's catch runs after the sign-out; give it time to land + try await Task.sleep(for: .milliseconds(200)) + + #expect(sessionController.didExpireSession) + #expect(loginRepository.currentShopkeeper == nil) + #expect(sessionController.permissionState == .notLoaded) + } + @Test func failedFetchIsAPermissionError() async throws { let server = try await TestHTTPServer.start { _ in diff --git a/NativeAppTemplateTests/Sessions/SessionControllerTest.swift b/NativeAppTemplateTests/Sessions/SessionControllerTest.swift index e1cfde5..b0181cd 100644 --- a/NativeAppTemplateTests/Sessions/SessionControllerTest.swift +++ b/NativeAppTemplateTests/Sessions/SessionControllerTest.swift @@ -78,4 +78,81 @@ struct SessionControllerTest { #expect(URLCache.shared.cachedResponse(for: request) == nil) } + + // MARK: - Expired session + + /// A signed-in SessionController listening on its own NotificationCenter, so + /// notices from other tests can't reach it + private func signedInSessionController( + token: String, + notificationCenter: NotificationCenter + ) throws -> (SessionController, TestLoginRepository) { + let loginRepository = TestLoginRepository() + loginRepository.currentShopkeeper = try #require(Shopkeeper( + id: "shopkeeper-1", + accountId: "account-1", + personalAccountId: "account-1", + accountOwnerId: "shopkeeper-1", + accountName: "Account", + email: "test@example.com", + name: "Test User", + timeZone: "Tokyo", + locale: "en", + uid: "test@example.com", + token: token, + client: "client-1", + expiry: "expiry-1" + )) + let sessionController = SessionController( + loginRepository: loginRepository, + monitorsConnection: false, + notificationCenter: notificationCenter + ) + return (sessionController, loginRepository) + } + + /// Every request fails once the token has expired, so the app signs out + /// locally and says why + @Test + func expiredTokenSignsOut() throws { + let notificationCenter = NotificationCenter() + let (sessionController, loginRepository) = try signedInSessionController( + token: "token-1", + notificationCenter: notificationCenter + ) + #expect(sessionController.isLoggedIn) + + notificationCenter.post( + name: .apiSessionDidExpire, + object: nil, + userInfo: [Notification.authTokenKey: "token-1"] + ) + + #expect(!sessionController.isLoggedIn) + #expect(sessionController.shopkeeper == nil) + // The saved session is gone too, so the next launch starts signed out + #expect(loginRepository.currentShopkeeper == nil) + #expect(sessionController.didExpireSession) + } + + /// A 401 for a token this session no longer uses (a request sent before the + /// user signed in again) is not about the current session + @Test + func expiryOfAnotherTokenKeepsTheSession() throws { + let notificationCenter = NotificationCenter() + let (sessionController, loginRepository) = try signedInSessionController( + token: "token-2", + notificationCenter: notificationCenter + ) + + notificationCenter.post( + name: .apiSessionDidExpire, + object: nil, + userInfo: [Notification.authTokenKey: "old-token"] + ) + + #expect(sessionController.isLoggedIn) + #expect(loginRepository.currentShopkeeper != nil) + #expect(!sessionController.didExpireSession) + } } diff --git a/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift b/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift index 11442ba..9749b15 100644 --- a/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift +++ b/NativeAppTemplateTests/Testing/JSONAPIFixtures.swift @@ -34,10 +34,11 @@ enum JSONAPIFixtures { currentPage: Int, totalPages: Int, totalCount: Int? = nil, - limit: Int = 20 + limit: Int = 20, + shopId: String = "shop-1" ) -> [String: Any] { [ - "data": ids.map { itemTag(id: $0) }, + "data": ids.map { itemTag(id: $0, shopId: shopId) }, "meta": [ "current_page": currentPage, "total_pages": totalPages, diff --git a/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift b/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift index 456f1a0..66bcdc3 100644 --- a/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift +++ b/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift @@ -14,6 +14,7 @@ import Foundation public var permissionState: PermissionState = .notLoaded public var shouldPopToRootView: Bool = false + public var didExpireSession: Bool = false public var shouldUpdateApp: Bool = false public var shouldUpdatePrivacy: Bool = false diff --git a/NativeAppTemplateTests/Testing/TestHTTPServer.swift b/NativeAppTemplateTests/Testing/TestHTTPServer.swift index 3f7831e..c9ea8f4 100644 --- a/NativeAppTemplateTests/Testing/TestHTTPServer.swift +++ b/NativeAppTemplateTests/Testing/TestHTTPServer.swift @@ -83,12 +83,12 @@ final class TestHTTPServer: @unchecked Sendable { } /// An API client whose requests go to this server - func client(accountId: String = "account-id") -> NativeAppTemplateAPI { + func client(accountId: String = "account-id", authToken: String = "token") -> NativeAppTemplateAPI { NativeAppTemplateAPI( // NativeAppTemplateEnvironment.swift is also compiled into the test target environment: NativeAppTemplate .NativeAppTemplateEnvironment(baseURL: URL(string: "http://127.0.0.1:\(port)")!), - authToken: "token", + authToken: authToken, client: "client", expiry: "expiry", uid: "uid", diff --git a/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift b/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift index 1d601a3..e2f9d49 100644 --- a/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift +++ b/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift @@ -6,6 +6,7 @@ import Foundation @testable import NativeAppTemplate import Testing +import UIKit @MainActor @Suite @@ -13,6 +14,14 @@ struct SignUpViewModelTest { let signUpRepository = TestSignUpRepository() let messageBus = MessageBus() + /// Sign-up creates a password, so the field is .newPassword (UITextContentType docs: + /// "the new password ... when creating an account or changing a password"), which + /// lets iOS suggest a strong one and save it + @Test + func passwordFieldIsANewPassword() { + #expect(SignUpViewModel.passwordContentType == .newPassword) + } + @Test func initialState() { let viewModel = SignUpViewModel( diff --git a/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift b/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift index 01b7ea4..659f9f6 100644 --- a/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Settings/PasswordEditViewModelTest.swift @@ -6,6 +6,7 @@ import Foundation @testable import NativeAppTemplate import Testing +import UIKit @MainActor @Suite @@ -15,6 +16,15 @@ struct PasswordEditViewModelTest { ) let messageBus = MessageBus() + /// The current password is an existing one (.password); the new password and its + /// confirmation are .newPassword (UITextContentType docs: "when creating an + /// account or changing a password") + @Test + func passwordFieldContentTypes() { + #expect(PasswordEditViewModel.currentPasswordContentType == .password) + #expect(PasswordEditViewModel.newPasswordContentType == .newPassword) + } + @Test func initializesCorrectly() { let viewModel = PasswordEditViewModel( diff --git a/NativeAppTemplateTests/Utilities/UtilityTest.swift b/NativeAppTemplateTests/Utilities/UtilityTest.swift index fac44fc..bd529f5 100644 --- a/NativeAppTemplateTests/Utilities/UtilityTest.swift +++ b/NativeAppTemplateTests/Utilities/UtilityTest.swift @@ -34,6 +34,33 @@ struct UtilityTest { #expect(Utility.currentTimeZone(timeZone, date: date) == expected) } + /// A city that is only part of a zone's name at another offset must not pick + /// that zone. The expected key is the first at the device's standard offset in + /// TimeZoneData.swift, as none at that offset contains the city: + /// - Australia/West is +08:00 ("West" is in International Date Line West, -12:00); + /// the first +08:00 zone is Beijing + /// - Pacific/Easter (Easter Island) is -06:00 ("Easter" is in Eastern Time, -05:00); + /// the first -06:00 zone is Central America + /// - America/Indiana/Petersburg is -05:00 ("Petersburg" is in St. Petersburg, +03:00); + /// the first -05:00 zone is Bogota + @Test(arguments: [ + ("Australia/West", "Beijing"), + ("Pacific/Easter", "Central America"), + ("America/Indiana/Petersburg", "Bogota") + ]) + func currentTimeZoneIgnoresACityInAZoneAtAnotherOffset(identifier: String, expected: String) throws { + let timeZone = try #require(TimeZone(identifier: identifier)) + #expect(Utility.currentTimeZone(timeZone, date: Self.winter) == expected) + } + + /// Part of a name still picks the zone when it is at the device's offset: + /// Atlantic/Cape_Verde (-01:00) is "Cape Verde Is." (-01:00) in the table + @Test + func currentTimeZoneMatchesPartOfANameAtTheSameOffset() throws { + let timeZone = try #require(TimeZone(identifier: "Atlantic/Cape_Verde")) + #expect(Utility.currentTimeZone(timeZone, date: Self.winter) == "Cape Verde Is.") + } + /// New York is not in the table, so the zone falls back to the offset. /// That must be its standard offset, -05:00 (America/New_York's base UTC /// offset), not the summer offset -04:00, which is Atlantic Time.