Skip to content

Commit 9bbf7b2

Browse files
authored
[Feat]: Authenticated in-app PalsHub checkout (US iOS) (#752)
1 parent 07d1937 commit 9bbf7b2

27 files changed

Lines changed: 1566 additions & 27 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Mock CheckoutFlowStore for testing
3+
*/
4+
5+
import {makeAutoObservable} from 'mobx';
6+
7+
class MockCheckoutFlowStore {
8+
status:
9+
| 'idle'
10+
| 'creating'
11+
| 'browser_open'
12+
| 'finalizing'
13+
| 'owned'
14+
| 'processing_deferred'
15+
| 'cancelled'
16+
| 'error' = 'idle';
17+
palId: string | null = null;
18+
purchaseId?: string;
19+
errorKind?: '401' | '404' | '500' | 'network';
20+
21+
start: jest.Mock;
22+
onReturn: jest.Mock;
23+
reset: jest.Mock;
24+
25+
constructor() {
26+
makeAutoObservable(this, {
27+
start: false,
28+
onReturn: false,
29+
reset: false,
30+
});
31+
this.start = jest.fn().mockResolvedValue(undefined);
32+
this.onReturn = jest.fn();
33+
this.reset = jest.fn();
34+
}
35+
36+
get isInFlight(): boolean {
37+
return (
38+
this.status === 'creating' ||
39+
this.status === 'browser_open' ||
40+
this.status === 'finalizing'
41+
);
42+
}
43+
}
44+
45+
export const checkoutFlowStore = new MockCheckoutFlowStore();

e2e/helpers/palshubTestApi.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* PalsHub test-support API client (e2e only).
3+
*
4+
* Drives the server-side e2e helpers from the test host so the purchase flow
5+
* can run repeatedly from a clean state. These endpoints are test-mode only,
6+
* guarded by a shared key, and idempotent — safe to call on every run.
7+
*
8+
* Required env (set in e2e/.env or the CI env). Names match the palshub
9+
* test-harness contract:
10+
* E2E_PALSHUB_BASE_URL - test server base, e.g. http://192.168.0.92:3010
11+
* E2E_API_KEY - shared secret sent as the X-E2E-Key header
12+
* E2E_BUYER_EMAIL - test buyer email (local part must contain "e2e")
13+
* E2E_BUYER_PASSWORD - test buyer password
14+
* E2E_PALSHUB_PAL_ID - premium fixture pal id (defaults to the seeded pal)
15+
*/
16+
17+
export const palshubTestConfig = {
18+
baseUrl: process.env.E2E_PALSHUB_BASE_URL || 'http://192.168.0.92:3010',
19+
testKey: process.env.E2E_API_KEY || '',
20+
email: process.env.E2E_BUYER_EMAIL || '',
21+
password: process.env.E2E_BUYER_PASSWORD || '',
22+
palId:
23+
process.env.E2E_PALSHUB_PAL_ID ||
24+
'f0c0ffee-cafe-4000-8000-000000000001',
25+
};
26+
27+
const ENSURE_USER_PATH = '/api/test/e2e/users/ensure';
28+
const RESET_OWNERSHIP_PATH = '/api/test/e2e/purchases/reset';
29+
30+
async function post(path: string, body: Record<string, unknown>): Promise<void> {
31+
if (!palshubTestConfig.testKey) {
32+
throw new Error(
33+
'E2E_API_KEY is not set — cannot call PalsHub test endpoints',
34+
);
35+
}
36+
37+
const url = `${palshubTestConfig.baseUrl}${path}`;
38+
const response = await fetch(url, {
39+
method: 'POST',
40+
headers: {
41+
'Content-Type': 'application/json',
42+
'X-E2E-Key': palshubTestConfig.testKey,
43+
},
44+
body: JSON.stringify(body),
45+
});
46+
47+
if (!response.ok) {
48+
const text = await response.text().catch(() => '');
49+
throw new Error(
50+
`PalsHub test API ${path} failed: ${response.status} ${response.statusText} ${text}`,
51+
);
52+
}
53+
}
54+
55+
/**
56+
* Ensure the test buyer exists in the test Supabase project. Idempotent:
57+
* a no-op when the user already exists.
58+
*/
59+
export async function ensureTestUser(
60+
email: string = palshubTestConfig.email,
61+
password: string = palshubTestConfig.password,
62+
): Promise<void> {
63+
await post(ENSURE_USER_PATH, {email, password});
64+
}
65+
66+
/**
67+
* Void the test buyer's purchase + entitlement of the given pal so it reads
68+
* is_owned=false and the Buy button renders. Call in beforeEach: without it
69+
* a prior run's purchase keeps the Buy button hidden. Idempotent.
70+
*/
71+
export async function resetPalOwnership(
72+
palId: string = palshubTestConfig.palId,
73+
userEmail: string = palshubTestConfig.email,
74+
): Promise<void> {
75+
await post(RESET_OWNERSHIP_PATH, {pal_id: palId, user_email: userEmail});
76+
}
77+
78+
/**
79+
* Convenience: bring the account to a known clean pre-purchase state.
80+
*/
81+
export async function resetCheckoutScene(): Promise<void> {
82+
await ensureTestUser();
83+
await resetPalOwnership();
84+
}

e2e/pages/PalPurchasePage.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Page object for the PalsHub premium-pal purchase flow:
3+
* browse card -> detail sheet -> Buy -> AuthSheet sign-in -> Download flip.
4+
*/
5+
6+
import {BasePage} from './BasePage';
7+
import {byTestId} from '../helpers/selectors';
8+
9+
declare const browser: WebdriverIO.Browser;
10+
11+
export class PalPurchasePage extends BasePage {
12+
private palCard(palId: string): string {
13+
return byTestId(`palshub-pal-card-${palId}`);
14+
}
15+
private get buyButton(): string {
16+
return byTestId('buy-button');
17+
}
18+
private get downloadButton(): string {
19+
return byTestId('download-button');
20+
}
21+
private get emailInput(): string {
22+
return byTestId('email-input');
23+
}
24+
private get passwordInput(): string {
25+
return byTestId('password-input');
26+
}
27+
private get authSubmit(): string {
28+
return byTestId('auth-submit-button');
29+
}
30+
31+
/** Open the premium pal's detail sheet from the browse list. */
32+
async openPalDetail(palId: string, timeout = 20000): Promise<void> {
33+
await this.tap(this.palCard(palId), timeout);
34+
await this.waitForElement(this.buyButton, timeout);
35+
}
36+
37+
async tapBuy(timeout = 15000): Promise<void> {
38+
const btn = await this.waitForEnabled(this.buyButton, timeout);
39+
await btn.click();
40+
}
41+
42+
/** Fill the AuthSheet email/password and submit. */
43+
async fillAndSubmitSignIn(
44+
email: string,
45+
password: string,
46+
timeout = 20000,
47+
): Promise<void> {
48+
await this.typeText(this.emailInput, email, timeout);
49+
await this.typeText(this.passwordInput, password, timeout);
50+
await this.dismissKeyboard();
51+
await this.tap(this.authSubmit, timeout);
52+
}
53+
54+
/** Dismiss the post-submit confirmation alert ("OK") if one appears. */
55+
async dismissAlertIfPresent(timeout = 4000): Promise<void> {
56+
const ok = browser.$(
57+
'-ios predicate string:type == "XCUIElementTypeButton" AND (label == "OK" OR label == "Ok")',
58+
);
59+
try {
60+
await ok.waitForDisplayed({timeout});
61+
await ok.click();
62+
} catch {
63+
// No alert — proceed.
64+
}
65+
}
66+
67+
/**
68+
* Tap Buy; if it routes to sign-in, authenticate and retry. Sign-in is async,
69+
* so a Buy tap before the session settles re-opens the AuthSheet — retry until
70+
* Buy actually starts checkout (the AuthSheet no longer appears).
71+
*/
72+
async signInAndStartCheckout(
73+
email: string,
74+
password: string,
75+
attempts = 4,
76+
): Promise<void> {
77+
for (let i = 0; i < attempts; i++) {
78+
await this.tapBuy();
79+
const authOpened = await this.isElementDisplayed(this.emailInput, 4000);
80+
if (!authOpened) {
81+
return; // checkout started
82+
}
83+
await this.fillAndSubmitSignIn(email, password);
84+
await this.dismissAlertIfPresent();
85+
await this.waitForElementToDisappear(this.emailInput, 15000).catch(
86+
() => {},
87+
);
88+
await browser.pause(2500); // let the session + observable state settle
89+
}
90+
throw new Error('Buy kept routing to sign-in; authentication never settled');
91+
}
92+
93+
/**
94+
* ASWebAuthenticationSession shows a one-time system consent alert before the
95+
* page loads. Accept it if present; a no-op otherwise (already granted).
96+
*/
97+
async acceptAuthConsentIfPresent(timeout = 8000): Promise<void> {
98+
const continueBtn = browser.$(
99+
'-ios predicate string:type == "XCUIElementTypeButton" AND label == "Continue"',
100+
);
101+
try {
102+
await continueBtn.waitForDisplayed({timeout});
103+
await continueBtn.click();
104+
} catch {
105+
// No consent prompt surfaced — proceed.
106+
}
107+
}
108+
109+
/** The reconcile poll flips Buy -> Download once ownership is granted. */
110+
async waitForDownloadButton(timeout = 30000): Promise<void> {
111+
await this.waitForElement(this.downloadButton, timeout);
112+
}
113+
}

e2e/pages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export {SettingsPage} from './SettingsPage';
66
export {HFSearchSheet} from './HFSearchSheet';
77
export {ModelDetailsSheet} from './ModelDetailsSheet';
88
export {PalSheetPage} from './PalSheetPage';
9+
export {PalPurchasePage} from './PalPurchasePage';
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* PalsHub authenticated purchase flow (iOS).
3+
*
4+
* Drives the real create-session -> ASWebAuthenticationSession -> success
5+
* return -> reconcile loop against the palshub e2e test harness, which returns
6+
* a deterministic test-complete checkout (no Stripe / Apple Pay UI). The server
7+
* helpers run from the test host to seed a clean pre-purchase state each run.
8+
*
9+
* Requires an E2E build (E2E_BUILD=true) pointed at the test server, and these
10+
* env vars (see e2e/helpers/palshubTestApi.ts):
11+
* E2E_PALSHUB_BASE_URL, E2E_API_KEY, E2E_BUYER_EMAIL, E2E_BUYER_PASSWORD,
12+
* E2E_PALSHUB_PAL_ID
13+
*/
14+
15+
import * as fs from 'fs';
16+
import * as path from 'path';
17+
18+
import {ChatPage} from '../../pages/ChatPage';
19+
import {DrawerPage} from '../../pages/DrawerPage';
20+
import {PalPurchasePage} from '../../pages/PalPurchasePage';
21+
import {TIMEOUTS} from '../../fixtures/models';
22+
import {
23+
ensureTestUser,
24+
resetPalOwnership,
25+
palshubTestConfig,
26+
} from '../../helpers/palshubTestApi';
27+
import {SCREENSHOT_DIR} from '../../wdio.shared.conf';
28+
29+
declare const driver: WebdriverIO.Browser;
30+
declare const browser: WebdriverIO.Browser;
31+
32+
describe('PalsHub authenticated purchase (iOS)', () => {
33+
let chatPage: ChatPage;
34+
let drawerPage: DrawerPage;
35+
let purchasePage: PalPurchasePage;
36+
37+
before(async () => {
38+
chatPage = new ChatPage();
39+
drawerPage = new DrawerPage();
40+
purchasePage = new PalPurchasePage();
41+
await chatPage.waitForReady(TIMEOUTS.appReady);
42+
});
43+
44+
beforeEach(async () => {
45+
// Clean slate on the server so the pal is unowned and the Buy button shows.
46+
await ensureTestUser();
47+
await resetPalOwnership();
48+
});
49+
50+
afterEach(async function (this: Mocha.Context) {
51+
if (this.currentTest?.state === 'failed') {
52+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
53+
const name = this.currentTest.title.replace(/\s+/g, '-');
54+
try {
55+
if (!fs.existsSync(SCREENSHOT_DIR)) {
56+
fs.mkdirSync(SCREENSHOT_DIR, {recursive: true});
57+
}
58+
await driver.saveScreenshot(
59+
path.join(SCREENSHOT_DIR, `failure-${name}-${stamp}.png`),
60+
);
61+
} catch (e) {
62+
console.error('Failed to capture screenshot:', (e as Error).message);
63+
}
64+
}
65+
});
66+
67+
it('completes checkout and flips Buy to Download', async () => {
68+
await chatPage.openDrawer();
69+
await drawerPage.navigateToPals();
70+
71+
await purchasePage.openPalDetail(palshubTestConfig.palId);
72+
73+
// Buy (logged out) -> sign in -> Buy again starts checkout.
74+
await purchasePage.signInAndStartCheckout(
75+
palshubTestConfig.email,
76+
palshubTestConfig.password,
77+
);
78+
await purchasePage.acceptAuthConsentIfPresent();
79+
80+
// test-complete grants ownership; reconcile flips Buy -> Download.
81+
await purchasePage.waitForDownloadButton();
82+
});
83+
});

ios/PocketPal.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
A8B4FFD92EA5731700CD3B4C /* PalEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B4FFD12EA5731700CD3B4C /* PalEntity.swift */; };
4040
A8B4FFDC2EA5736100CD3B4C /* DeepLinkModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B4FFDB2EA5736100CD3B4C /* DeepLinkModule.swift */; };
4141
A8B4FFDD2EA5736100CD3B4C /* DeepLinkModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A8B4FFDA2EA5736100CD3B4C /* DeepLinkModule.m */; };
42+
AB1001010000000000000001 /* AuthSessionModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1001010000000000000003 /* AuthSessionModule.swift */; };
43+
AB1001010000000000000002 /* AuthSessionModule.m in Sources */ = {isa = PBXBuildFile; fileRef = AB1001010000000000000004 /* AuthSessionModule.m */; };
4244
B70FB28C50CE468DAF8D023A /* JetBrainsMono-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = FF9D2CC3DF0F4073AB223CC1 /* JetBrainsMono-Regular.ttf */; };
4345
C0270FB5CD74463CA3FF5284 /* Inter-ExtraBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = B033E4AC2D8E41719BF5142F /* Inter-ExtraBold.ttf */; };
4446
C12BD91DEBF24C6F8A87DF6E /* JetBrainsMono-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 9FFFE5CF13C04970AB5BF8BF /* JetBrainsMono-Medium.ttf */; };
@@ -97,6 +99,8 @@
9799
A8B4FFDA2EA5736100CD3B4C /* DeepLinkModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = DeepLinkModule.m; path = PocketPal/DeepLinkModule.m; sourceTree = "<group>"; };
98100
A8B4FFDB2EA5736100CD3B4C /* DeepLinkModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeepLinkModule.swift; path = PocketPal/DeepLinkModule.swift; sourceTree = "<group>"; };
99101
A8D9D0A86B69377BF32D2540 /* Pods-PocketPal.profiling.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PocketPal.profiling.xcconfig"; path = "Target Support Files/Pods-PocketPal/Pods-PocketPal.profiling.xcconfig"; sourceTree = "<group>"; };
102+
AB1001010000000000000003 /* AuthSessionModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AuthSessionModule.swift; path = PocketPal/AuthSessionModule.swift; sourceTree = "<group>"; };
103+
AB1001010000000000000004 /* AuthSessionModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AuthSessionModule.m; path = PocketPal/AuthSessionModule.m; sourceTree = "<group>"; };
100104
AE37F4EB27084262BAE95F85 /* Inter-Light.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Inter-Light.ttf"; path = "../src/assets/fonts/Inter-Light.ttf"; sourceTree = "<group>"; };
101105
B033E4AC2D8E41719BF5142F /* Inter-ExtraBold.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Inter-ExtraBold.ttf"; path = "../src/assets/fonts/Inter-ExtraBold.ttf"; sourceTree = "<group>"; };
102106
BB20B47BBDAD48C3B5711DA7 /* StorefrontModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = StorefrontModule.m; path = PocketPal/StorefrontModule.m; sourceTree = "<group>"; };
@@ -159,6 +163,8 @@
159163
A8B4FFDB2EA5736100CD3B4C /* DeepLinkModule.swift */,
160164
BB20B47BBDAD48C3B5711DA7 /* StorefrontModule.m */,
161165
5EDB347C521A41619835CAD9 /* StorefrontModule.swift */,
166+
AB1001010000000000000004 /* AuthSessionModule.m */,
167+
AB1001010000000000000003 /* AuthSessionModule.swift */,
162168
A8B4FFCC2EA5731700CD3B4C /* AskPalIntent.swift */,
163169
A8B4FFCD2EA5731700CD3B4C /* LlamaInferenceEngine.swift */,
164170
A8B4FFCF2EA5731700CD3B4C /* OpenPalChatIntent.swift */,
@@ -514,6 +520,8 @@
514520
A8B4FFDD2EA5736100CD3B4C /* DeepLinkModule.m in Sources */,
515521
0D85337A512F432695A638E3 /* StorefrontModule.swift in Sources */,
516522
C75985DD3D2E4F2083261F4B /* StorefrontModule.m in Sources */,
523+
AB1001010000000000000001 /* AuthSessionModule.swift in Sources */,
524+
AB1001010000000000000002 /* AuthSessionModule.m in Sources */,
517525
A86CF0D42EA57BE100BFFCEE /* LlamaContextWrapper.mm in Sources */,
518526
A8B4FFD52EA5731700CD3B4C /* PalDataProvider.swift in Sources */,
519527
A8B4FFD62EA5731700CD3B4C /* PocketPalShortcuts.swift in Sources */,

ios/PocketPal/AppDelegate.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
6666
continue userActivity: NSUserActivity,
6767
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
6868
) -> Bool {
69-
// Handle universal links if needed in the future
7069
return false
7170
}
7271
}

ios/PocketPal/AuthSessionModule.m

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// AuthSessionModule.m
3+
// PocketPal
4+
//
5+
// Objective-C bridge for AuthSessionModule
6+
//
7+
8+
#import <React/RCTBridgeModule.h>
9+
10+
@interface RCT_EXTERN_MODULE(AuthSessionModule, NSObject)
11+
12+
RCT_EXTERN_METHOD(openAuth:(NSString *)url
13+
callbackScheme:(NSString *)scheme
14+
resolver:(RCTPromiseResolveBlock)resolve
15+
rejecter:(RCTPromiseRejectBlock)reject)
16+
17+
@end

0 commit comments

Comments
 (0)