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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user