From 93a427696ccdd47e0d4c16926269518df58aff0d Mon Sep 17 00:00:00 2001 From: dadachi Date: Wed, 14 Oct 2026 18:08:48 +0900 Subject: [PATCH] Localize user-facing error messages; move developer details to the log CodedError descriptions were developer text ("NativeAppTemplateAPIError::NoData", " [Status: 500]") shown to the user in English. What the user sees is now localized (en + ja), with the same wording as Android: - NATIVEAPPTEMPLATE-2001 without the API's error_message: "Something went wrong while communicating with the server. Please try again later." - 2001 with error_message: the message as is, without the " [Status: nnn]" suffix - Developer-only errors (1001, 2002-2008, 4001-4004): "An unexpected error occurred." Codes and codedDescription's "[CODE] description" format are unchanged. Status codes, field names and underlying errors move to the new CodedError.debugDescription; Failure logs and URLSession.apiResponse log it (error.debugCodedDescription). Sign-up no longer shows "UNKNOWN" for a failure without error_message. AGENTS.md's localization rule is updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- AGENTS.md | 4 +- CHANGELOG.md | 3 ++ NativeAppTemplate.xcodeproj/project.pbxproj | 12 +++--- .../Common/Errors/AppError.swift | 5 ++- .../Common/Errors/CodedError.swift | 17 ++++++++ .../Common/Errors/EntityAdapterError.swift | 9 +++++ .../Common/Errors/KeychainStoreError.swift | 10 +++++ .../Errors/NativeAppTemplateAPIError.swift | 25 +++++++++--- NativeAppTemplate/Constants.swift | 9 +++++ .../AccountPasswordRepository.swift | 2 +- .../Data/Repositories/ItemTagRepository.swift | 18 ++++----- .../Data/Repositories/ShopRepository.swift | 10 ++--- NativeAppTemplate/Localizable.xcstrings | 20 ++++++++++ NativeAppTemplate/Logging/Logger.swift | 6 +++ NativeAppTemplate/Login/LoginRepository.swift | 4 +- .../Login/SignUpRepository.swift | 10 ++--- .../Networking/Services/Service.swift | 12 +++++- .../Sessions/SessionController.swift | 12 +++--- .../UI/App Root/SignUpViewModel.swift | 5 ++- .../Errors/EntityAdapterErrorTest.swift | 16 ++++++-- .../Errors/KeychainStoreErrorTest.swift | 21 ++++++++-- .../Networking/ServiceTest.swift | 14 ++++--- .../Networking/SessionsServiceTest.swift | 8 ++-- .../Networking/SignUpsServiceTest.swift | 8 ++-- .../UI/App Root/SignUpViewModelTest.swift | 25 ++++++++++++ .../ShopkeeperEditViewModelTest.swift | 2 +- .../UI/Shared/SaveThenDismissTest.swift | 4 +- .../Shop Detail/ShopDetailViewModelTest.swift | 2 +- .../Shop List/ShopCreateViewModelTest.swift | 6 +-- .../ItemTagDetailViewModelTest.swift | 4 +- .../ItemTagEditViewModelTest.swift | 4 +- .../ItemTagCreateViewModelTest.swift | 4 +- .../ShopBasicSettingsViewModelTest.swift | 6 +-- .../ShopSettingsViewModelTest.swift | 6 +-- .../Utilities/AppErrorTest.swift | 8 ++-- .../Utilities/CodedErrorTest.swift | 24 ++++++++++++ .../Utilities/MessageBusTest.swift | 3 +- .../NativeAppTemplateAPIErrorTest.swift | 39 ++++++++++++------- 38 files changed, 293 insertions(+), 104 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1676e92..43654c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,7 @@ All errors use the `CodedError` protocol in `NativeAppTemplate/Common/Errors/`. - New error types must conform to `CodedError` and be placed in `Common/Errors/` - Use `error.codedDescription` (not `error.localizedDescription`) in all error messages +- `errorDescription` is what the user sees, so it is localized (`String(localized:)` in `Constants.swift`): the API's `error_message` as is, or a generic text ("Something went wrong while communicating with the server. Please try again later." when a request fails without one, "An unexpected error occurred." for developer-only errors such as malformed responses or Keychain failures). Developer details (status codes, field names, underlying errors) go to `debugDescription`; log errors with `error.debugCodedDescription`, never show it - Use `Message(error: error)` convenience to post errors to `MessageBus` - Error code numbers must match across iOS and Android @@ -128,7 +129,8 @@ The app ships in English (development language) and Japanese. **Every new user-f - Keep the keys in English: nativeapptemplate-agent renames the English nouns in code and catalog keys together. - After building, Xcode adds new keys to `NativeAppTemplate/Localizable.xcstrings` (from the command line: `xcrun xcstringstool sync NativeAppTemplate/Localizable.xcstrings --stringsdata `). Add the `ja` value with state `translated`, keeping every `%@`/`%lld` (use `%1$@`, `%2$@` to reorder). Permission prompts go in `InfoPlist.xcstrings`. - Use the API's terms (`config/locales/ja.yml`), the same as the paid NativeAppTemplate-iOS: Shop is **店舗** (never ショップ/店/お店), ItemTag is **アイテムタグ** in full (never タグ or アイテム alone; the agent renames these Japanese nouns as substrings), Idled/Completed 未完了/完了. Don't translate the product name. -- Keep untranslated: product names, URLs, `NATIVEAPPTEMPLATE-xxxx` error codes and `CodedError` descriptions, log messages, the API's `error_message` (already localized by the server), and text the user typed. +- User-facing error text is localized, like any other UI text (`CodedError.errorDescription`); developer details go to logs (`debugDescription`). +- Keep untranslated: product names, URLs, `NATIVEAPPTEMPLATE-xxxx` error codes, log messages, the API's `error_message` (already localized by the server), and text the user typed. - Tests run in English (the scheme's test action sets `language = "en"`). - The app's language is set in iOS Settings, not in the app. Edit Profile shows it read-only; `SessionController` keeps `shopkeepers.locale` in step with it so emails match. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5620388..987fdd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Error messages are localized (English and Japanese). A request that fails without the API's `error_message` shows "Something went wrong while communicating with the server. Please try again later." (`NATIVEAPPTEMPLATE-2001`); developer-only errors (`NATIVEAPPTEMPLATE-1001`, `2002`–`2008`, `4001`–`4004`) show "An unexpected error occurred." The API's `error_message` is shown without the " [Status: nnn]" suffix. Error codes are unchanged; status codes, field names and underlying errors go to the log (`CodedError.debugDescription`) + ### Fixed - Debug builds lost the local API when launched other than from Xcode, e.g. from the Home Screen or when iOS relaunched the app after its language was changed in Settings: the endpoint came only from the scheme's environment variables, which only Xcode passes. It now comes from `API.xcconfig` (override it in the gitignored `API.local.xcconfig`) and is built into `Info.plist`; `NATIVEAPPTEMPLATE_API_*` environment variables still win. The scheme no longer sets them diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index 74b4098..e840878 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -62,7 +62,6 @@ 017204D925AA847E008FD63B /* ShopRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017204A925AA8449008FD63B /* ShopRepository.swift */; }; 017204FA25AA860F008FD63B /* MessageBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017204F925AA85F3008FD63B /* MessageBus.swift */; }; 0172051A25AAF6C0008FD63B /* SessionsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172051925AAF6BF008FD63B /* SessionsService.swift */; }; - 3DCDACF2A0069F085A61F98B /* RootPathService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF815E3C15C1BC7D878EE20 /* RootPathService.swift */; }; 0172052525AAFA43008FD63B /* Shopkeeper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172052425AAFA43008FD63B /* Shopkeeper.swift */; }; 0172052F25AC41A7008FD63B /* SessionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172052E25AC41A7008FD63B /* SessionRequest.swift */; }; 017278622D7D83E700CE424F /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172785C2D7D83E700CE424F /* ItemTag.swift */; }; @@ -84,7 +83,6 @@ 0172789D2D7D99D100CE424F /* ItemTagCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278962D7D99D100CE424F /* ItemTagCreateView.swift */; }; 0172789E2D7D99D100CE424F /* ItemTagEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 017278942D7D99D100CE424F /* ItemTagEditView.swift */; }; 0182D37825B277FA001E881D /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0182D37625B277D6001E881D /* KeychainStore.swift */; }; - A1B2C3D4E5F6A7B8C9D0E1F2 /* KeychainReinstallCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F3 /* KeychainReinstallCleanup.swift */; }; 0182D38225B296B9001E881D /* ShopkeeperAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0182D38125B296B9001E881D /* ShopkeeperAdapter.swift */; }; 0182D39A25B4424B001E881D /* LoggedInShopkeeperKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0182D39925B4424B001E881D /* LoggedInShopkeeperKeychainStore.swift */; }; 018E21CB2B36367F00FFD1F6 /* MeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018E21CA2B36367F00FFD1F6 /* MeRequest.swift */; }; @@ -128,7 +126,6 @@ 01E0A5B725BD0FCD00298D35 /* OfflineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A5B225BD0FC700298D35 /* OfflineView.swift */; }; 01E0A5B825BD0FCD00298D35 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A5B325BD0FC700298D35 /* ErrorView.swift */; }; 01E0A60125BD149200298D35 /* MainButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A5F925BD148800298D35 /* MainButtonView.swift */; }; - E6D6F71FD22DECE8CC643108 /* SaveThenDismiss.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D40C223000EA09B764706B /* SaveThenDismiss.swift */; }; 01E0A60C25BD440300298D35 /* SignInEmailAndPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A60B25BD440300298D35 /* SignInEmailAndPasswordView.swift */; }; 01E0A63025BD53FD00298D35 /* Shop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A62F25BD53FD00298D35 /* Shop.swift */; }; 01E1CECC287787A700E724FC /* SignUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E1CECB287787A700E724FC /* SignUpView.swift */; }; @@ -138,15 +135,18 @@ 01ED197B2A037B9E00CD4735 /* AppTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ED197A2A037B9E00CD4735 /* AppTabView.swift */; }; 01EE363E29A6DCEB009BCD9D /* ShopkeeperEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01EE363D29A6DCEB009BCD9D /* ShopkeeperEditView.swift */; }; 01FC03E22B3329B700E6CD8E /* NeedAppUpdatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01FC03E12B3329B700E6CD8E /* NeedAppUpdatesView.swift */; }; + 3DCDACF2A0069F085A61F98B /* RootPathService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF815E3C15C1BC7D878EE20 /* RootPathService.swift */; }; 4A8DA0DEF6F142C3A127058A /* PaginationMeta.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE8A6D1D27C458389C4F61A /* PaginationMeta.swift */; }; 7249A60C06FE44338E16BC50 /* APISessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C597C0551370446BB931F19B /* APISessionDelegate.swift */; }; A1B2C3D401000003 /* GlassCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D401000004 /* GlassCard.swift */; }; + A1B2C3D4E5F6A7B8C9D0E1F2 /* KeychainReinstallCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F3 /* KeychainReinstallCleanup.swift */; }; A2B3C4D500000002 /* CodedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B3C4D500000001 /* CodedError.swift */; }; A2B3C4D500000004 /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B3C4D500000003 /* AppError.swift */; }; A2B3C4D500000008 /* NativeAppTemplateAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B3C4D500000007 /* NativeAppTemplateAPIError.swift */; }; A2B3C4D50000000C /* EntityAdapterError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B3C4D50000000B /* EntityAdapterError.swift */; }; A2B3C4D50000000E /* KeychainStoreError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B3C4D50000000D /* KeychainStoreError.swift */; }; A370BAECACECFAB76D851427 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 412B1F0A34D186286A11F085 /* Keychain.swift */; }; + E6D6F71FD22DECE8CC643108 /* SaveThenDismiss.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D40C223000EA09B764706B /* SaveThenDismiss.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -218,7 +218,6 @@ 017204A925AA8449008FD63B /* ShopRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopRepository.swift; sourceTree = ""; }; 017204F925AA85F3008FD63B /* MessageBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBus.swift; sourceTree = ""; }; 0172051925AAF6BF008FD63B /* SessionsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsService.swift; sourceTree = ""; }; - DCF815E3C15C1BC7D878EE20 /* RootPathService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootPathService.swift; sourceTree = ""; }; 0172052425AAFA43008FD63B /* Shopkeeper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shopkeeper.swift; sourceTree = ""; }; 0172052E25AC41A7008FD63B /* SessionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRequest.swift; sourceTree = ""; }; 0172785C2D7D83E700CE424F /* ItemTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTag.swift; sourceTree = ""; }; @@ -240,7 +239,6 @@ 017278972D7D99D100CE424F /* ItemTagListCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagListCardView.swift; sourceTree = ""; }; 017278982D7D99D100CE424F /* ItemTagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTagListView.swift; sourceTree = ""; }; 0182D37625B277D6001E881D /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; - A1B2C3D4E5F6A7B8C9D0E1F3 /* KeychainReinstallCleanup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainReinstallCleanup.swift; sourceTree = ""; }; 0182D38125B296B9001E881D /* ShopkeeperAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopkeeperAdapter.swift; sourceTree = ""; }; 0182D39925B4424B001E881D /* LoggedInShopkeeperKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggedInShopkeeperKeychainStore.swift; sourceTree = ""; }; 018E21CA2B36367F00FFD1F6 /* MeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeRequest.swift; sourceTree = ""; }; @@ -286,7 +284,6 @@ 01E0A5B325BD0FC700298D35 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; 01E0A5B525BD0FC700298D35 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; 01E0A5F925BD148800298D35 /* MainButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainButtonView.swift; sourceTree = ""; }; - 99D40C223000EA09B764706B /* SaveThenDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveThenDismiss.swift; sourceTree = ""; }; 01E0A60B25BD440300298D35 /* SignInEmailAndPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInEmailAndPasswordView.swift; sourceTree = ""; }; 01E0A62F25BD53FD00298D35 /* Shop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shop.swift; sourceTree = ""; }; 01E1CECB287787A700E724FC /* SignUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpView.swift; sourceTree = ""; }; @@ -298,13 +295,16 @@ 01FC03E12B3329B700E6CD8E /* NeedAppUpdatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NeedAppUpdatesView.swift; sourceTree = ""; }; 2FE8A6D1D27C458389C4F61A /* PaginationMeta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationMeta.swift; sourceTree = ""; }; 412B1F0A34D186286A11F085 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; + 99D40C223000EA09B764706B /* SaveThenDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveThenDismiss.swift; sourceTree = ""; }; A1B2C3D401000004 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = ""; }; + A1B2C3D4E5F6A7B8C9D0E1F3 /* KeychainReinstallCleanup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainReinstallCleanup.swift; sourceTree = ""; }; A2B3C4D500000001 /* CodedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodedError.swift; sourceTree = ""; }; A2B3C4D500000003 /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; A2B3C4D500000007 /* NativeAppTemplateAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAppTemplateAPIError.swift; sourceTree = ""; }; A2B3C4D50000000B /* EntityAdapterError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityAdapterError.swift; sourceTree = ""; }; A2B3C4D50000000D /* KeychainStoreError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStoreError.swift; sourceTree = ""; }; C597C0551370446BB931F19B /* APISessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APISessionDelegate.swift; sourceTree = ""; }; + DCF815E3C15C1BC7D878EE20 /* RootPathService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootPathService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ diff --git a/NativeAppTemplate/Common/Errors/AppError.swift b/NativeAppTemplate/Common/Errors/AppError.swift index b8b7c7c..71b5d1c 100644 --- a/NativeAppTemplate/Common/Errors/AppError.swift +++ b/NativeAppTemplate/Common/Errors/AppError.swift @@ -22,8 +22,9 @@ enum AppError: CodedError { var errorDescription: String? { switch self { - case let .unexpected(description, _, _, _): - "An unexpected error occurred. \(description)" + case .unexpected: + // The description is developer detail: it is in debugDescription, for the log + Strings.unexpectedError } } diff --git a/NativeAppTemplate/Common/Errors/CodedError.swift b/NativeAppTemplate/Common/Errors/CodedError.swift index 94b08a8..e9b8a86 100644 --- a/NativeAppTemplate/Common/Errors/CodedError.swift +++ b/NativeAppTemplate/Common/Errors/CodedError.swift @@ -11,18 +11,35 @@ import Foundation +/// `errorDescription` is what the user sees, so it is localized; developer details +/// (status codes, field names, underlying errors) go to `debugDescription`, which is +/// for the log only. protocol CodedError: LocalizedError, Sendable { var errorCode: String { get } + + /// The error with its developer details, for the log only + var debugDescription: String { get } } extension CodedError { var formattedDescription: String { "[\(errorCode)] \(errorDescription ?? "Unknown error")" } + + /// Errors without developer details log what the user sees + var debugDescription: String { + formattedDescription + } } extension Error { var codedDescription: String { (self as? CodedError)?.formattedDescription ?? localizedDescription } + + /// Returns `debugDescription` if the error conforms to `CodedError`, otherwise + /// falls back to `localizedDescription`. For the log, not for the user. + var debugCodedDescription: String { + (self as? CodedError)?.debugDescription ?? localizedDescription + } } diff --git a/NativeAppTemplate/Common/Errors/EntityAdapterError.swift b/NativeAppTemplate/Common/Errors/EntityAdapterError.swift index 1506977..d29c74d 100644 --- a/NativeAppTemplate/Common/Errors/EntityAdapterError.swift +++ b/NativeAppTemplate/Common/Errors/EntityAdapterError.swift @@ -23,7 +23,16 @@ enum EntityAdapterError: CodedError { } } + /// Developer-only errors: the user sees the generic text, the log the case nonisolated var errorDescription: String? { + Strings.unexpectedError + } + + nonisolated var debugDescription: String { + "[\(errorCode)] \(detail)" + } + + private nonisolated var detail: String { let prefix = "EntityAdapterError::" switch self { case .invalidResourceTypeForAdapter: diff --git a/NativeAppTemplate/Common/Errors/KeychainStoreError.swift b/NativeAppTemplate/Common/Errors/KeychainStoreError.swift index 8679573..9c766e6 100644 --- a/NativeAppTemplate/Common/Errors/KeychainStoreError.swift +++ b/NativeAppTemplate/Common/Errors/KeychainStoreError.swift @@ -26,7 +26,17 @@ enum KeychainStoreError: CodedError { } } + /// Developer-only errors: the user sees the generic text, the log the case and the + /// underlying error nonisolated var errorDescription: String? { + Strings.unexpectedError + } + + nonisolated var debugDescription: String { + "[\(errorCode)] \(detail)" + } + + private nonisolated var detail: String { switch self { case let .secCallFailed(error): "KeychainStoreError::SecCallFailed[Error: \(error.localizedDescription)]" diff --git a/NativeAppTemplate/Common/Errors/NativeAppTemplateAPIError.swift b/NativeAppTemplate/Common/Errors/NativeAppTemplateAPIError.swift index 9f64207..fc9bbbc 100644 --- a/NativeAppTemplate/Common/Errors/NativeAppTemplateAPIError.swift +++ b/NativeAppTemplate/Common/Errors/NativeAppTemplateAPIError.swift @@ -27,15 +27,28 @@ enum NativeAppTemplateAPIError: CodedError { } } + /// What the user sees: the API's error_message (already localized) as is, else a + /// localized generic text. The status and other developer details are in + /// `debugDescription`, for the log. nonisolated var errorDescription: String? { + switch self { + case let .requestFailed(_, _, message): + message ?? Strings.serverCommunicationError + case .processingError, .responseMissingRequiredMeta, .responseHasIncorrectNumberOfElements, .noData: + Strings.unexpectedError + } + } + + nonisolated var debugDescription: String { + "[\(errorCode)] \(detail)" + } + + private nonisolated var detail: String { switch self { case let .requestFailed(error, statusCode, message): - if let message { - "\(message) [Status: \(statusCode)]" - } else { - "NativeAppTemplateAPIError::RequestFailed" + - "[Status: \(statusCode) | Error: \(error?.localizedDescription ?? "UNKNOWN")]" - } + "NativeAppTemplateAPIError::RequestFailed[Status: \(statusCode)" + + " | Message: \(message ?? "NONE")" + + " | Error: \(error?.localizedDescription ?? "UNKNOWN")]" case let .processingError(error): "NativeAppTemplateAPIError::ProcessingError[Error: \(error?.localizedDescription ?? "UNKNOWN")]" case let .responseMissingRequiredMeta(field: field): diff --git a/NativeAppTemplate/Constants.swift b/NativeAppTemplate/Constants.swift index f997360..204c8ae 100644 --- a/NativeAppTemplate/Constants.swift +++ b/NativeAppTemplate/Constants.swift @@ -278,6 +278,15 @@ enum Strings { static let updateApp = String(localized: "Update App") static let installNewVersionApp = String(localized: "Please install new version app.") + // MARK: Errors + + /// A request failed and the API sent no error_message (e.g. a 5xx without a JSON body) + static let serverCommunicationError = + String(localized: "Something went wrong while communicating with the server. Please try again later.") + /// What the user sees for a developer-only error (a malformed response, a Keychain + /// failure, ...); its details go to the log + static let unexpectedError = String(localized: "An unexpected error occurred.") + // MARK: Onboarding static let signIn = String(localized: "Sign In") diff --git a/NativeAppTemplate/Data/Repositories/AccountPasswordRepository.swift b/NativeAppTemplate/Data/Repositories/AccountPasswordRepository.swift index cda724d..ea03ae8 100644 --- a/NativeAppTemplate/Data/Repositories/AccountPasswordRepository.swift +++ b/NativeAppTemplate/Data/Repositories/AccountPasswordRepository.swift @@ -17,7 +17,7 @@ try await accountPasswordService.updatePassword(updatePassword: updatePassword) } catch { Failure - .update(from: Self.self, reason: error.codedDescription) + .update(from: Self.self, reason: error.debugCodedDescription) .log() throw error } diff --git a/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift index 4351fc4..8f69587 100644 --- a/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift +++ b/NativeAppTemplate/Data/Repositories/ItemTagRepository.swift @@ -59,7 +59,7 @@ import SwiftUI state = .failed Failure - .fetch(from: Self.self, reason: error.codedDescription) + .fetch(from: Self.self, reason: error.debugCodedDescription) .log() } } @@ -88,7 +88,7 @@ import SwiftUI paginationMeta = response.paginationMeta } catch { Failure - .fetch(from: Self.self, reason: error.codedDescription) + .fetch(from: Self.self, reason: error.debugCodedDescription) .log() } guard generation == listGeneration else { return } @@ -105,7 +105,7 @@ import SwiftUI return try await itemTagsService.allItemTags(shopId: shopId).itemTags } catch { Failure - .fetch(from: Self.self, reason: error.codedDescription) + .fetch(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -124,7 +124,7 @@ import SwiftUI return itemTag } catch { Failure - .fetch(from: Self.self, reason: error.codedDescription) + .fetch(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -135,7 +135,7 @@ import SwiftUI return try await itemTagsService.makeItemTag(shopId: shopId, itemTag: itemTag) } catch { Failure - .create(from: Self.self, reason: error.codedDescription) + .create(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -152,7 +152,7 @@ import SwiftUI return updatedItemTag } catch { Failure - .update(from: Self.self, reason: error.codedDescription) + .update(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -163,7 +163,7 @@ import SwiftUI try await itemTagsService.destroyItemTag(id: id) } catch { Failure - .destroy(from: Self.self, reason: error.codedDescription) + .destroy(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -181,7 +181,7 @@ import SwiftUI } catch { // The caller reports the error; the list's own state is unchanged Failure - .update(from: Self.self, reason: error.codedDescription) + .update(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -199,7 +199,7 @@ import SwiftUI } catch { // The caller reports the error; the list's own state is unchanged Failure - .update(from: Self.self, reason: error.codedDescription) + .update(from: Self.self, reason: error.debugCodedDescription) .log() throw error } diff --git a/NativeAppTemplate/Data/Repositories/ShopRepository.swift b/NativeAppTemplate/Data/Repositories/ShopRepository.swift index d33b758..c888b76 100644 --- a/NativeAppTemplate/Data/Repositories/ShopRepository.swift +++ b/NativeAppTemplate/Data/Repositories/ShopRepository.swift @@ -41,7 +41,7 @@ import SwiftUI } catch { state = .failed Failure - .fetch(from: Self.self, reason: error.codedDescription) + .fetch(from: Self.self, reason: error.debugCodedDescription) .log() } } @@ -60,7 +60,7 @@ import SwiftUI return shop } catch { Failure - .fetch(from: Self.self, reason: error.codedDescription) + .fetch(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -71,7 +71,7 @@ import SwiftUI return try await shopsService.makeShop(shop: shop) } catch { Failure - .create(from: Self.self, reason: error.codedDescription) + .create(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -88,7 +88,7 @@ import SwiftUI return updatedShop } catch { Failure - .update(from: Self.self, reason: error.codedDescription) + .update(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -99,7 +99,7 @@ import SwiftUI try await shopsService.destroyShop(id: id) } catch { Failure - .destroy(from: Self.self, reason: error.codedDescription) + .destroy(from: Self.self, reason: error.debugCodedDescription) .log() throw error } diff --git a/NativeAppTemplate/Localizable.xcstrings b/NativeAppTemplate/Localizable.xcstrings index a398f42..cbc8cd5 100644 --- a/NativeAppTemplate/Localizable.xcstrings +++ b/NativeAppTemplate/Localizable.xcstrings @@ -131,6 +131,16 @@ } } }, + "An unexpected error occurred." : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "予期しないエラーが発生しました。" + } + } + } + }, "App" : { "localizations" : { "ja" : { @@ -1119,6 +1129,16 @@ } } }, + "Something went wrong while communicating with the server. Please try again later." : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "サーバーとの通信中に問題が発生しました。しばらくしてからもう一度お試しください。" + } + } + } + }, "Something went wrong." : { "localizations" : { "ja" : { diff --git a/NativeAppTemplate/Logging/Logger.swift b/NativeAppTemplate/Logging/Logger.swift index 692bec8..6e31ab3 100644 --- a/NativeAppTemplate/Logging/Logger.swift +++ b/NativeAppTemplate/Logging/Logger.swift @@ -32,6 +32,12 @@ struct Failure { .init(source: source, action: "destroy", reason: reason) } + /// An API request failed or its response could not be read; `reason` is the + /// error's developer details (`debugCodedDescription`), which the user does not see + static func request(from source: (some Any).Type, reason: String) -> Self { + .init(source: source, action: "request", reason: reason) + } + private init( source: Source.Type, action: String, diff --git a/NativeAppTemplate/Login/LoginRepository.swift b/NativeAppTemplate/Login/LoginRepository.swift index e0cb37d..53cb0ea 100644 --- a/NativeAppTemplate/Login/LoginRepository.swift +++ b/NativeAppTemplate/Login/LoginRepository.swift @@ -39,7 +39,7 @@ import os _currentShopkeeper = shopkeeper } catch { Failure - .login(from: Self.self, reason: error.codedDescription) + .login(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -54,7 +54,7 @@ import os _currentShopkeeper = .none } catch { Failure - .logout(from: Self.self, reason: error.codedDescription) + .logout(from: Self.self, reason: error.debugCodedDescription) .log() removeShopkeeper() _currentShopkeeper = .none diff --git a/NativeAppTemplate/Login/SignUpRepository.swift b/NativeAppTemplate/Login/SignUpRepository.swift index 5f1c472..9052300 100644 --- a/NativeAppTemplate/Login/SignUpRepository.swift +++ b/NativeAppTemplate/Login/SignUpRepository.swift @@ -21,7 +21,7 @@ import os shopkeeper = try await signUpsService.makeShopkeeper(signUp: signUp) } catch { Failure - .create(from: Self.self, reason: error.codedDescription) + .create(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -36,7 +36,7 @@ import os shopkeeper = try await signUpsService.updateShopkeeper(signUp: signUp) } catch { Failure - .update(from: Self.self, reason: error.codedDescription) + .update(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -50,7 +50,7 @@ import os removeShopkeeper() } catch { Failure - .destroy(from: Self.self, reason: error.codedDescription) + .destroy(from: Self.self, reason: error.debugCodedDescription) .log() removeShopkeeper() @@ -70,7 +70,7 @@ import os try await signUpsService.sendResetPasswordInstruction(sendResetPassword: sendResetPassword) } catch { Failure - .fetch(from: Self.self, reason: error.codedDescription) + .fetch(from: Self.self, reason: error.debugCodedDescription) .log() throw error @@ -89,7 +89,7 @@ import os try await signUpsService.sendConfirmationInstruction(sendConfirmation: sendConfirmation) } catch { Failure - .fetch(from: Self.self, reason: error.codedDescription) + .fetch(from: Self.self, reason: error.debugCodedDescription) .log() throw error } diff --git a/NativeAppTemplate/Networking/Services/Service.swift b/NativeAppTemplate/Networking/Services/Service.swift index 992f5d2..ca840b8 100644 --- a/NativeAppTemplate/Networking/Services/Service.swift +++ b/NativeAppTemplate/Networking/Services/Service.swift @@ -107,10 +107,18 @@ extension URLSession { ) } - throw NativeAppTemplateAPIError.requestFailed(nil, statusCode ?? 0, errorMessage) + let error = NativeAppTemplateAPIError.requestFailed(nil, statusCode ?? 0, errorMessage) + // The status is developer detail: the log gets it, the user the message + Failure.request(from: Request.self, reason: error.debugCodedDescription).log() + throw error } - return try request.handle(response: data) + do { + return try request.handle(response: data) + } catch { + Failure.request(from: Request.self, reason: error.debugCodedDescription).log() + throw error + } } } diff --git a/NativeAppTemplate/Sessions/SessionController.swift b/NativeAppTemplate/Sessions/SessionController.swift index f20fe68..234ca60 100644 --- a/NativeAppTemplate/Sessions/SessionController.swift +++ b/NativeAppTemplate/Sessions/SessionController.swift @@ -144,7 +144,7 @@ import Observation permissionState = .notLoaded Failure - .login(from: Self.self, reason: error.codedDescription) + .login(from: Self.self, reason: error.debugCodedDescription) .log() throw error @@ -161,7 +161,7 @@ import Observation try await loginRepository.logout(networkClient: client) } catch { Failure - .logout(from: Self.self, reason: error.codedDescription) + .logout(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -228,7 +228,7 @@ import Observation } catch { enum Permissions {} Failure - .fetch(from: Permissions.self, reason: error.codedDescription) + .fetch(from: Permissions.self, reason: error.debugCodedDescription) .log() // An expired session has signed out already; that is not a permissions error @@ -254,7 +254,7 @@ import Observation try updateShopkeeper(shopkeeper: shopkeeper) } catch { Failure - .update(from: Self.self, reason: error.codedDescription) + .update(from: Self.self, reason: error.debugCodedDescription) .log() } } @@ -270,7 +270,7 @@ import Observation shouldUpdatePrivacy = false } catch { Failure - .update(from: Self.self, reason: error.codedDescription) + .update(from: Self.self, reason: error.debugCodedDescription) .log() throw error } @@ -282,7 +282,7 @@ import Observation shouldUpdateTerms = false } catch { Failure - .update(from: Self.self, reason: error.codedDescription) + .update(from: Self.self, reason: error.debugCodedDescription) .log() throw error } diff --git a/NativeAppTemplate/UI/App Root/SignUpViewModel.swift b/NativeAppTemplate/UI/App Root/SignUpViewModel.swift index aa536f2..864cba1 100644 --- a/NativeAppTemplate/UI/App Root/SignUpViewModel.swift +++ b/NativeAppTemplate/UI/App Root/SignUpViewModel.swift @@ -110,8 +110,9 @@ final class SignUpViewModel { autoDismiss: false )) shouldDismiss = true - } catch let NativeAppTemplateAPIError.requestFailed(_, _, message) { - errorMessage = message ?? "UNKNOWN" + } catch let NativeAppTemplateAPIError.requestFailed(_, _, message?) { + // The API's error_message as is; without one, the code and the generic text below + errorMessage = message isShowingAlert = true } catch { errorMessage = error.codedDescription diff --git a/NativeAppTemplateTests/Common/Errors/EntityAdapterErrorTest.swift b/NativeAppTemplateTests/Common/Errors/EntityAdapterErrorTest.swift index bf72f4e..c8fe022 100644 --- a/NativeAppTemplateTests/Common/Errors/EntityAdapterErrorTest.swift +++ b/NativeAppTemplateTests/Common/Errors/EntityAdapterErrorTest.swift @@ -16,17 +16,18 @@ struct EntityAdapterErrorTest { @Test func formattedDescriptions() { + // The generic text for developer-only errors, worded as decided for iOS and Android (2026-10-14) #expect( EntityAdapterError.invalidResourceTypeForAdapter.formattedDescription - == "[NATIVEAPPTEMPLATE-2006] EntityAdapterError::InvalidResourceTypeForAdapter" + == "[NATIVEAPPTEMPLATE-2006] An unexpected error occurred." ) #expect( EntityAdapterError.invalidOrMissingAttributes.formattedDescription - == "[NATIVEAPPTEMPLATE-2007] EntityAdapterError::InvalidOrMissingAttributes" + == "[NATIVEAPPTEMPLATE-2007] An unexpected error occurred." ) #expect( EntityAdapterError.invalidOrMissingRelationships.formattedDescription - == "[NATIVEAPPTEMPLATE-2008] EntityAdapterError::InvalidOrMissingRelationships" + == "[NATIVEAPPTEMPLATE-2008] An unexpected error occurred." ) } @@ -34,6 +35,13 @@ struct EntityAdapterErrorTest { func codedDescriptionPrependsCode() { let error: Error = EntityAdapterError.invalidOrMissingAttributes - #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-2007] EntityAdapterError::InvalidOrMissingAttributes") + #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-2007] An unexpected error occurred.") + } + + /// The log still names the case + @Test + func debugDescriptionNamesTheCase() { + #expect(EntityAdapterError.invalidOrMissingAttributes.debugDescription + == "[NATIVEAPPTEMPLATE-2007] EntityAdapterError::InvalidOrMissingAttributes") } } diff --git a/NativeAppTemplateTests/Common/Errors/KeychainStoreErrorTest.swift b/NativeAppTemplateTests/Common/Errors/KeychainStoreErrorTest.swift index a798564..ca9f4f9 100644 --- a/NativeAppTemplateTests/Common/Errors/KeychainStoreErrorTest.swift +++ b/NativeAppTemplateTests/Common/Errors/KeychainStoreErrorTest.swift @@ -20,13 +20,18 @@ struct KeychainStoreErrorTest { @Test func formattedDescriptions() { + // The generic text for developer-only errors, worded as decided for iOS and Android (2026-10-14) #expect( KeychainStoreError.notFound.formattedDescription - == "[NATIVEAPPTEMPLATE-4002] KeychainStoreError::NotFound" + == "[NATIVEAPPTEMPLATE-4002] An unexpected error occurred." ) #expect( KeychainStoreError.badData.formattedDescription - == "[NATIVEAPPTEMPLATE-4003] KeychainStoreError::BadData" + == "[NATIVEAPPTEMPLATE-4003] An unexpected error occurred." + ) + #expect( + KeychainStoreError.secCallFailed(NSError(domain: "test", code: 1)).formattedDescription + == "[NATIVEAPPTEMPLATE-4001] An unexpected error occurred." ) } @@ -34,6 +39,16 @@ struct KeychainStoreErrorTest { func codedDescriptionPrependsCode() { let error: Error = KeychainStoreError.notFound - #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-4002] KeychainStoreError::NotFound") + #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-4002] An unexpected error occurred.") + } + + /// The log keeps the case and the underlying error + @Test + func debugDescriptionKeepsTheUnderlyingError() { + let error = KeychainStoreError.archiveFailure(NSError(domain: "test", code: 7)) + + #expect(error.debugDescription.hasPrefix("[NATIVEAPPTEMPLATE-4004] KeychainStoreError::ArchiveFailure")) + // NSError's localizedDescription names the domain and code: "The operation couldn’t be completed. (test error 7.)" + #expect(error.debugDescription.contains("test error 7")) } } diff --git a/NativeAppTemplateTests/Networking/ServiceTest.swift b/NativeAppTemplateTests/Networking/ServiceTest.swift index 39adb5d..fcab648 100644 --- a/NativeAppTemplateTests/Networking/ServiceTest.swift +++ b/NativeAppTemplateTests/Networking/ServiceTest.swift @@ -99,8 +99,9 @@ struct ServiceTest { } #expect(statusCode == 422) #expect(message == "Name has already been taken") - // NativeAppTemplateAPIError.errorDescription: "\(message) [Status: \(statusCode)]", prefixed with the code - #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-2001] Name has already been taken [Status: 422]") + // The API's error_message as is, prefixed with the code; the status is for the log only + #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-2001] Name has already been taken") + #expect(error.debugDescription.contains("Status: 422")) } @Test @@ -158,7 +159,7 @@ struct ServiceTest { /// The HTML page has no error_message to show, so the message is nil and the /// user gets the generic text, as for a JSON body without error_message. - /// An empty message would read "[NATIVEAPPTEMPLATE-2001] [Status: 502]". + /// An empty message would read "[NATIVEAPPTEMPLATE-2001] ". @Test func errorStatusWithANonJsonBodyHasNoMessage() async throws { let server = try await TestHTTPServer.start { _ in @@ -175,10 +176,11 @@ struct ServiceTest { return } #expect(message == nil) - // NativeAppTemplateAPIError.errorDescription without a message and without an underlying error: - // "NativeAppTemplateAPIError::RequestFailed[Status: \(statusCode) | Error: UNKNOWN]", prefixed with the code + // Without error_message the user gets the generic communication error, worded as + // decided for iOS and Android (2026-10-14); the status goes to the log only #expect(error.codedDescription == - "[NATIVEAPPTEMPLATE-2001] NativeAppTemplateAPIError::RequestFailed[Status: 502 | Error: UNKNOWN]") + "[NATIVEAPPTEMPLATE-2001] Something went wrong while communicating with the server." + " Please try again later.") + #expect(error.debugDescription.contains("Status: 502")) } // MARK: - Expired session diff --git a/NativeAppTemplateTests/Networking/SessionsServiceTest.swift b/NativeAppTemplateTests/Networking/SessionsServiceTest.swift index 7f1be78..a157e9f 100644 --- a/NativeAppTemplateTests/Networking/SessionsServiceTest.swift +++ b/NativeAppTemplateTests/Networking/SessionsServiceTest.swift @@ -12,7 +12,7 @@ import Testing @Suite(.serialized) struct SessionsServiceTest { /// As in ServiceTest: an HTML error page has no error_message, so the user - /// gets the generic text rather than "[NATIVEAPPTEMPLATE-2001] [Status: 502]" + /// gets the generic text rather than "[NATIVEAPPTEMPLATE-2001] " @Test func errorStatusWithANonJsonBodyHasNoMessage() async throws { let server = try await TestHTTPServer.start { _ in @@ -30,9 +30,11 @@ struct SessionsServiceTest { return } #expect(message == nil) - // NativeAppTemplateAPIError.errorDescription without a message or underlying error + // Without error_message the user gets the generic communication error, worded as + // decided for iOS and Android (2026-10-14); the status goes to the log only #expect(error.codedDescription == - "[NATIVEAPPTEMPLATE-2001] NativeAppTemplateAPIError::RequestFailed[Status: 502 | Error: UNKNOWN]") + "[NATIVEAPPTEMPLATE-2001] Something went wrong while communicating with the server." + " Please try again later.") + #expect(error.debugDescription.contains("Status: 502")) } /// A sign-in response without data is a processing error (NATIVEAPPTEMPLATE-2002, diff --git a/NativeAppTemplateTests/Networking/SignUpsServiceTest.swift b/NativeAppTemplateTests/Networking/SignUpsServiceTest.swift index 1945f5d..a46e7a4 100644 --- a/NativeAppTemplateTests/Networking/SignUpsServiceTest.swift +++ b/NativeAppTemplateTests/Networking/SignUpsServiceTest.swift @@ -31,7 +31,7 @@ struct SignUpsServiceTest { } /// As in ServiceTest: an HTML error page has no error_message, so the user - /// gets the generic text rather than "[NATIVEAPPTEMPLATE-2001] [Status: 502]" + /// gets the generic text rather than "[NATIVEAPPTEMPLATE-2001] " @Test func errorStatusWithANonJsonBodyHasNoMessage() async throws { let server = try await TestHTTPServer.start { _ in @@ -48,8 +48,10 @@ struct SignUpsServiceTest { return } #expect(message == nil) - // NativeAppTemplateAPIError.errorDescription without a message or underlying error + // Without error_message the user gets the generic communication error, worded as + // decided for iOS and Android (2026-10-14); the status goes to the log only #expect(error.codedDescription == - "[NATIVEAPPTEMPLATE-2001] NativeAppTemplateAPIError::RequestFailed[Status: 502 | Error: UNKNOWN]") + "[NATIVEAPPTEMPLATE-2001] Something went wrong while communicating with the server." + " Please try again later.") + #expect(error.debugDescription.contains("Status: 502")) } } diff --git a/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift b/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift index fb49e76..01164ce 100644 --- a/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift +++ b/NativeAppTemplateTests/UI/App Root/SignUpViewModelTest.swift @@ -227,6 +227,31 @@ struct SignUpViewModelTest { #expect(viewModel.isCreating == false) } + /// A failure without the API's error_message (e.g. a 500 without a JSON body) + /// used to show "UNKNOWN" + @Test + func createShopkeeperErrorWithoutMessageShowsTheGenericCommunicationError() async { + signUpRepository.error = NativeAppTemplateAPIError.requestFailed(nil, 500, nil) + + let viewModel = SignUpViewModel( + signUpRepository: signUpRepository, + messageBus: messageBus + ) + + viewModel.name = "John Doe" + viewModel.email = "john@example.com" + viewModel.password = "password123" + + viewModel.createShopkeeper() + + await waitUntil { viewModel.isShowingAlert } + + // requestFailed's codedDescription without a message: the generic communication + // error, worded as decided for iOS and Android (2026-10-14) + #expect(viewModel.errorMessage == "[NATIVEAPPTEMPLATE-2001] Something went wrong while communicating" + + " with the server. Please try again later.") + } + @Test func createShopkeeperWithInvalidData() { let viewModel = SignUpViewModel( diff --git a/NativeAppTemplateTests/UI/Settings/ShopkeeperEditViewModelTest.swift b/NativeAppTemplateTests/UI/Settings/ShopkeeperEditViewModelTest.swift index b76b7a2..087ae90 100644 --- a/NativeAppTemplateTests/UI/Settings/ShopkeeperEditViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Settings/ShopkeeperEditViewModelTest.swift @@ -391,7 +391,7 @@ struct ShopkeeperEditViewModelTest { // swiftlint:disable:this type_body_length #expect(messageBus.currentMessage?.autoDismiss == false) // " " + NativeAppTemplateAPIError.requestFailed's codedDescription #expect(messageBus.currentMessage?.message == - "\(Strings.shopkeeperDeletedError) [NATIVEAPPTEMPLATE-2001] Delete failed [Status: 500]") + "\(Strings.shopkeeperDeletedError) [NATIVEAPPTEMPLATE-2001] Delete failed") } @Test diff --git a/NativeAppTemplateTests/UI/Shared/SaveThenDismissTest.swift b/NativeAppTemplateTests/UI/Shared/SaveThenDismissTest.swift index 484b3ad..457bf08 100644 --- a/NativeAppTemplateTests/UI/Shared/SaveThenDismissTest.swift +++ b/NativeAppTemplateTests/UI/Shared/SaveThenDismissTest.swift @@ -47,7 +47,7 @@ struct SaveThenDismissTest { #expect(dismissCount == 0) #expect(messageBus.currentMessage?.level == .error) // Message(error:) shows NativeAppTemplateAPIError.requestFailed's codedDescription: - // "[NATIVEAPPTEMPLATE-2001] [Status: ]" - #expect(messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") + // "[NATIVEAPPTEMPLATE-2001] " + #expect(messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message)") } } diff --git a/NativeAppTemplateTests/UI/Shop Detail/ShopDetailViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Detail/ShopDetailViewModelTest.swift index 64e851a..bc07567 100644 --- a/NativeAppTemplateTests/UI/Shop Detail/ShopDetailViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Detail/ShopDetailViewModelTest.swift @@ -106,7 +106,7 @@ struct ShopDetailViewModelTest { // swiftlint:disable:this type_body_length viewModel.reload() await waitUntil { viewModel.shouldDismiss } - #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") + #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message)") #expect(viewModel.shouldDismiss) } diff --git a/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift b/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift index 7f1c00f..a22824b 100644 --- a/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift @@ -163,7 +163,7 @@ struct ShopCreateViewModelTest { viewModel.createShop() await waitUntil { messageBus.currentMessage != nil } - #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") + #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message)") #expect(viewModel.isCreating) #expect(shopRepository.shops.count == createdShopsCount) #expect(viewModel.shouldDismiss) @@ -195,8 +195,8 @@ struct ShopCreateViewModelTest { await waitUntil { viewModel.messageBus.currentMessage != nil && !viewModel.isCreating } // NativeAppTemplateAPIError.requestFailed's codedDescription: - // "[NATIVEAPPTEMPLATE-2001] [Status: ]" - #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") + // "[NATIVEAPPTEMPLATE-2001] " + #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message)") // A server or network error keeps the form open, so the user can retry. // ShopCreateViewModel no longer holds the session, so it can't sign the user out. #expect(!viewModel.isCreating) diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModelTest.swift index 09b2a34..c915d19 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagDetailViewModelTest.swift @@ -287,9 +287,9 @@ struct ItemTagDetailViewModelTest { #expect(messageBus.currentMessage?.level == .error) #expect(messageBus.currentMessage?.autoDismiss == false) // " " + NativeAppTemplateAPIError.requestFailed's codedDescription - // "[NATIVEAPPTEMPLATE-2001] [Status: ]" (ItemTagDetailViewModel.destroyItemTag) + // "[NATIVEAPPTEMPLATE-2001] " (ItemTagDetailViewModel.destroyItemTag) #expect(messageBus.currentMessage?.message == - "\(Strings.itemTagDeletedError) [NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") + "\(Strings.itemTagDeletedError) [NATIVEAPPTEMPLATE-2001] \(message)") #expect(itemTagRepository.itemTags.count == 1) } diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModelTest.swift index 34cbccf..77ddb19 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag Detail/ItemTagEditViewModelTest.swift @@ -299,8 +299,8 @@ struct ItemTagEditViewModelTest { #expect(viewModel.shouldDismiss == false) #expect(viewModel.name == "Updated") // Message(error:) shows NativeAppTemplateAPIError.requestFailed's codedDescription: - // "[NATIVEAPPTEMPLATE-2001] [Status: ]" - #expect(messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") + // "[NATIVEAPPTEMPLATE-2001] " + #expect(messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message)") #expect(messageBus.currentMessage?.level == .error) #expect(messageBus.currentMessage?.autoDismiss == false) } diff --git a/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagCreateViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagCreateViewModelTest.swift index a661289..c82af1e 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagCreateViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ItemTag List/ItemTagCreateViewModelTest.swift @@ -168,8 +168,8 @@ struct ItemTagCreateViewModelTest { #expect(viewModel.isBusy == false) #expect(viewModel.name == "Buy milk") // Message(error:) shows NativeAppTemplateAPIError.requestFailed's codedDescription: - // "[NATIVEAPPTEMPLATE-2001] [Status: ]" - #expect(messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") + // "[NATIVEAPPTEMPLATE-2001] " + #expect(messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message)") #expect(messageBus.currentMessage?.level == .error) #expect(messageBus.currentMessage?.autoDismiss == false) #expect(itemTagRepository.itemTags.isEmpty) diff --git a/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift index ae18d3e..4929596 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift @@ -214,7 +214,7 @@ struct ShopBasicSettingsViewModelTest { viewModel.reload() await waitUntil { viewModel.shouldDismiss } - #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") + #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message)") #expect(viewModel.shouldDismiss) } @@ -286,8 +286,8 @@ struct ShopBasicSettingsViewModelTest { await waitUntil { viewModel.messageBus.currentMessage != nil && !viewModel.isUpdating } // NativeAppTemplateAPIError.requestFailed's codedDescription: - // "[NATIVEAPPTEMPLATE-2001] [Status: ]" - #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") + // "[NATIVEAPPTEMPLATE-2001] " + #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message)") #expect(viewModel.isUpdating == false) #expect(viewModel.isBusy == false) // A failed save keeps the form open with the user's input so they can retry diff --git a/NativeAppTemplateTests/UI/Shop Settings/ShopSettingsViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ShopSettingsViewModelTest.swift index 7c6eec4..14a2e10 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ShopSettingsViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ShopSettingsViewModelTest.swift @@ -83,7 +83,7 @@ struct ShopSettingsViewModelTest { viewModel.reload() await waitUntil { !viewModel.isFetching } - #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") + #expect(viewModel.messageBus.currentMessage?.message == "[NATIVEAPPTEMPLATE-2001] \(message)") #expect(viewModel.shouldDismiss) #expect(viewModel.isFetching == false) #expect(viewModel.isBusy == false) @@ -141,9 +141,9 @@ struct ShopSettingsViewModelTest { await waitUntil { viewModel.messageBus.currentMessage != nil && !viewModel.isDeleting } // " " + NativeAppTemplateAPIError.requestFailed's codedDescription - // "[NATIVEAPPTEMPLATE-2001] [Status: ]" + // "[NATIVEAPPTEMPLATE-2001] " #expect(viewModel.messageBus.currentMessage?.message == - "\(Strings.shopDeletedError) [NATIVEAPPTEMPLATE-2001] \(message) [Status: \(httpResponseCode)]") + "\(Strings.shopDeletedError) [NATIVEAPPTEMPLATE-2001] \(message)") // A failed delete leaves the shop and the session as they were, and the // user can retry #expect(!viewModel.isDeleting) diff --git a/NativeAppTemplateTests/Utilities/AppErrorTest.swift b/NativeAppTemplateTests/Utilities/AppErrorTest.swift index 7c71022..e4b964a 100644 --- a/NativeAppTemplateTests/Utilities/AppErrorTest.swift +++ b/NativeAppTemplateTests/Utilities/AppErrorTest.swift @@ -16,13 +16,15 @@ struct AppErrorTest { @Test func unexpectedErrorDescription() { let error = AppError.unexpected(description: "Something broke") - #expect(error.errorDescription == "An unexpected error occurred. Something broke") + // The description is developer detail: it goes to the log, not to the user + // The generic text for developer-only errors, worded as decided for iOS and Android (2026-10-14) + #expect(error.errorDescription == "An unexpected error occurred.") } @Test func unexpectedFormattedDescription() { let error = AppError.unexpected(description: "Something broke") - #expect(error.formattedDescription == "[NATIVEAPPTEMPLATE-1001] An unexpected error occurred. Something broke") + #expect(error.formattedDescription == "[NATIVEAPPTEMPLATE-1001] An unexpected error occurred.") } @Test @@ -43,6 +45,6 @@ struct AppErrorTest { @Test func codedDescriptionViaErrorExtension() { let error: Error = AppError.unexpected(description: "Test") - #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-1001] An unexpected error occurred. Test") + #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-1001] An unexpected error occurred.") } } diff --git a/NativeAppTemplateTests/Utilities/CodedErrorTest.swift b/NativeAppTemplateTests/Utilities/CodedErrorTest.swift index 2fc4102..16074b2 100644 --- a/NativeAppTemplateTests/Utilities/CodedErrorTest.swift +++ b/NativeAppTemplateTests/Utilities/CodedErrorTest.swift @@ -35,6 +35,30 @@ struct CodedErrorTest { #expect(error.codedDescription == "Plain error") } + /// The log gets the developer detail the user no longer sees + @Test + func debugCodedDescriptionForCodedError() { + let error: Error = NativeAppTemplateAPIError.responseMissingRequiredMeta(field: "limit_count") + #expect(error.debugCodedDescription.hasPrefix("[NATIVEAPPTEMPLATE-2003]")) + #expect(error.debugCodedDescription.contains("limit_count")) + } + + /// A CodedError without developer details logs what the user sees + @Test + func debugDescriptionDefaultsToFormattedDescription() { + #expect(TestCodedError().debugDescription == "[NATIVEAPPTEMPLATE-9999] Test error description") + } + + @Test + func debugCodedDescriptionWithNonCodedError() { + let error: Error = NSError( + domain: "TestDomain", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Plain error"] + ) + #expect(error.debugCodedDescription == "Plain error") + } + struct NilDescriptionError: CodedError { var errorCode: String { "NATIVEAPPTEMPLATE-0000" } var errorDescription: String? { nil } diff --git a/NativeAppTemplateTests/Utilities/MessageBusTest.swift b/NativeAppTemplateTests/Utilities/MessageBusTest.swift index 74e2fd1..13665eb 100644 --- a/NativeAppTemplateTests/Utilities/MessageBusTest.swift +++ b/NativeAppTemplateTests/Utilities/MessageBusTest.swift @@ -78,7 +78,8 @@ struct MessageBusTest { let message = Message(error: error) #expect(message.level == .error) - #expect(message.message == "[NATIVEAPPTEMPLATE-2005] NativeAppTemplateAPIError::NoData") + // The generic text for developer-only errors, worded as decided for iOS and Android (2026-10-14) + #expect(message.message == "[NATIVEAPPTEMPLATE-2005] An unexpected error occurred.") #expect(message.autoDismiss == false) } diff --git a/NativeAppTemplateTests/Utilities/NativeAppTemplateAPIErrorTest.swift b/NativeAppTemplateTests/Utilities/NativeAppTemplateAPIErrorTest.swift index f77a73a..b765a4b 100644 --- a/NativeAppTemplateTests/Utilities/NativeAppTemplateAPIErrorTest.swift +++ b/NativeAppTemplateTests/Utilities/NativeAppTemplateAPIErrorTest.swift @@ -40,55 +40,64 @@ struct NativeAppTemplateAPIErrorTest { @Test func requestFailedWithMessage() { let error = NativeAppTemplateAPIError.requestFailed(nil, 422, "Validation failed") - #expect(error.errorDescription == "Validation failed [Status: 422]") - #expect(error.formattedDescription == "[NATIVEAPPTEMPLATE-2001] Validation failed [Status: 422]") + // The API's error_message, already localized, as is: the status is for the log only + #expect(error.errorDescription == "Validation failed") + #expect(error.formattedDescription == "[NATIVEAPPTEMPLATE-2001] Validation failed") + #expect(error.debugDescription == "[NATIVEAPPTEMPLATE-2001] NativeAppTemplateAPIError::RequestFailed" + + "[Status: 422 | Message: Validation failed | Error: UNKNOWN]") } @Test func requestFailedWithoutMessage() { let error = NativeAppTemplateAPIError.requestFailed(nil, 500, nil) - let description = error.errorDescription! - #expect(description.contains("RequestFailed")) - #expect(description.contains("500")) - #expect(description.contains("UNKNOWN")) + // The generic communication error, worded as decided for iOS and Android (2026-10-14) + #expect(error.errorDescription == "Something went wrong while communicating with the server." + " Please try again later.") + #expect(error.debugDescription.contains("RequestFailed")) + #expect(error.debugDescription.contains("Status: 500")) + #expect(error.debugDescription.contains("UNKNOWN")) } @Test func processingErrorDescription() { let error = NativeAppTemplateAPIError.processingError(nil) - #expect(error.errorDescription!.contains("ProcessingError")) - #expect(error.errorDescription!.contains("UNKNOWN")) + // The generic text for developer-only errors, worded as decided for iOS and Android (2026-10-14) + #expect(error.errorDescription == "An unexpected error occurred.") + #expect(error.debugDescription.contains("ProcessingError")) + #expect(error.debugDescription.contains("UNKNOWN")) } @Test func responseMissingRequiredMetaDescription() { let error = NativeAppTemplateAPIError.responseMissingRequiredMeta(field: "count") - #expect(error.errorDescription!.contains("ResponseMissingRequiredMeta")) - #expect(error.errorDescription!.contains("count")) + #expect(error.formattedDescription == "[NATIVEAPPTEMPLATE-2003] An unexpected error occurred.") + #expect(error.debugDescription.contains("ResponseMissingRequiredMeta")) + #expect(error.debugDescription.contains("count")) } @Test func responseMissingRequiredMetaDescriptionNilField() { let error = NativeAppTemplateAPIError.responseMissingRequiredMeta(field: nil) - #expect(error.errorDescription!.contains("UNKNOWN")) + #expect(error.debugDescription.contains("UNKNOWN")) } @Test func responseHasIncorrectNumberOfElementsDescription() { let error = NativeAppTemplateAPIError.responseHasIncorrectNumberOfElements - #expect(error.errorDescription!.contains("ResponseHasIncorrectNumberOfElements")) + #expect(error.formattedDescription == "[NATIVEAPPTEMPLATE-2004] An unexpected error occurred.") + #expect(error.debugDescription.contains("ResponseHasIncorrectNumberOfElements")) } @Test func noDataDescription() { let error = NativeAppTemplateAPIError.noData - #expect(error.errorDescription == "NativeAppTemplateAPIError::NoData") - #expect(error.formattedDescription == "[NATIVEAPPTEMPLATE-2005] NativeAppTemplateAPIError::NoData") + #expect(error.errorDescription == "An unexpected error occurred.") + #expect(error.formattedDescription == "[NATIVEAPPTEMPLATE-2005] An unexpected error occurred.") + #expect(error.debugDescription == "[NATIVEAPPTEMPLATE-2005] NativeAppTemplateAPIError::NoData") } @Test func codedDescriptionViaErrorExtension() { let error: Error = NativeAppTemplateAPIError.requestFailed(nil, 404, "Not found") - #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-2001] Not found [Status: 404]") + #expect(error.codedDescription == "[NATIVEAPPTEMPLATE-2001] Not found") } }