From 3fb270d391df1425faff28e0aab8db3f11b0d0db Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 9 Sep 2026 14:41:33 -0400 Subject: [PATCH] fix(username): drop a leading @ in the validator A handle pasted from a profile arrives as `@taylor`, and the validator passed it through to `Username.init?`, which rejects the `@` against the server's `^[a-z0-9_]{2,15}$` charset. The claim screen answered with the Invalid Characters dialog and the lookup screen with Username Not Found, for a handle the user could see was right. `normalized` now strips one leading `@` after trimming and lowercasing. Both `validate` and `failure(for:)` run through it, so both screens pick the change up, and `canSubmit` on the claim screen already compares through the validator, so `@taylor` against a current `taylor` reads as no edit. Only one `@` goes: `@@taylor` still reports invalid characters, and a lone `@` reports too short. Android already lands on the same submitted value, by dropping the `@` in the field's input transformation. iOS keeps the raw input on screen on purpose, so the rejection dialogs stay reachable. --- .../Validation/UsernameValidator.swift | 22 ++++++++++++------- .../UsernameValidatorTests.swift | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Validation/UsernameValidator.swift b/FlipcashCore/Sources/FlipcashCore/Validation/UsernameValidator.swift index 673121d10..ab7812a06 100644 --- a/FlipcashCore/Sources/FlipcashCore/Validation/UsernameValidator.swift +++ b/FlipcashCore/Sources/FlipcashCore/Validation/UsernameValidator.swift @@ -5,12 +5,12 @@ import Foundation -/// Validates a handle typed into the username field: lowercases the input, then -/// holds it to the `Username` contract. +/// Validates a handle typed into the username field: lowercases the input and +/// drops the `@` a handle is shown with, then holds it to the `Username` contract. /// -/// Lowercasing lives here rather than in `Username.init?` so the model stays -/// strict — a handle reaching it uppercase is still a caller error. This is the -/// only place a typed handle is rewritten before it reaches the model. +/// That normalising lives here rather than in `Username.init?` so the model stays +/// strict — a handle reaching it uppercase or prefixed is still a caller error. +/// This is the only place a typed handle is rewritten before it reaches the model. public struct UsernameValidator: Validator { /// Why an input was rejected. The claim screen raises a different dialog for @@ -54,9 +54,15 @@ public struct UsernameValidator: Validator { return .invalidCharacters } - /// Lowercased and stripped of surrounding whitespace. A paste carries both, - /// and neither is worth rejecting someone over. + /// Lowercased, stripped of surrounding whitespace, and of one leading `@`. + /// A paste carries all three — a handle copied out of a profile arrives as + /// `@taylor` — and none is worth rejecting someone over. Only one `@` goes: + /// `@@taylor` is not a handle anyone has, and the field never adds one. private func normalized(_ input: String) -> String { - input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + var candidate = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if candidate.hasPrefix("@") { + candidate.removeFirst() + } + return candidate } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/UsernameValidatorTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/UsernameValidatorTests.swift index 178568438..47559bf3f 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/UsernameValidatorTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/UsernameValidatorTests.swift @@ -21,6 +21,28 @@ struct UsernameValidatorTests { #expect(validator.validate(" taylor ")?.value == "taylor") } + @Test("A leading @ is dropped, so a pasted handle is accepted as its username") + func validate_leadingAt_stripped() { + #expect(validator.validate("@taylor")?.value == "taylor") + #expect(validator.validate(" @Taylor ")?.value == "taylor") + } + + @Test("Only one leading @ is dropped; a second is an illegal character") + func validate_doubleLeadingAt_rejected() { + #expect(validator.validate("@@taylor") == nil) + #expect(validator.failure(for: "@@taylor") == .invalidCharacters) + } + + @Test("A lone @ is an empty handle, so it reports too short") + func failure_loneAt_tooShort() { + #expect(validator.failure(for: "@") == .tooShort) + } + + @Test("An @ anywhere but the front is an illegal character") + func failure_embeddedAt_invalidCharacters() { + #expect(validator.failure(for: "tay@lor") == .invalidCharacters) + } + @Test("Two characters is the shortest accepted handle") func validate_minimumLength_boundary() { #expect(validator.validate("ab")?.value == "ab")