Inngest Orchestration Integration Tests — Implementation Plan
Overview
Phase 2 of the test-plan rollout closes three production gaps (Risks 2, 3, 6) and proves each one is protected through integration tests. All work lands in a single change: targeted production code hardening followed by the integration test suite.
Current State Analysis
The extractRecipe Inngest function (src/inngest/functions.ts) is a monolithic async
body — no step.run() steps, just sequential logic. This makes it untestable without an
Inngest dev server unless the pipeline body is extracted into an injectable helper.
Three concrete gaps found in the current code:
-
Risk 2 (output gate unwired):
isExtractedRecipeUsable()exists incontent-quality.ts:37and is fully unit-tested (Phase 1). It is NOT called infunctions.ts. The only post-LLM check is a title-only guard at line 191-193. A recipe with a valid title but empty ingredients/steps is persisted silently. -
Risk 3 (send-failure blind spot): In
actions.ts, therecipe_sharesrow is correctly created beforeinngest.send()(line 38 before line 59). But ifinngest.send()throws, the catch at line 70-72 only logs — the share staysstatus: 'pending'forever.getFailedShares()queries forstatus = 'failed'only, so the bell never surfaces it. -
Risk 6 (SSRF in archiveImage):
archiveImage()inrecipe-image-archive.ts:19callsfetch(externalUrl, ...)with no private-range blocklist. TheexternalUrlis derived fromscraped.metadata?.ogImage ?? extractFirstImage(html)— fully content-controlled. Firecrawl does not mediate the image download.
Desired End State
After this change:
- A known-junk fixture OR a title-only LLM response causes the Inngest function to throw before any Supabase insert/update is called — proven by integration test.
- A
inngest.send()failure marks the share row asfailed— proven by integration test. archiveImage()with a private/loopback/metadata URL returnsnullwithout making an outbound fetch — proven by integration test.pnpm testincludes all three new test files and passes.
Key Findings
functions.ts:129— junk check (looksUnextractable) fires correctly before the OpenAI call. The output-side gap is after line 189 (JSON.parse(content)) and before line 244 (the insert loop).functions.ts:199-233— force-refresh path skips the insert loop entirely; the output gate must also guard this path.recipe-image-archive.ts:19— the barefetch(externalUrl, ...)is the SSRF surface. No other code path callsarchiveImage()currently; guarding here protects all callers.actions.ts:70-72— the only change needed is in the catch block; the row already exists at this point, so the update is always safe.env.tsis imported at the top offunctions.ts; to make the pipeline body importable in Vitest without triggeringserver-only/ env-var checks, the extracted helper must live in a separate file that does NOT importenv.tsor anyserver-onlymodule.
What We Are NOT Doing
- No Inngest dev server setup or Inngest-managed step tests.
- No real Supabase connection in integration tests — mocked Supabase fluent chain only.
- No E2E Playwright extension (that is Phase 3).
- No fix for the PINNED long-GT-passes-gate known miss documented in
content-quality.test.ts. - No multi-user / RLS tests (§7 negative space).
Implementation Approach
Three phases: (1) production code hardening, (2) integration tests, (3) cookbook update. Phases 1 and 2 are sequenced — tests depend on the extracted helper. Phase 3 is a doc update after tests are green.
Phase 1: Production Code Hardening
Overview
Three targeted production changes: extract the pipeline into a testable helper, wire the output gate, fix the send-failure blind spot, and add the SSRF guard.
Required Changes
1.1 Extract pipeline into src/inngest/run-extract-recipe.ts
File: src/inngest/run-extract-recipe.ts (new file)
Goal: Move the entire async body of extractRecipe out of functions.ts into an
exported function that accepts injected dependencies. The Inngest wrapper in functions.ts
becomes a thin shell that instantiates the Supabase client and calls this helper.
Contract:
// Exported types (used by functions.ts and tests)export interface ExtractRecipeEvent { shareId: number sharedUrl: string sharedTitle?: string sharedText?: string userId: string sourceType?: 'facebook_text' | 'web_blog' | 'youtube' force?: boolean}
export interface RunExtractRecipeDeps { fetch: typeof globalThis.fetch supabase: import('@supabase/supabase-js').SupabaseClient}
export async function runExtractRecipe( event: ExtractRecipeEvent, deps: RunExtractRecipeDeps,): Promise<{ recipeId: number; title: string; category: string; status: string }>This file imports ONLY from pure lib modules (buildFirecrawlOptions, buildEmbedScanOptions,
slugify, archiveImage, youtubeIdFromUrl, findEmbeddedYoutubeId, isBlogspotUrl,
fetchBloggerPost, looksUnextractable, isExtractedRecipeUsable, RECIPE_CATEGORIES).
It must NOT import env.ts, server-only, or @/inngest/client.
Wire isExtractedRecipeUsable: Immediately after JSON.parse(content), add:
if (!isExtractedRecipeUsable(recipeJSON)) { throw new Error( 'Extracted content has no usable recipe (title present but no ingredients or steps)' )}This guard must run before the if (forceRefresh) branch AND before the insert loop —
the current title-only check at line 191-193 can stay as an additional guard or be folded
into isExtractedRecipeUsable (which already checks title).
1.2 Update src/inngest/functions.ts
File: src/inngest/functions.ts
Goal: Replace the full pipeline body with a thin wrapper that creates the Supabase
service-role client and delegates to runExtractRecipe.
Contract: The exported extractRecipe Inngest function stays at the same export name
and triggers event name recipe/extract — no caller changes.
export const extractRecipe = inngest.createFunction( { id: 'extract-recipe', retries: 3, triggers: { event: 'recipe/extract' } }, async ({ event }) => { const supabase = createClient(SUPABASE_URL, getSuabaseServiceRoleKey(), { auth: { autoRefreshToken: false, persistSession: false }, }) return runExtractRecipe(event.data as ExtractRecipeEvent, { fetch, supabase }) })1.3 Fix send-failure path in src/app/share/actions.ts
File: src/app/share/actions.ts
Goal: When inngest.send() throws, the share row must be updated to
status: 'failed' so getFailedShares() surfaces it in the notification bell.
Contract: Replace the silent catch block (currently lines 70-72) with:
} catch (error) { console.error('Failed to trigger extraction task:', error) await supabase .from('recipe_shares') .update({ status: 'failed', error_message: 'Failed to queue extraction' }) .eq('id', share.id)}The function should still return { shareId: share.id, message: ... } even when the
send fails — the caller (share route) shows the confirmation; the bell will surface the
failure asynchronously. Alternatively: throw after marking failed, letting the route show
an error. Either behaviour is acceptable; marking as failed is the non-negotiable part.
1.4 Add SSRF guard in src/lib/recipe-image-archive.ts
File: src/lib/recipe-image-archive.ts
Goal: Reject private/loopback/cloud-metadata addresses before fetch() is called,
preventing server-side request forgery via a content-controlled og:image URL.
Contract: Add a pure exported isPrivateUrl(url: string): boolean function above
archiveImage. At the top of archiveImage, before the fetch, call it and return
null early if matched.
The blocklist must cover at minimum:
- Loopback:
127.0.0.0/8,::1, hostnamelocalhost - Private RFC1918:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 - Link-local / metadata:
169.254.0.0/16, hostname169.254.169.254, hostnamemetadata.google.internal - Unspecified:
0.0.0.0
Parse via new URL(url) and check hostname. Return true (is private) on parse
failure — an invalid URL should never be fetched.
Exporting isPrivateUrl is required so the integration test can assert the logic
directly without creating a full archiveImage test fixture.
Criteria for Success
Automatic Verification
pnpm typecheckpasses — no TS errors in the refactored filespnpm lintpasses — no unused imports after movepnpm test— existing unit tests (content-quality.test.ts,firecrawl.test.ts, etc.) still pass
Manual Verification
- Trigger a recipe extraction from the UI end-to-end; confirm a normal recipe is saved correctly (no regression from the refactor)
- Confirm
pnpm typecheckandpnpm lintproduce zero errors
Phase 2: Integration Tests
Overview
Three new test files — one per risk. All use Vitest with mocked Supabase fluent chains
and mocked fetch. No Inngest dev server, no real network calls.
Required Changes
2.1 src/inngest/run-extract-recipe.test.ts (Risk 2)
File: src/inngest/run-extract-recipe.test.ts (new file)
Goal: Prove that the pipeline rejects junk and title-only LLM output before any Supabase write, that the force-refresh path is also guarded, and that the happy path and gap-fill paths call Supabase correctly.
Contract: Import runExtractRecipe and ExtractRecipeEvent from
@/inngest/run-extract-recipe. Build a makeSupabaseMock() helper that returns a
chainable vi.fn() object satisfying the Supabase query-builder fluent interface.
Build a makeFetchMock({ firecrawl, openai }) helper that routes by URL prefix.
Scenarios to cover (each a separate it block):
| Scenario | Risk | Expected outcome |
|---|---|---|
| Junk markdown AND junk html | 2 (input gate) | throws before supabase.from('recipes').insert is called |
Valid markdown; LLM returns { title, ingredients: [], steps: [] } | 2 (output gate) | throws before insert |
Valid markdown; LLM returns { title, ingredients: [], steps: [] }; force: true | 2 (force path) | throws before supabase.from('recipes').update |
URL collision (error.message includes recipes_user_source_url_uniq) | dedup | supabase.from('recipes').update called with youtube_id when existing youtube_id is null |
| Happy path | smoke | insert called once; recipe_shares updated to status: 'completed' |
Note on mocking strategy: runExtractRecipe uses deps.fetch for Firecrawl and OpenAI;
both are URL-routed in the mock. The Blogger feed path (fetchBloggerPost) is imported
from @/lib/blogger-feed — mock that module with vi.mock('@/lib/blogger-feed', ...) to
return null for all non-Blogger URLs (or mock isBlogspotUrl to return false).
2.2 src/app/share/actions.test.ts (Risk 3)
File: src/app/share/actions.test.ts (new file)
Goal: Prove that when inngest.send() throws, the share row is updated to
status: 'failed'.
Contract: triggerRecipeExtraction calls createSupabaseServerClient() (which uses
server-only) — mock this module. Also mock @/inngest/client so inngest.send can be
made to throw.
vi.mock('@/lib/supabase/server', ...) // return mock supabase with spyvi.mock('@/inngest/client', ...) // inngest.send = vi.fn().mockRejectedValue(new Error('queue down'))vi.mock('@/lib/recipe-dedup', ...) // findExistingRecipeForUrl → { status: 'none', normalizedUrl: ... }vi.mock('@/lib/detect-source-type', ...) // returns 'web_blog'Scenario:
inngest.send()rejects →supabase.from('recipe_shares').update({ status: 'failed', ... }).eq('id', share.id)is called.
2.3 src/lib/recipe-image-archive.test.ts (Risk 6)
File: src/lib/recipe-image-archive.test.ts (new file)
Goal: Prove that isPrivateUrl correctly identifies private addresses and that
archiveImage returns null without fetching for those URLs.
Contract: Import isPrivateUrl and archiveImage from @/lib/recipe-image-archive.
Use vi.stubGlobal('fetch', mockFetch) to observe whether fetch is called.
isPrivateUrl unit checks (fast, no Supabase mock needed):
'http://localhost/image.jpg'→ true'http://127.0.0.1/image.jpg'→ true'http://169.254.169.254/latest/meta-data/'→ true'http://metadata.google.internal/'→ true'http://10.0.0.1/image.jpg'→ true'http://192.168.1.1/image.jpg'→ true'not-a-url'→ true (parse failure = reject)'https://external-cdn.example.com/photo.jpg'→ false
archiveImage integration check:
- Private URL → returns
null; the stubbedfetchwas NOT called. - Valid HTTPS URL with allowed MIME → fetch IS called (normal flow).
Criteria for Success
Automatic Verification
pnpm test— all new tests pass with no skipspnpm typecheck— no errors in new test files
Manual Verification
- Review failure messages in the junk/output-gate tests: error message should clearly name the rejection reason (not a generic assertion error)
- Confirm the SSRF test failure message when
fetchis unexpectedly called is actionable
Phase 3: Cookbook Update
Overview
Update §6.2 in the test plan with the concrete integration test location, naming convention, mock strategy, and run command — matching the §6.1 unit-test cookbook pattern.
Required Changes
3.1 Update context/foundation/test-plan.md §6.2
File: context/foundation/test-plan.md
Goal: Replace the TBD placeholder in §6.2 with a concrete cookbook entry covering
the integration test patterns shipped in Phase 2.
Contract: Replace the ### 6.2 Integration tests section body with:
- Location and naming:
src/inngest/*.test.ts,src/app/**/*.test.ts,src/lib/*.test.tscolocated with the module under test; suffix.test.ts - Scope: pipeline orchestration logic with injected deps — no real DB, no real network
- Mock strategy:
vi.mockforserver-onlymodules; manual Supabase fluent-chain mock viamakeSupabaseMock();vi.stubGlobal('fetch', ...)ormakeFetchMock()for HTTP - Reference tests:
src/inngest/run-extract-recipe.test.ts(dependency injection pattern),src/app/share/actions.test.ts(server-only module mock pattern),src/lib/recipe-image-archive.test.ts(pure guard export pattern) - Run:
pnpm test(same runner as unit tests — Vitest,nodeenv,@/alias)
Criteria for Success
Automatic Verification
pnpm test— full suite still passes after the doc change
Manual Verification
- Read §6.2 and confirm a future contributor could add a new integration test for a new Inngest scenario without reading this plan
Testing Strategy
Unit Tests (pre-existing, must not regress)
All tests in Phase 1 already covered looksUnextractable and isExtractedRecipeUsable.
Phase 2 does not duplicate those; it tests the wired pipeline.
Integration Tests (new — Phase 2)
Mocked external I/O; tests pipeline ordering guarantees and the three risk invariants.
Manual Smoke
After Phase 1: one end-to-end extraction confirms no regression from the refactor.
After Phase 2: pnpm test is the primary gate; no additional manual test required.
References
- Test plan (risk map, rollout table):
context/foundation/test-plan.md - Phase 1 unit tests (patterns to follow):
src/lib/content-quality.test.ts - Pipeline function:
src/inngest/functions.ts - Share action:
src/app/share/actions.ts - Image archive:
src/lib/recipe-image-archive.ts - Failed-shares bell query:
src/lib/failed-shares.ts
Progress
Convention:
- [ ]pending,- [x]done. Add— <commit sha>when a step lands. Do not rename step titles.
Phase 1: Production Code Hardening
Automatic
- 1.1
pnpm typecheckpasses after extractingrun-extract-recipe.tsand wiring output gate - 1.2
pnpm lintpasses — no unused imports after move - 1.3
pnpm test— existing unit tests pass (no regression from refactor)
Manual
- 1.4 End-to-end recipe extraction works from UI after the refactor
Phase 2: Integration Tests
Automatic
- 2.1
pnpm test—run-extract-recipe.test.tspasses (5 scenarios: junk input, title-only output, force-refresh+junk, gap-fill, happy path) - 2.2
pnpm test—actions.test.tspasses (send-failure → share marked failed) - 2.3
pnpm test—recipe-image-archive.test.tspasses (private URLs blocked, fetch not called) - 2.4
pnpm typecheck— no errors in new test files
Manual
- 2.5 Review failure messages in junk/output-gate tests for actionability
Phase 3: Cookbook Update
Automatic
- 3.1
pnpm testpasses after §6.2 update (no code change, doc only)
Manual
- 3.2 §6.2 entry is self-contained — a contributor can add a new integration test from it alone