diff --git a/docs/ios_greenfield_test_plan.csv b/docs/ios_greenfield_test_plan.csv index 854fb17..f0abf99 100644 --- a/docs/ios_greenfield_test_plan.csv +++ b/docs/ios_greenfield_test_plan.csv @@ -10,13 +10,13 @@ "AUTH-009","Authentication","Registration","Create account success then verify-email step","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Unique email/username","Submit valid registration","Session established and verify-email screen appears","Existing email conflict","Register API auto-authenticates","Automate","🟢 testR103_successfulRegistrationTransitionsToVerificationGate" "AUTH-010","Authentication","Registration validation","Invalid email/password formats rejected","Manual + UI","P1","iOS, Android, Web, Desktop","No session","Try invalid email, short password, weak password","User-friendly validation errors","Unicode emails, long usernames","Validation rules are enforced consistently","Automate","🟢 test03_registrationWithEmptyFields | test04_registrationWithInvalidEmail | test06_registrationWithWeakPassword" "AUTH-011","Authentication","Email verification","Valid 6-digit code verifies account","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Logged-in unverified user","Enter 6-digit numeric code","Account marked verified and app routes forward","Code already used","Code must be exactly 6 digits","Automate","🟢 testR105_validVerificationCodeTransitionsToMainApp | test07_successfulRegistrationAndVerification" -"AUTH-012","Authentication","Email verification","Non-numeric/short/long code blocked","Manual + UI","P1","iOS, Android, Web, Desktop","On verify-email screen","Enter invalid codes","Verify action disabled or error shown","Pasted with spaces","Code input sanitizes to digits","Automate","🟢 testR107_invalidVerificationCodeShowsErrorAndStaysBlocked | testR109_verifyButtonDisabledForIncompleteCode | test10_verificationCodeFieldValidation" +"AUTH-012","Authentication","Email verification","Non-numeric/short/long code blocked","Manual + UI","P1","iOS, Android, Web, Desktop","On verify-email screen","Enter invalid codes","Verify action disabled or error shown","Pasted with spaces","Code input sanitizes to digits","Automate","🟢 testR107_invalidVerificationCodeShowsErrorAndStaysBlocked | testR109_verifyButtonDisabledForIncompleteCode | test10_verificationCodeFieldValidation | emptyCodeReturnsRequired | wrongLengthReturnsInvalidCode | nonNumericCodeReturnsInvalidCode | validSixDigitCodeReturnsNil | customLengthFourDigitCode | customLengthWrongDigitCount (ValidationRulesTests)" "AUTH-013","Authentication","Logout","Logout clears token, user data, lookups, and returns to login","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Logged in","Tap logout","Login shown; protected API calls fail without token","Logout API fails network-side","Client clears state even if API call fails","Automate","🟢 test06_logout | testR205_logoutFromMainAppReturnsToLoginRoot" "AUTH-014","Authentication","Forgot password","Request reset by email success path","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Known account email","Submit forgot-password email","Success message and next step available","Unknown email behavior privacy-safe","Backend may still return generic success","Automate","🟢 test05_forgotPasswordNavigation | testF206_ForgotPasswordButtonIsAccessible | testF209_ForgotPasswordNavigatesToResetFlow" "AUTH-015","Authentication","Reset code verification","Verify reset code success path","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Reset email submitted","Enter correct code","Reset-token available for password reset","Expired code","Code verification endpoint returns token","Automate","🟢 test03_verifyResetCodeSuccess" "AUTH-016","Authentication","Password reset","Reset password success with matching confirmation","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Valid reset token","Enter new password + confirm","Password changed; user can log in (or auto-login flow)","Token expires mid-flow","Reset endpoint ignores confirm field","Automate","🟢 test04_resetPasswordSuccessAndLogin" -"AUTH-017","Authentication","Password reset","Mismatched password confirmation blocked","Manual + UI","P1","iOS, Android, Web, Desktop","Reset password screen","Enter mismatch","Inline error; no reset request","Trailing spaces","Client validates before submit","Automate","🟢 mismatchedPasswordsFails | caseSensitiveMismatchFails (ValidationHelpersTests)" -"AUTH-018","Authentication","Deep link reset","Password-reset deep link opens flow with token prefilled","Manual + E2E UI","P0","iOS, Android","Installed app with deep-link support","Open reset deep link","Forgot/reset flow opens and token is consumed","Malformed token in URL","Deep link token can be cleared on back","Automate","" +"AUTH-017","Authentication","Password reset","Mismatched password confirmation blocked","Manual + UI","P1","iOS, Android, Web, Desktop","Reset password screen","Enter mismatch","Inline error; no reset request","Trailing spaces","Client validates before submit","Automate","🟢 mismatchedPasswordsFails | caseSensitiveMismatchFails (ValidationHelpersTests) | matchingPasswordsReturnsNil | mismatchedPasswordsReturnsMismatch | caseSensitiveMismatch (ValidationRulesTests) | requiredFieldErrorDescription | invalidEmailErrorDescription | passwordTooShortErrorDescription | passwordMismatchErrorDescription | passwordMissingLetterErrorDescription | passwordMissingNumberErrorDescription | invalidCodeErrorDescription | invalidUsernameErrorDescription | customMessageErrorDescription (ValidationRulesTests)" +"AUTH-018","Authentication","Deep link reset","Password-reset deep link opens flow with token prefilled","Manual + E2E UI","P0","iOS, Android","Installed app with deep-link support","Open reset deep link","Forgot/reset flow opens and token is consumed","Malformed token in URL","Deep link token can be cleared on back","Automate","🟢 defaultInitStartsAtRequestCode | initWithTokenStartsAtResetPassword | moveToNextStepFromRequestCode | moveToNextStepFromVerifyCode | moveToNextStepFromResetPassword | moveToNextStepFromLoggingIn | moveToNextStepFromSuccessIsNoOp | moveToPreviousStepFromVerifyCode | moveToPreviousStepFromResetPassword | moveToPreviousStepFromRequestCodeIsNoOp | moveToPreviousStepFromLoggingInIsNoOp | moveToPreviousStepFromSuccessIsNoOp | resetClearsAllState | clearErrorNilsOutErrorMessage | clearSuccessNilsOutSuccessMessage | verifyResetCodeWithEmptyCodeSetsError | verifyResetCodeWithNonNumericCodeSetsError | resetPasswordWithEmptyPasswordSetsError | resetPasswordWithWeakPasswordSetsError | resetPasswordWithMismatchedPasswordsSetsError | resetPasswordWithNilTokenSetsError | allCasesHasFiveValues | allCasesContainsExpectedSteps (PasswordResetViewModelTests)" "AUTH-019","Authentication","SSO Apple","Apple Sign-In success creates or signs in account","Manual + Integration","P1","iOS","Apple-capable test device/simulator","Complete Apple sign-in","Session established; verification state handled","User hides email, first-login only email scope","Apple credential mapped to backend request","Automate partially (mock)","" "AUTH-020","Authentication","SSO Google","Google Sign-In success path","Manual + Integration","P1","Android, Web, KMP UI","Google sign-in configured","Complete Google sign-in","Session established with backend token","Revoked Google token","Backend validates ID token","Automate partially","" "ONB-001","Onboarding","Intent split","Start Fresh path goes through value props and name residence","Manual + E2E UI","P0","iOS, Android (KMP)","Fresh install","Choose Start Fresh and continue","Step order matches intended flow","Back navigation at each step","Flow differs by intent","Automate","🟢 testF101_StartFreshFlowReachesCreateAccount | testR002_startFreshFlowReachesCreateAccount" @@ -89,8 +89,8 @@ "NOTIF-003","Notifications","Token refresh","New push token triggers backend re-registration","Manual + Integration","P1","iOS, Android","Previously registered token","Simulate token rotation","Backend receives updated registration id","No token change should skip call","Last registered token cache used","Automate","" "NOTIF-004","Notifications","Foreground notification","Foreground notifications display banner/sound and update read state","Manual","P1","iOS, Android","Permission granted, send test push","Receive push while app active","Banner shown; notification handled","Malformed payload missing type","Foreground presentation explicitly enabled","Manual","" "NOTIF-005","Notifications","Notification tap navigation","Tap task push opens app and routes to tasks context","Manual + E2E","P0","iOS, Android","Task-linked push sent","Tap push from background/terminated","App opens on tasks flow for target task","Task no longer exists","Task id may be string or int","Automate partially","" -"NOTIF-006","Notifications","Action buttons premium","Premium users see and can execute task action buttons","Manual + Integration","P0","iOS, Android","Premium subscription active","Receive actionable task notification, tap action","Action API executes and UI refreshes","Action timeout/network loss","Actions gated by premium/limitationsEnabled","Manual","" -"NOTIF-007","Notifications","Action buttons free-tier gating","Free users with limitations enabled are routed home/not allowed actions","Manual","P0","iOS, Android","Free user limitationsEnabled=true","Tap actionable notification","No privileged task action executed","Subscription cache stale nil","Nil subscription defaults allow on iOS currently","Manual","" +"NOTIF-006","Notifications","Action buttons premium","Premium users see and can execute task action buttons","Manual + Integration","P0","iOS, Android","Premium subscription active","Receive actionable task notification, tap action","Action API executes and UI refreshes","Action timeout/network loss","Actions gated by premium/limitationsEnabled","Manual","🟢 encodeDecodeRoundTrip | decodedValuesMatch | sameValuesAreEqual | differentTaskIdNotEqual | differentTaskTitleNotEqual | taskIdReturnsCorrectValue | taskTitleReturnsCorrectValue (WidgetActionTests)" +"NOTIF-007","Notifications","Action buttons free-tier gating","Free users with limitations enabled are routed home/not allowed actions","Manual","P0","iOS, Android","Free user limitationsEnabled=true","Tap actionable notification","No privileged task action executed","Subscription cache stale nil","Nil subscription defaults allow on iOS currently","Manual","🟢 encodeDecodeRoundTrip | sameValuesAreEqual | differentTaskIdNotEqual | differentTaskTitleNotEqual (WidgetActionTests)" "NOTIF-008","Notifications","Preferences","Load and update notification preference toggles","Manual + Integration","P1","iOS, Android, Web, Desktop","Logged in","Open notification preferences, toggle settings, save","Preferences persist and affect server payloads","Partial update failures","Preferences API supports patch/update","Automate","" "NOTIF-009","Notifications","History/read state","Notification history list and mark-read operations","Manual + Integration","P2","iOS, Android, Web, Desktop","Notifications exist","Open history, mark one and mark all","Unread counts decrement correctly","Race with incoming push","Unread count endpoint consistent","Automate","" "SUB-001","Subscription","Status load","Subscription status loads at app launch/foreground","Manual + Integration","P0","iOS, Android, Web, Desktop","Logged in","Launch then background/foreground app","Status cache updates and UI gating accurate","Backend temporarily unavailable","Status refresh should be non-fatal","Automate","" @@ -103,7 +103,7 @@ "SUB-008","Subscription","Feature gating","Pro-only features hidden/disabled for limited users","Manual + E2E","P0","iOS, Android, Web, Desktop","Test free and pro accounts","Traverse gated features (actions, limits, upgrades)","Gating consistent across surfaces","Cache stale after plan change","Gating uses subscription status + limitationsEnabled","Automate","" "WID-001","Widgets","Small widget rendering","Small widget shows counts and opens app","Manual","P1","Android","Widget added, logged in","Place small widget and tap","Correct counts; tap opens app","No data cached yet","Widget reads from shared preferences state","Manual","" "WID-002","Widgets","Medium widget list","Medium widget shows top tasks and overdue badge","Manual","P1","Android","Tasks exist","Place medium widget","Task rows and overdue badge correct","Malformed tasks_json","JSON parse fallback to empty list","Manual","" -"WID-003","Widgets","Large widget interactions","Large widget actions execute for pro users","Manual + Integration","P0","Android","Pro account, widget configured","Tap row and action controls","Opens task or executes action as expected","Free user should not see/execute pro actions","Widget passes task_id in intent","Manual","" +"WID-003","Widgets","Large widget interactions","Large widget actions execute for pro users","Manual + Integration","P0","Android","Pro account, widget configured","Tap row and action controls","Opens task or executes action as expected","Free user should not see/execute pro actions","Widget passes task_id in intent","Manual","🟢 encodeDecodeRoundTrip | decodedValuesMatch | taskIdReturnsCorrectValue | taskTitleReturnsCorrectValue (WidgetActionTests)" "WID-004","Widgets","Widget refresh","Widgets refresh after task state changes","Manual","P1","Android","Widget present","Complete/cancel task in app","Widget counts/list update within expected interval","Background restrictions","Widget update manager triggers refresh","Manual","" "SHR-001","Sharing/Import","File association",".casera files open app import flow","Manual","P1","Android","Have .casera file","Open file from files app/share sheet","Import confirmation dialog shown","Multiple apps can open same MIME","Intent filter handles application/json with extension","Manual","" "SHR-002","Sharing/Import","Security","Import rejects when unauthenticated","Manual","P0","Android","Logged out, .casera file ready","Attempt import","Error shown, no data mutation","Stale token in storage","Auth check occurs before API call","Manual","" @@ -134,7 +134,7 @@ "I18N-001","Localization","String coverage","No missing keys/placeholders across supported locales","Manual + Static check","P1","iOS, Android, Web, Desktop","Run app in each locale","Traverse major screens","No raw keys, no placeholder mismatches","Pluralization and gender strings","Locales include es/fr/de/it/ja/ko/nl/pt/zh etc.","Automate (lint + screenshot)","" "I18N-002","Localization","Layout expansion","Long translations do not break onboarding/forms/buttons","Manual","P1","iOS, Android, Web, Desktop","Switch to longest-string locale","Review high-density screens","No clipping/overlap","RTL future locale support","Current locales may be LTR only","Manual","" "THEME-001","Theming","Theme persistence","Theme choice persists across app restarts","Manual + Integration","P1","iOS, Android, Web, Desktop","Logged in or logged out","Change theme then relaunch","Selected theme reapplied","Theme ID missing/corrupt in storage","Theme stored separately from auth data","Automate","🟢 defaultThemeIdIsDefault | setThemeIdUpdatesValue | clearDoesNotResetTheme | themeIdIsPreservedAsOceanAfterClear (ThemePersistenceTests) | test09_themePersistsAcrossRestart" -"THEME-002","Theming","Theme switch live update","Changing theme updates active screen without broken states","Manual + UI","P2","iOS, Android, Web, Desktop","Open app on any tab","Change theme in profile/settings","Immediate UI recolor with legible components","Transition while modal open","Theme manager publishes updates","Manual","" +"THEME-002","Theming","Theme switch live update","Changing theme updates active screen without broken states","Manual + UI","P2","iOS, Android, Web, Desktop","Open app on any tab","Change theme in profile/settings","Immediate UI recolor with legible components","Transition while modal open","Theme manager publishes updates","Manual","🟢 allCasesCountIsEleven | allDisplayNamesAreNonEmpty | allDescriptionsAreNonEmpty | rawValueRoundTripsForAllCases | allRawValuesAreUnique | brightDisplayNameIsDefault | oceanRawValueIsOcean (ThemeIDTests)" "NAV-001","Navigation","Bottom tabs","Residences/Tasks/Contractors/Documents tabs navigate and preserve expected state","Manual + E2E UI","P0","iOS, Android","Logged in","Switch tabs repeatedly","Correct screens shown; no stuck navigation","Push navigation then tab switch back","Nested nav stacks differ per platform","Automate","🟢 test03_navigationBetweenTabs | test10_navigateBetweenTabs | testR309_navigationAcrossPrimaryTabsAndBackToResidences" "NAV-002","Navigation","Back stack safety","Back from details/forms returns to correct parent","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Navigate into detail/edit screens","Use back gestures/buttons","Parent state intact and refreshed as expected","Direct deep-link entry without parent","Saved-state refresh flags used in KMP nav","Automate","🟢 test02_cancelRegistration | test04_cancelResidenceCreation | test02_cancelTaskCreation | test19_CancelDocumentCreation" "NAV-003","Navigation","Duplicate routes","Avoid duplicate screen instances from repeated tap/navigation actions","Manual","P2","iOS, Android, Web, Desktop","Rapidly tap nav actions","Observe back stack and UI","No duplicate stacking or loops","Double tap race conditions","NavHost popUpTo rules applied","Manual","🟢 testP003_RapidDoubleTapOnValuePropsContinueLandsOnNameResidence | testP004_StartFreshThenBackToWelcomeThenJoinExistingDoesNotCorruptState | testP005_RepeatedLoginNavigationRemainsStable" diff --git a/iosApp/CaseraTests/PasswordResetViewModelTests.swift b/iosApp/CaseraTests/PasswordResetViewModelTests.swift new file mode 100644 index 0000000..77b900c --- /dev/null +++ b/iosApp/CaseraTests/PasswordResetViewModelTests.swift @@ -0,0 +1,218 @@ +// +// PasswordResetViewModelTests.swift +// CaseraTests +// +// Unit tests for PasswordResetViewModel navigation, state management, +// and client-side validation (no network calls). +// + +import Testing +@testable import Casera + +// MARK: - PasswordResetStep Tests + +struct PasswordResetStepTests { + + @Test func allCasesHasFiveValues() { + #expect(PasswordResetStep.allCases.count == 5) + } + + @Test func allCasesContainsExpectedSteps() { + let cases = PasswordResetStep.allCases + #expect(cases.contains(.requestCode)) + #expect(cases.contains(.verifyCode)) + #expect(cases.contains(.resetPassword)) + #expect(cases.contains(.loggingIn)) + #expect(cases.contains(.success)) + } +} + +// MARK: - Initialization Tests + +@MainActor +struct PasswordResetViewModelInitTests { + + @Test func defaultInitStartsAtRequestCode() { + let vm = PasswordResetViewModel() + #expect(vm.currentStep == .requestCode) + #expect(vm.resetToken == nil) + } + + @Test func initWithTokenStartsAtResetPassword() { + let vm = PasswordResetViewModel(resetToken: "abc123") + #expect(vm.currentStep == .resetPassword) + #expect(vm.resetToken == "abc123") + } +} + +// MARK: - Navigation Tests + +@MainActor +struct PasswordResetViewModelNavigationTests { + + @Test func moveToNextStepFromRequestCode() { + let vm = PasswordResetViewModel() + vm.currentStep = .requestCode + vm.moveToNextStep() + #expect(vm.currentStep == .verifyCode) + } + + @Test func moveToNextStepFromVerifyCode() { + let vm = PasswordResetViewModel() + vm.currentStep = .verifyCode + vm.moveToNextStep() + #expect(vm.currentStep == .resetPassword) + } + + @Test func moveToNextStepFromResetPassword() { + let vm = PasswordResetViewModel() + vm.currentStep = .resetPassword + vm.moveToNextStep() + #expect(vm.currentStep == .loggingIn) + } + + @Test func moveToNextStepFromLoggingIn() { + let vm = PasswordResetViewModel() + vm.currentStep = .loggingIn + vm.moveToNextStep() + #expect(vm.currentStep == .success) + } + + @Test func moveToNextStepFromSuccessIsNoOp() { + let vm = PasswordResetViewModel() + vm.currentStep = .success + vm.moveToNextStep() + #expect(vm.currentStep == .success) + } + + @Test func moveToPreviousStepFromVerifyCode() { + let vm = PasswordResetViewModel() + vm.currentStep = .verifyCode + vm.moveToPreviousStep() + #expect(vm.currentStep == .requestCode) + } + + @Test func moveToPreviousStepFromResetPassword() { + let vm = PasswordResetViewModel() + vm.currentStep = .resetPassword + vm.moveToPreviousStep() + #expect(vm.currentStep == .verifyCode) + } + + @Test func moveToPreviousStepFromRequestCodeIsNoOp() { + let vm = PasswordResetViewModel() + vm.currentStep = .requestCode + vm.moveToPreviousStep() + #expect(vm.currentStep == .requestCode) + } + + @Test func moveToPreviousStepFromLoggingInIsNoOp() { + let vm = PasswordResetViewModel() + vm.currentStep = .loggingIn + vm.moveToPreviousStep() + #expect(vm.currentStep == .loggingIn) + } + + @Test func moveToPreviousStepFromSuccessIsNoOp() { + let vm = PasswordResetViewModel() + vm.currentStep = .success + vm.moveToPreviousStep() + #expect(vm.currentStep == .success) + } +} + +// MARK: - Reset and Clear Tests + +@MainActor +struct PasswordResetViewModelResetTests { + + @Test func resetClearsAllState() { + let vm = PasswordResetViewModel() + vm.email = "test@example.com" + vm.code = "123456" + vm.newPassword = "password123" + vm.confirmPassword = "password123" + vm.resetToken = "token" + vm.errorMessage = "error" + vm.successMessage = "success" + vm.currentStep = .resetPassword + vm.isLoading = true + + vm.reset() + + #expect(vm.email == "") + #expect(vm.code == "") + #expect(vm.newPassword == "") + #expect(vm.confirmPassword == "") + #expect(vm.resetToken == nil) + #expect(vm.errorMessage == nil) + #expect(vm.successMessage == nil) + #expect(vm.currentStep == .requestCode) + #expect(vm.isLoading == false) + } + + @Test func clearErrorNilsOutErrorMessage() { + let vm = PasswordResetViewModel() + vm.errorMessage = "Something went wrong" + vm.clearError() + #expect(vm.errorMessage == nil) + } + + @Test func clearSuccessNilsOutSuccessMessage() { + let vm = PasswordResetViewModel() + vm.successMessage = "Code sent!" + vm.clearSuccess() + #expect(vm.successMessage == nil) + } +} + +// MARK: - Client-Side Validation Tests (verifyResetCode / resetPassword gates) + +@MainActor +struct PasswordResetViewModelValidationTests { + + @Test func verifyResetCodeWithEmptyCodeSetsError() { + let vm = PasswordResetViewModel() + vm.code = "" + vm.verifyResetCode() + #expect(vm.errorMessage == "Code is required") + } + + @Test func verifyResetCodeWithNonNumericCodeSetsError() { + let vm = PasswordResetViewModel() + vm.code = "abcdef" + vm.verifyResetCode() + #expect(vm.errorMessage == "Code must be 6 digits") + } + + @Test func resetPasswordWithEmptyPasswordSetsError() { + let vm = PasswordResetViewModel() + vm.newPassword = "" + vm.resetPassword() + #expect(vm.errorMessage == "Password is required") + } + + @Test func resetPasswordWithWeakPasswordSetsError() { + let vm = PasswordResetViewModel() + vm.newPassword = "abcdefgh" // no number + vm.resetPassword() + #expect(vm.errorMessage == "Password must contain at least one number") + } + + @Test func resetPasswordWithMismatchedPasswordsSetsError() { + let vm = PasswordResetViewModel() + vm.newPassword = "abc12345" + vm.confirmPassword = "xyz12345" + vm.resetPassword() + #expect(vm.errorMessage == "Passwords do not match") + } + + @Test func resetPasswordWithNilTokenSetsError() { + let vm = PasswordResetViewModel() + vm.newPassword = "abc12345" + vm.confirmPassword = "abc12345" + vm.resetToken = nil + vm.resetPassword() + #expect(vm.errorMessage == "Invalid reset token. Please start over.") + } +} diff --git a/iosApp/CaseraTests/ThemeIDTests.swift b/iosApp/CaseraTests/ThemeIDTests.swift new file mode 100644 index 0000000..f24fe63 --- /dev/null +++ b/iosApp/CaseraTests/ThemeIDTests.swift @@ -0,0 +1,50 @@ +// +// ThemeIDTests.swift +// CaseraTests +// +// Unit tests for ThemeID enum properties and round-tripping. +// + +import Testing +@testable import Casera + +// MARK: - ThemeID Enum Tests + +struct ThemeIDTests { + + @Test func allCasesCountIsEleven() { + #expect(ThemeID.allCases.count == 11) + } + + @Test func allDisplayNamesAreNonEmpty() { + for theme in ThemeID.allCases { + #expect(!theme.displayName.isEmpty, "ThemeID.\(theme) has empty displayName") + } + } + + @Test func allDescriptionsAreNonEmpty() { + for theme in ThemeID.allCases { + #expect(!theme.description.isEmpty, "ThemeID.\(theme) has empty description") + } + } + + @Test func rawValueRoundTripsForAllCases() { + for theme in ThemeID.allCases { + let roundTripped = ThemeID(rawValue: theme.rawValue) + #expect(roundTripped == theme, "ThemeID.\(theme) failed rawValue round-trip") + } + } + + @Test func allRawValuesAreUnique() { + let rawValues = ThemeID.allCases.map(\.rawValue) + #expect(Set(rawValues).count == rawValues.count) + } + + @Test func brightDisplayNameIsDefault() { + #expect(ThemeID.bright.displayName == "Default") + } + + @Test func oceanRawValueIsOcean() { + #expect(ThemeID.ocean.rawValue == "Ocean") + } +} diff --git a/iosApp/CaseraTests/ValidationRulesTests.swift b/iosApp/CaseraTests/ValidationRulesTests.swift new file mode 100644 index 0000000..018ec4b --- /dev/null +++ b/iosApp/CaseraTests/ValidationRulesTests.swift @@ -0,0 +1,266 @@ +// +// ValidationRulesTests.swift +// CaseraTests +// +// Unit tests for ValidationError and ValidationRules (distinct from ValidationHelpers). +// + +import Testing +@testable import Casera + +// MARK: - ValidationError errorDescription Tests + +struct ValidationErrorTests { + + @Test func requiredFieldErrorDescription() { + let error = ValidationError.required(field: "Email") + #expect(error.errorDescription == "Email is required") + } + + @Test func invalidEmailErrorDescription() { + let error = ValidationError.invalidEmail + #expect(error.errorDescription == "Please enter a valid email address") + } + + @Test func passwordTooShortErrorDescription() { + let error = ValidationError.passwordTooShort(minLength: 8) + #expect(error.errorDescription == "Password must be at least 8 characters") + } + + @Test func passwordMismatchErrorDescription() { + let error = ValidationError.passwordMismatch + #expect(error.errorDescription == "Passwords do not match") + } + + @Test func passwordMissingLetterErrorDescription() { + let error = ValidationError.passwordMissingLetter + #expect(error.errorDescription == "Password must contain at least one letter") + } + + @Test func passwordMissingNumberErrorDescription() { + let error = ValidationError.passwordMissingNumber + #expect(error.errorDescription == "Password must contain at least one number") + } + + @Test func invalidCodeErrorDescription() { + let error = ValidationError.invalidCode(expectedLength: 6) + #expect(error.errorDescription == "Code must be 6 digits") + } + + @Test func invalidUsernameErrorDescription() { + let error = ValidationError.invalidUsername + #expect(error.errorDescription == "Username can only contain letters, numbers, and underscores") + } + + @Test func customMessageErrorDescription() { + let error = ValidationError.custom(message: "Something went wrong") + #expect(error.errorDescription == "Something went wrong") + } +} + +// MARK: - Email Validation Tests + +struct ValidationRulesEmailTests { + + @Test func emptyEmailReturnsRequired() { + let error = ValidationRules.validateEmail("") + #expect(error?.errorDescription == "Email is required") + } + + @Test func whitespaceOnlyEmailReturnsRequired() { + let error = ValidationRules.validateEmail(" ") + #expect(error?.errorDescription == "Email is required") + } + + @Test func invalidEmailReturnsInvalidEmail() { + let error = ValidationRules.validateEmail("notanemail") + #expect(error?.errorDescription == "Please enter a valid email address") + } + + @Test func validEmailReturnsNil() { + let error = ValidationRules.validateEmail("user@example.com") + #expect(error == nil) + } + + @Test func isValidEmailReturnsTrueForValid() { + #expect(ValidationRules.isValidEmail("user@example.com")) + } + + @Test func isValidEmailReturnsFalseForInvalid() { + #expect(!ValidationRules.isValidEmail("notanemail")) + } +} + +// MARK: - Password Validation Tests + +struct ValidationRulesPasswordTests { + + @Test func emptyPasswordReturnsRequired() { + let error = ValidationRules.validatePassword("") + #expect(error?.errorDescription == "Password is required") + } + + @Test func shortPasswordReturnsTooShort() { + let error = ValidationRules.validatePassword("abc") + #expect(error?.errorDescription == "Password must be at least 8 characters") + } + + @Test func validPasswordReturnsNil() { + let error = ValidationRules.validatePassword("longpassword") + #expect(error == nil) + } + + @Test func exactMinLengthPasswordReturnsNil() { + let error = ValidationRules.validatePassword("12345678") + #expect(error == nil) + } +} + +// MARK: - Password Strength Tests + +struct ValidationRulesPasswordStrengthTests { + + @Test func emptyPasswordReturnsRequired() { + let error = ValidationRules.validatePasswordStrength("") + #expect(error?.errorDescription == "Password is required") + } + + @Test func noLetterReturnsMissingLetter() { + let error = ValidationRules.validatePasswordStrength("123456") + #expect(error?.errorDescription == "Password must contain at least one letter") + } + + @Test func noNumberReturnsMissingNumber() { + let error = ValidationRules.validatePasswordStrength("abcdef") + #expect(error?.errorDescription == "Password must contain at least one number") + } + + @Test func letterAndNumberReturnsNil() { + let error = ValidationRules.validatePasswordStrength("abc123") + #expect(error == nil) + } + + @Test func isValidPasswordReturnsTrueForStrong() { + #expect(ValidationRules.isValidPassword("abc123")) + } + + @Test func isValidPasswordReturnsFalseForLettersOnly() { + #expect(!ValidationRules.isValidPassword("abcdef")) + } + + @Test func isValidPasswordReturnsFalseForNumbersOnly() { + #expect(!ValidationRules.isValidPassword("123456")) + } +} + +// MARK: - Password Match Tests + +struct ValidationRulesPasswordMatchTests { + + @Test func matchingPasswordsReturnsNil() { + let error = ValidationRules.validatePasswordMatch("abc123", "abc123") + #expect(error == nil) + } + + @Test func mismatchedPasswordsReturnsMismatch() { + let error = ValidationRules.validatePasswordMatch("abc123", "xyz789") + #expect(error?.errorDescription == "Passwords do not match") + } + + @Test func caseSensitiveMismatch() { + let error = ValidationRules.validatePasswordMatch("Password", "password") + #expect(error?.errorDescription == "Passwords do not match") + } +} + +// MARK: - Code Validation Tests + +struct ValidationRulesCodeTests { + + @Test func emptyCodeReturnsRequired() { + let error = ValidationRules.validateCode("") + #expect(error?.errorDescription == "Code is required") + } + + @Test func wrongLengthReturnsInvalidCode() { + let error = ValidationRules.validateCode("123") + #expect(error?.errorDescription == "Code must be 6 digits") + } + + @Test func nonNumericCodeReturnsInvalidCode() { + let error = ValidationRules.validateCode("abcdef") + #expect(error?.errorDescription == "Code must be 6 digits") + } + + @Test func validSixDigitCodeReturnsNil() { + let error = ValidationRules.validateCode("123456") + #expect(error == nil) + } + + @Test func customLengthFourDigitCode() { + let error = ValidationRules.validateCode("1234", expectedLength: 4) + #expect(error == nil) + } + + @Test func customLengthWrongDigitCount() { + let error = ValidationRules.validateCode("123", expectedLength: 4) + #expect(error?.errorDescription == "Code must be 4 digits") + } +} + +// MARK: - Username Validation Tests + +struct ValidationRulesUsernameTests { + + @Test func emptyUsernameReturnsRequired() { + let error = ValidationRules.validateUsername("") + #expect(error?.errorDescription == "Username is required") + } + + @Test func validAlphanumericUsernameReturnsNil() { + let error = ValidationRules.validateUsername("john_doe123") + #expect(error == nil) + } + + @Test func usernameWithSpacesReturnsInvalid() { + let error = ValidationRules.validateUsername("john doe") + #expect(error?.errorDescription == "Username can only contain letters, numbers, and underscores") + } + + @Test func usernameWithSpecialCharsReturnsInvalid() { + let error = ValidationRules.validateUsername("user@name!") + #expect(error?.errorDescription == "Username can only contain letters, numbers, and underscores") + } +} + +// MARK: - Required Field Validation Tests + +struct ValidationRulesRequiredTests { + + @Test func emptyValueReturnsRequired() { + let error = ValidationRules.validateRequired("", fieldName: "Name") + #expect(error?.errorDescription == "Name is required") + } + + @Test func whitespaceOnlyReturnsRequired() { + let error = ValidationRules.validateRequired(" ", fieldName: "Name") + #expect(error?.errorDescription == "Name is required") + } + + @Test func nonEmptyReturnsNil() { + let error = ValidationRules.validateRequired("hello", fieldName: "Name") + #expect(error == nil) + } + + @Test func isNotEmptyReturnsTrueForNonEmpty() { + #expect(ValidationRules.isNotEmpty("hello")) + } + + @Test func isNotEmptyReturnsFalseForEmpty() { + #expect(!ValidationRules.isNotEmpty("")) + } + + @Test func isNotEmptyReturnsFalseForWhitespace() { + #expect(!ValidationRules.isNotEmpty(" ")) + } +} diff --git a/iosApp/CaseraTests/WidgetActionTests.swift b/iosApp/CaseraTests/WidgetActionTests.swift new file mode 100644 index 0000000..66d1ea5 --- /dev/null +++ b/iosApp/CaseraTests/WidgetActionTests.swift @@ -0,0 +1,102 @@ +// +// WidgetActionTests.swift +// CaseraTests +// +// Unit tests for WidgetDataManager.WidgetAction (Codable, Equatable, accessors) +// and WidgetDataManager.parseDate static helper. +// + +import Testing +import Foundation +@testable import Casera + +// MARK: - WidgetAction Codable Tests + +struct WidgetActionCodableTests { + + @Test func encodeDecodeRoundTrip() throws { + let original = WidgetDataManager.WidgetAction.completeTask(taskId: 42, taskTitle: "Fix leak") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(WidgetDataManager.WidgetAction.self, from: data) + #expect(decoded == original) + } + + @Test func decodedValuesMatch() throws { + let original = WidgetDataManager.WidgetAction.completeTask(taskId: 99, taskTitle: "Paint walls") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(WidgetDataManager.WidgetAction.self, from: data) + #expect(decoded.taskId == 99) + #expect(decoded.taskTitle == "Paint walls") + } +} + +// MARK: - WidgetAction Equatable Tests + +struct WidgetActionEquatableTests { + + @Test func sameValuesAreEqual() { + let a = WidgetDataManager.WidgetAction.completeTask(taskId: 1, taskTitle: "Test") + let b = WidgetDataManager.WidgetAction.completeTask(taskId: 1, taskTitle: "Test") + #expect(a == b) + } + + @Test func differentTaskIdNotEqual() { + let a = WidgetDataManager.WidgetAction.completeTask(taskId: 1, taskTitle: "Test") + let b = WidgetDataManager.WidgetAction.completeTask(taskId: 2, taskTitle: "Test") + #expect(a != b) + } + + @Test func differentTaskTitleNotEqual() { + let a = WidgetDataManager.WidgetAction.completeTask(taskId: 1, taskTitle: "Alpha") + let b = WidgetDataManager.WidgetAction.completeTask(taskId: 1, taskTitle: "Beta") + #expect(a != b) + } +} + +// MARK: - WidgetAction Accessor Tests + +struct WidgetActionAccessorTests { + + @Test func taskIdReturnsCorrectValue() { + let action = WidgetDataManager.WidgetAction.completeTask(taskId: 55, taskTitle: "Mow lawn") + #expect(action.taskId == 55) + } + + @Test func taskTitleReturnsCorrectValue() { + let action = WidgetDataManager.WidgetAction.completeTask(taskId: 55, taskTitle: "Mow lawn") + #expect(action.taskTitle == "Mow lawn") + } +} + +// MARK: - parseDate Tests + +struct ParseDateTests { + + @Test func validDateStringReturnsDate() { + let date = WidgetDataManager.parseDate("2024-06-15") + #expect(date != nil) + } + + @Test func nilInputReturnsNil() { + let date = WidgetDataManager.parseDate(nil) + #expect(date == nil) + } + + @Test func emptyStringReturnsNil() { + let date = WidgetDataManager.parseDate("") + #expect(date == nil) + } + + @Test func isoDateTimeExtractsDatePart() { + let date = WidgetDataManager.parseDate("2025-12-26T00:00:00Z") + #expect(date != nil) + // Should parse the same as just the date part + let dateDirect = WidgetDataManager.parseDate("2025-12-26") + #expect(date == dateDirect) + } + + @Test func invalidStringReturnsNil() { + let date = WidgetDataManager.parseDate("not-a-date") + #expect(date == nil) + } +}