|
| 1 | +# Atomic Mobile Testing — Deep Link Support |
| 2 | + |
| 3 | +Enables the external automation framework (`ahm-poc`) to open the OmniPizza mobile |
| 4 | +app directly on any target screen after hydrating state through the API, without |
| 5 | +executing a full user journey. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 1. Repository Findings |
| 10 | + |
| 11 | +| Item | Details | |
| 12 | +|------|---------| |
| 13 | +| Entry point | `frontend-mobile/App.tsx` → `NavigationContainer` wrapping a single `createNativeStackNavigator` | |
| 14 | +| Screens | 6: Login, Catalog, PizzaBuilder, Checkout, OrderSuccess, Profile | |
| 15 | +| State | Zustand (`useAppStore`) — ephemeral, no persistence layer | |
| 16 | +| URI scheme | `omnipizza://` already registered in `app.json` | |
| 17 | +| Deep linking before | Not implemented (scheme registered but no handlers) | |
| 18 | +| Cart hydration | Exists in `CheckoutScreen` — skips fetch if local cart is non-empty | |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## 2. Solution Summary |
| 23 | + |
| 24 | +React Navigation's built-in `linking` prop on `NavigationContainer` maps |
| 25 | +`omnipizza://<route>` URLs directly to screens, passing all query params as |
| 26 | +screen route params. |
| 27 | + |
| 28 | +A separate `useDeepLinkParams` hook processes the side-effect params that |
| 29 | +Navigation cannot handle on its own: |
| 30 | +- `market` / `lang` → update Zustand store (country + language) |
| 31 | +- `resetSession=true` → `logout()` + navigate to Login |
| 32 | +- `hydrateCart=true` → `clearCart()` so `CheckoutScreen`'s existing hydration runs |
| 33 | + |
| 34 | +`PizzaBuilderScreen` was extended with a minimal `pizzaId` + `size` fallback so it |
| 35 | +can be reached atomically by ID instead of requiring a full `Pizza` object. |
| 36 | + |
| 37 | +--- |
| 38 | + |
| 39 | +## 3. Files Modified |
| 40 | + |
| 41 | +| File | Type | Why | |
| 42 | +|------|------|-----| |
| 43 | +| `frontend-mobile/src/navigation/types.ts` | **Created** | Typed `RootStackParamList` with deep link params for every screen | |
| 44 | +| `frontend-mobile/src/navigation/linking.ts` | **Created** | React Navigation `LinkingOptions` mapping `omnipizza://` routes to screen names | |
| 45 | +| `frontend-mobile/src/hooks/useDeepLinkParams.ts` | **Created** | Hook that handles state side effects from URL params (market, lang, resetSession, hydrateCart) | |
| 46 | +| `frontend-mobile/App.tsx` | **Modified** | Added `linking` prop to `NavigationContainer`, wired `useDeepLinkParams`, exported `navigationRef` | |
| 47 | +| `frontend-mobile/src/screens/PizzaBuilderScreen.tsx` | **Modified** | Handle `pizzaId` and `size` deep link params as fallback when no full `pizza` object is passed | |
| 48 | + |
| 49 | +--- |
| 50 | + |
| 51 | +## 4. Deep Link Reference |
| 52 | + |
| 53 | +### Supported routes |
| 54 | + |
| 55 | +| Deep Link | Opens Screen | Notes | |
| 56 | +|-----------|-------------|-------| |
| 57 | +| `omnipizza://login` | LoginScreen | Always accessible | |
| 58 | +| `omnipizza://catalog` | CatalogScreen | Requires valid token in store | |
| 59 | +| `omnipizza://pizza-builder` | PizzaBuilderScreen | Requires `pizzaId` param | |
| 60 | +| `omnipizza://checkout` | CheckoutScreen | Use `hydrateCart=true` for API-injected carts | |
| 61 | +| `omnipizza://order-success` | OrderSuccessScreen | Pass `orderId` for test assertions | |
| 62 | +| `omnipizza://profile` | ProfileScreen | Requires valid token in store | |
| 63 | + |
| 64 | +### Universal query params (any route) |
| 65 | + |
| 66 | +| Param | Values | Effect | |
| 67 | +|-------|--------|--------| |
| 68 | +| `market` | `US` `MX` `CH` `JP` | Sets country in store; auto-updates language | |
| 69 | +| `lang` | `de` `fr` | Sets CH language preference (ignored for other markets) | |
| 70 | +| `resetSession` | `true` | Logs out, clears cart, navigates to Login | |
| 71 | + |
| 72 | +### Route-specific params |
| 73 | + |
| 74 | +| Route | Param | Effect | |
| 75 | +|-------|-------|--------| |
| 76 | +| `pizza-builder` | `pizzaId=<id>` | Fetches pizza from catalog by ID | |
| 77 | +| `pizza-builder` | `size=small\|medium\|large\|family` | Pre-selects size | |
| 78 | +| `checkout` | `hydrateCart=true` | Clears local cart so screen fetches from backend | |
| 79 | +| `order-success` | `orderId=<id>` | Available in route params for test assertions | |
| 80 | + |
| 81 | +--- |
| 82 | + |
| 83 | +## 5. Deep Link Examples |
| 84 | + |
| 85 | +``` |
| 86 | +# Open Login (cold reset before test suite) |
| 87 | +omnipizza://login?resetSession=true |
| 88 | +
|
| 89 | +# Open Catalog in JP market |
| 90 | +omnipizza://catalog?market=JP&lang=ja |
| 91 | +
|
| 92 | +# Open PizzaBuilder with a specific pizza pre-loaded, family size, MX market |
| 93 | +omnipizza://pizza-builder?pizzaId=pepperoni&size=family&market=MX&lang=es |
| 94 | +
|
| 95 | +# Open Checkout with API-injected cart (requires POST /api/cart first) |
| 96 | +omnipizza://checkout?market=US&lang=en&hydrateCart=true |
| 97 | +
|
| 98 | +# Open Checkout in Switzerland, French language |
| 99 | +omnipizza://checkout?market=CH&lang=fr&hydrateCart=true |
| 100 | +
|
| 101 | +# Open OrderSuccess with order ID for assertion |
| 102 | +omnipizza://order-success?orderId=12345&market=JP&lang=ja |
| 103 | +
|
| 104 | +# Open Profile in US market |
| 105 | +omnipizza://profile?market=US&lang=en |
| 106 | +``` |
| 107 | + |
| 108 | +--- |
| 109 | + |
| 110 | +## 6. External Automation Usage (`ahm-poc`) |
| 111 | + |
| 112 | +The pattern for each atomic test: |
| 113 | + |
| 114 | +``` |
| 115 | +1. API setup |
| 116 | + POST /api/auth/login → get token |
| 117 | + POST /api/market (header) → set X-Country-Code |
| 118 | + POST /api/cart { items: [...] } → seed cart |
| 119 | +
|
| 120 | +2. Deep link |
| 121 | + driver.execute("mobile: deepLink", { |
| 122 | + url: "omnipizza://checkout?market=MX&lang=es&hydrateCart=true", |
| 123 | + package: "com.omnipizza.app" // Android |
| 124 | + }) |
| 125 | +
|
| 126 | + // iOS (XCUITest / Appium) |
| 127 | + driver.execute("mobile: deepLink", { |
| 128 | + url: "omnipizza://checkout?market=MX&lang=es&hydrateCart=true" |
| 129 | + }) |
| 130 | +
|
| 131 | + // Or via adb (Android direct) |
| 132 | + adb shell am start \ |
| 133 | + -W -a android.intent.action.VIEW \ |
| 134 | + -d "omnipizza://checkout?market=MX&lang=es&hydrateCart=true" \ |
| 135 | + com.omnipizza.app |
| 136 | +
|
| 137 | + // Or via xcrun simctl (iOS simulator) |
| 138 | + xcrun simctl openurl booted \ |
| 139 | + "omnipizza://checkout?market=MX&lang=es&hydrateCart=true" |
| 140 | +
|
| 141 | +3. Assert |
| 142 | + await expect(element(by.id("screen-checkout"))).toBeVisible(); |
| 143 | + await expect(element(by.id("view-order-summary"))).toBeVisible(); |
| 144 | +``` |
| 145 | + |
| 146 | +### Flow example — Checkout atomic test (MX market) |
| 147 | + |
| 148 | +```bash |
| 149 | +# 1. Login and seed state |
| 150 | +TOKEN=$(curl -s -X POST https://omnipizza-backend.onrender.com/api/auth/login \ |
| 151 | + -H "Content-Type: application/json" \ |
| 152 | + -d '{"username":"demo_mx","password":"demo123"}' | jq -r .access_token) |
| 153 | + |
| 154 | +curl -s -X POST https://omnipizza-backend.onrender.com/api/cart \ |
| 155 | + -H "Authorization: Bearer $TOKEN" \ |
| 156 | + -H "X-Country-Code: MX" \ |
| 157 | + -H "Content-Type: application/json" \ |
| 158 | + -d '{"items":[{"pizza_id":"pepperoni","size":"large","quantity":2}]}' |
| 159 | + |
| 160 | +# 2. Set token in app (via Detox launchArgs or Appium capability) |
| 161 | +# 3. Open checkout directly |
| 162 | +adb shell am start -W -a android.intent.action.VIEW \ |
| 163 | + -d "omnipizza://checkout?market=MX&lang=es&hydrateCart=true" \ |
| 164 | + com.omnipizza.app |
| 165 | +``` |
| 166 | + |
| 167 | +--- |
| 168 | + |
| 169 | +## 7. Manual Validation Steps |
| 170 | + |
| 171 | +### iOS Simulator |
| 172 | +```bash |
| 173 | +xcrun simctl openurl booted omnipizza://login |
| 174 | +xcrun simctl openurl booted omnipizza://catalog?market=JP |
| 175 | +xcrun simctl openurl booted "omnipizza://pizza-builder?pizzaId=pepperoni&size=large&market=US" |
| 176 | +xcrun simctl openurl booted "omnipizza://checkout?market=MX&lang=es&hydrateCart=true" |
| 177 | +xcrun simctl openurl booted "omnipizza://order-success?orderId=abc123&market=US" |
| 178 | +xcrun simctl openurl booted omnipizza://profile?market=CH&lang=fr |
| 179 | +``` |
| 180 | + |
| 181 | +### Android Emulator |
| 182 | +```bash |
| 183 | +adb shell am start -W -a android.intent.action.VIEW -d "omnipizza://login" com.omnipizza.app |
| 184 | +adb shell am start -W -a android.intent.action.VIEW -d "omnipizza://catalog?market=JP" com.omnipizza.app |
| 185 | +adb shell am start -W -a android.intent.action.VIEW -d "omnipizza://pizza-builder?pizzaId=pepperoni&size=large&market=US" com.omnipizza.app |
| 186 | +adb shell am start -W -a android.intent.action.VIEW -d "omnipizza://checkout?market=MX&lang=es&hydrateCart=true" com.omnipizza.app |
| 187 | +adb shell am start -W -a android.intent.action.VIEW -d "omnipizza://order-success?orderId=abc123&market=US" com.omnipizza.app |
| 188 | +adb shell am start -W -a android.intent.action.VIEW -d "omnipizza://profile?market=CH&lang=fr" com.omnipizza.app |
| 189 | +``` |
| 190 | + |
| 191 | +### Validation checklist |
| 192 | + |
| 193 | +- [ ] `omnipizza://login` opens LoginScreen directly |
| 194 | +- [ ] `omnipizza://catalog?market=JP` opens CatalogScreen and displays JPY prices |
| 195 | +- [ ] `omnipizza://pizza-builder?pizzaId=pepperoni&size=large` loads pepperoni pizza with Large pre-selected |
| 196 | +- [ ] `omnipizza://pizza-builder?pizzaId=unknown` falls back (goBack → Catalog) |
| 197 | +- [ ] `omnipizza://checkout?hydrateCart=true` with seeded backend cart shows correct items |
| 198 | +- [ ] `omnipizza://checkout?market=MX` shows MX-specific fields (colonia, zip) |
| 199 | +- [ ] `omnipizza://order-success?orderId=12345` opens OrderSuccess screen |
| 200 | +- [ ] `omnipizza://profile?market=CH&lang=fr` opens Profile with French locale |
| 201 | +- [ ] `omnipizza://login?resetSession=true` clears session and lands on Login |
| 202 | +- [ ] Warm start (app already open): deep link switches screen correctly |
| 203 | + |
| 204 | +--- |
| 205 | + |
| 206 | +## 8. Technical Notes |
| 207 | + |
| 208 | +### Architecture decisions |
| 209 | + |
| 210 | +**Why React Navigation `linking` + a separate hook?** |
| 211 | +React Navigation's `linking` config handles URL-to-screen routing automatically |
| 212 | +(both cold start and warm start). The hook handles state mutations that don't map |
| 213 | +to screen params — you cannot tell Navigation to "also call `setCountry()`" via URL |
| 214 | +mapping alone. |
| 215 | + |
| 216 | +**Why `hydrateCart` clears local cart instead of fetching itself?** |
| 217 | +`CheckoutScreen` already has robust cart hydration logic (with error fallback, |
| 218 | +cancellation, cart item normalization). Duplicating that in the hook would violate |
| 219 | +DRY. The hook just signals intent by clearing the local cart; the screen does the rest. |
| 220 | + |
| 221 | +**Why `resetSession` navigates to Login via `navRef.reset()`?** |
| 222 | +`navigation.navigate("Login")` would push Login onto the stack, preserving the |
| 223 | +current screen in history. `reset()` replaces the entire stack, which matches the |
| 224 | +expected behavior for a full session reset. |
| 225 | + |
| 226 | +### Edge cases |
| 227 | + |
| 228 | +| Scenario | Behavior | |
| 229 | +|----------|---------| |
| 230 | +| `omnipizza://pizza-builder` with no `pizzaId` | Screen renders `null` (existing behavior — `if (!pizza) return null`) | |
| 231 | +| `omnipizza://pizza-builder?pizzaId=<nonexistent>` | Screen calls `navigation.goBack()` | |
| 232 | +| `omnipizza://checkout` with empty local cart but no backend cart | Hydration runs, finds nothing, screen renders with empty cart | |
| 233 | +| `market=CH&lang=es` | `setCountry("CH")` sets German, then `setLanguage("es")` is ignored (not de/fr) | |
| 234 | +| Deep link while unauthenticated | Screen renders normally; screens that require a token will hit API errors and their own fallback UIs | |
| 235 | +| Cold start timing | `getInitialURL()` is awaited asynchronously; `navigationRef.isReady()` guard prevents navigation before container mounts | |
| 236 | + |
| 237 | +### No auth guard in deep link handler |
| 238 | + |
| 239 | +Screens requiring authentication (Catalog, Checkout, Profile, etc.) are not |
| 240 | +individually guarded by the deep link handler. The screens themselves handle |
| 241 | +auth errors through their existing API calls. Adding a centralized auth guard |
| 242 | +is possible but would require a meaningful architecture change; for a test/demo |
| 243 | +app the current behavior is acceptable. |
0 commit comments