feat(account): add QR invitation poster and 8-character invitation codes

- Invite opens an appearance-matched poster with the app icon, inviter name,
  QR code, and short code; tap for a full-brightness QR view; save (add-only
  Photos access), share, or copy the link. Mac shows the poster in a popover
  and can save it as PNG.
- Accounts get look-alike-free 8-character codes. "I have a code" accepts a
  code, link, or pasted message during the server-defined binding window.
- Permanently rejected redemptions show a specific message and drop the
  stored code instead of retrying on every sign-in.
This commit is contained in:
2026-10-20 20:06:36 +02:00
parent eaacc35cf7
commit 03bfbbe08b
26 changed files with 2269 additions and 86 deletions
@@ -5,6 +5,7 @@
import AuthenticationServices
@testable import OSGKeyboard
@testable import OSGKeyboardHostSupport
import XCTest
final class AccountCenterViewModelTests: XCTestCase {
@@ -34,6 +35,185 @@ final class AccountCenterViewModelTests: XCTestCase {
}
}
func testPastedInputAcceptsCodeLinkAndChatMessage() {
let inputs = [
validCode,
" \(validCode)\n",
"https://osglab.com/i/\(validCode)",
"https://osglab.com/i/\(validCode)?source=poster",
"快来试试 OSGKeyboard:https://osglab.com/i/\(validCode)",
"邀请你加入https://osglab.com/i/\(validCode),一起领积分",
"Join me: <https://osglab.com/i/\(validCode)>"
]
for input in inputs {
XCTAssertEqual(
ReferralUniversalLink.code(fromUserInput: input),
validCode,
"Failed to extract from \(input)"
)
}
}
func testPastedInputRejectsForeignLinksAndPlainText() {
let inputs = [
"",
"hello",
"https://example.com/i/\(validCode)",
"http://osglab.com/i/\(validCode)",
"https://osglab.com/i/short",
String(validCode.dropLast())
]
for input in inputs {
XCTAssertNil(
ReferralUniversalLink.code(fromUserInput: input),
"Unexpectedly accepted \(input)"
)
}
}
func testTypedInputAcceptsShortCodeInAnyCaseWithOrWithoutHyphen() {
let inputs = [
"K7M2Q9XA",
"k7m2-q9xa",
" K7M2-Q9XA \n",
"邀请码K7M2-Q9XA",
"我的邀请码:K7M2-Q9XA,快来"
]
for input in inputs {
XCTAssertEqual(
ReferralUniversalLink.code(fromUserInput: input),
"K7M2Q9XA",
"Failed to extract from \(input)"
)
}
}
func testTypedInputRejectsAmbiguousOrMalformedShortCodes() {
let inputs = [
"K7M2Q9X",
"K7M2Q9XAB",
"K7M-2Q9XA",
"K0M2Q9XA",
"KOM2Q9XA",
"K1M2Q9XA",
"KLM2Q9XA"
]
for input in inputs {
XCTAssertNil(
ReferralUniversalLink.code(fromUserInput: input),
"Unexpectedly accepted \(input)"
)
}
}
func testInvitationLinkWinsOverShortCodeLikeWordsInTheSameMessage() {
// "HEXAGRAM" uses only short-code characters.
let input = "HEXAGRAM https://osglab.com/i/\(validCode)"
XCTAssertEqual(ReferralUniversalLink.code(fromUserInput: input), validCode)
}
func testShortCodeDisplayAddsHyphen() {
XCTAssertEqual(ReferralShortCode.display("K7M2Q9XA"), "K7M2-Q9XA")
XCTAssertEqual(ReferralShortCode.display("bogus"), "bogus")
}
@MainActor
func testPendingStoreKeepsShortCodesForRedemptionAfterSignIn() throws {
let suiteName = "PendingReferralShortCode.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = UserDefaultsPendingReferralCodeStore(defaults: defaults)
store.save("K7M2Q9XA")
XCTAssertEqual(store.code, "K7M2Q9XA")
}
func testBindingWindowFollowsServerDeadlineWhenPresent() throws {
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
let deadline = Date(timeIntervalSince1970: 1_700_003_600)
let profile = ReferralProfile(
code: try makeReferralCode(),
binding: nil,
bindingDeadline: deadline
)
XCTAssertTrue(ReferralBindingWindow.isOpen(for: account, profile: profile, now: deadline))
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: account,
profile: profile,
now: deadline.addingTimeInterval(1)
))
}
func testBindingWindowFallsBackToSevenDaysWithoutServerDeadline() throws {
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
let profile = ReferralProfile(code: try makeReferralCode(), binding: nil)
let start = Date(timeIntervalSince1970: 1_700_000_000)
XCTAssertTrue(ReferralBindingWindow.isOpen(
for: account,
profile: profile,
now: start.addingTimeInterval(6 * 24 * 60 * 60)
))
}
func testBindingWindowClosesOnceBound() throws {
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
let binding = try JSONDecoder().decode(
ReferralBinding.self,
from: Data(#"{"inviterUserId":"x"}"#.utf8)
)
let profile = ReferralProfile(
code: try makeReferralCode(),
binding: binding,
bindingDeadline: .distantFuture
)
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: account,
profile: profile,
now: Date(timeIntervalSince1970: 1_700_000_001)
))
}
private func makeReferralCode() throws -> ReferralCode {
ReferralCode(
code: validCode,
inviteURL: try XCTUnwrap(URL(string: "https://osglab.com/i/\(validCode)")),
campaignID: nil,
createdAt: Date(timeIntervalSince1970: 1_700_000_000),
shortCode: "K7M2Q9XA"
)
}
func testBindingWindowClosesSevenDaysAfterRegistration() {
let registeredAt: Int64 = 1_700_000_000
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: registeredAt
)
let start = Date(timeIntervalSince1970: TimeInterval(registeredAt))
XCTAssertTrue(ReferralBindingWindow.isOpen(
for: account,
now: start.addingTimeInterval(6 * 24 * 60 * 60)
))
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: account,
now: start.addingTimeInterval(7 * 24 * 60 * 60)
))
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: AccountSession(accountID: UUID(), createdAtEpochSeconds: 0),
now: start
))
}
func testReferralSummaryCountsEveryServerStatus() {
let referrals = [
makeReferral(status: .pending),
@@ -439,6 +619,151 @@ final class AccountCenterViewModelTests: XCTestCase {
XCTAssertEqual(coordinator.operationErrorKey, "account.error.redeemReferral")
}
@MainActor
func testPermanentRedemptionRejectionsDropTheStoredCode() async {
let cases: [(AccountAPIError, String)] = [
(
.server(statusCode: 404, code: "http_error", message: "Referral code does not exist"),
"account.referral.redeem.error.invalid"
),
(
.server(statusCode: 422, code: "http_error", message: "Referral binding window has expired"),
"account.referral.redeem.error.expired"
),
(
.server(statusCode: 409, code: "http_error", message: "Self-referral is not allowed"),
"account.referral.redeem.error.self"
),
(
.server(
statusCode: 409,
code: "http_error",
message: "This account is already bound to another inviter"
),
"account.referral.redeem.error.alreadyBound"
),
(
.conflict("This account is not eligible for referral rewards"),
"account.referral.redeem.error.ineligible"
)
]
for (error, expectedKey) in cases {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
redemptionError: error
)
let store = InMemoryPendingReferralStore(code: validCode)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
XCTAssertNil(store.code, "Kept code after \(error)")
XCTAssertNil(coordinator.pendingReferralCode)
XCTAssertEqual(coordinator.operationErrorKey, expectedKey)
}
}
@MainActor
func testPastedInvitationWhileSignedOutIsSavedForSignIn() async {
let service = AccountServiceSpy(restoredSession: nil)
let store = InMemoryPendingReferralStore()
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
let invalid = await coordinator.submitReferralInput("not an invitation")
let saved = await coordinator.submitReferralInput(
"Join me https://osglab.com/i/\(validCode)"
)
XCTAssertEqual(invalid, .invalidInput)
XCTAssertEqual(saved, .savedForSignIn)
XCTAssertEqual(store.code, validCode)
XCTAssertEqual(coordinator.pendingReferralCode, validCode)
XCTAssertFalse(coordinator.consumeAccountCenterPresentation())
let redeemedCodes = await service.redeemedCodes()
XCTAssertEqual(redeemedCodes, [])
}
@MainActor
func testPastedInvitationWhileSignedInRedeemsImmediately() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account)
)
let store = InMemoryPendingReferralStore()
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
let outcome = await coordinator.submitReferralInput(validCode)
XCTAssertEqual(outcome, .redeemed)
XCTAssertNil(store.code)
XCTAssertNil(coordinator.pendingReferralCode)
XCTAssertNil(coordinator.operationErrorKey)
let redeemedCodes = await service.redeemedCodes()
XCTAssertEqual(redeemedCodes, [validCode])
}
@MainActor
func testPastedInvitationRejectionIsReturnedInsteadOfAlerting() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
redemptionError: .server(
statusCode: 422,
code: "http_error",
message: "Referral binding window has expired"
)
)
let store = InMemoryPendingReferralStore()
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
let outcome = await coordinator.submitReferralInput(validCode)
XCTAssertEqual(outcome, .failed(messageKey: "account.referral.redeem.error.expired"))
XCTAssertNil(store.code)
XCTAssertNil(coordinator.operationErrorKey)
}
@MainActor
func testSignOutClearsRemoteAccountStateOnly() async {
let account = AccountSession(
@@ -800,6 +1125,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
private let centerSnapshot: AccountCenterSnapshot?
private let refreshedSnapshot: AccountCenterSnapshot?
private let shouldFailRedemption: Bool
private let redemptionError: AccountAPIError?
private let shouldFailAccountRefresh: Bool
private let shouldFailSignOut: Bool
private let signOutDelayNanoseconds: UInt64
@@ -822,6 +1148,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
snapshot: AccountCenterSnapshot? = nil,
refreshedSnapshot: AccountCenterSnapshot? = nil,
shouldFailRedemption: Bool = false,
redemptionError: AccountAPIError? = nil,
shouldFailAccountRefresh: Bool = false,
shouldFailSignOut: Bool = false,
signOutDelayNanoseconds: UInt64 = 0,
@@ -836,6 +1163,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
centerSnapshot = snapshot
self.refreshedSnapshot = refreshedSnapshot
self.shouldFailRedemption = shouldFailRedemption
self.redemptionError = redemptionError
self.shouldFailAccountRefresh = shouldFailAccountRefresh
self.shouldFailSignOut = shouldFailSignOut
self.signOutDelayNanoseconds = signOutDelayNanoseconds
@@ -925,6 +1253,9 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
if shouldFailRedemption {
throw AccountServiceSpyError.failed
}
if let redemptionError {
throw redemptionError
}
redeemed.append(code)
}