| 1 | /** |
| 2 | * True `alchemy dev` end-to-end for the Job app — the regression test for |
| 3 | * the dev-mode apply failure where the SNS→Lambda glue resources |
| 4 | * (AWS.SNS.Subscription and the AWS.Lambda.Permission behind |
| 5 | * `consumeTopicNotifications`) were created against REAL AWS with floci |
| 6 | * ARNs and died with `InvalidParameterException: Invalid parameter: |
| 7 | * TopicArn` / `ResourceNotFoundException: Function not found`. |
| 8 | * |
| 9 | * This stack is also the mixed-mode case: the DynamoDB table, SQS queue, |
| 10 | * SNS topic, Lambda function, event source mapping, subscription, and |
| 11 | * permission are all local (floci), while the mode-agnostic CloudWatch |
| 12 | * Dashboard + Alarm deploy live — so the run needs AWS credentials, and a |
| 13 | * green apply proves local glue and live mode-agnostic resources coexist. |
| 14 | * |
| 15 | * Mirrored from examples/aws-dev/test/dev.test.ts (which owns the broader |
| 16 | * per-binding + hot-reload coverage); this suite drives the real app: |
| 17 | * POST a job over the emulator-served Function URL, read it back, and let |
| 18 | * the notification publish ride the local Subscription. |
| 19 | */ |
| 20 | import { afterAll, expect, test } from "bun:test"; |
| 21 | import { DevCli, fetchOk } from "alchemy-test/DevCli"; |
| 22 | import { spawnSync } from "node:child_process"; |
| 23 | import * as path from "node:path"; |
| 24 | |
| 25 | const root = path.resolve(import.meta.dirname, ".."); |
| 26 | const STAGE = "dev-cli-test"; |
| 27 | const cli = new DevCli({ root, stage: STAGE }); |
| 28 | |
| 29 | // The whole suite needs docker (floci runs as a container). |
| 30 | const dockerAvailable = |
| 31 | spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; |
| 32 | |
| 33 | afterAll(async () => { |
| 34 | await cli.stop(); |
| 35 | if (!process.env.NO_DESTROY && dockerAvailable) { |
| 36 | cli.destroy(); |
| 37 | } |
| 38 | }, 180_000); |
| 39 | |
| 40 | test.skipIf(!dockerAvailable)( |
| 41 | "alchemy dev applies the mixed local/live Job stack and serves jobs", |
| 42 | async () => { |
| 43 | cli.start(); |
| 44 | |
| 45 | // The first dev deploy may pull the floci image and provision the |
| 46 | // local data plane before printing stack outputs. |
| 47 | const url = await cli.pollUntil( |
| 48 | "url in stack outputs", |
| 49 | () => cli.outputUrl("url"), |
| 50 | { tries: 300, delayMs: 1000 }, |
| 51 | ); |
| 52 | |
| 53 | // Dev identity: the function URL is served locally, not by AWS |
| 54 | // (e.g. http://<id>.lambda-url.us-east-1.localhost:<port>/). The port |
| 55 | // is whatever the emulator bound — never hard-code it, only the URL |
| 56 | // captured from the CLI's stdout is authoritative. |
| 57 | expect(new URL(url).hostname).toEndWith("localhost"); |
| 58 | expect(url).not.toContain("amazonaws.com"); |
| 59 | |
| 60 | // The bug failed the APPLY itself: Subscription/Permission creation |
| 61 | // against real AWS with floci ARNs. Outputs printing means apply |
| 62 | // succeeded, but pin the failure banner too so a partial apply that |
| 63 | // still prints outputs can never sneak past. |
| 64 | expect(cli.output).not.toContain("apply failed"); |
| 65 | |
| 66 | const api = new URL(url); |
| 67 | api.search = ""; |
| 68 | |
| 69 | // POST a job: stores it in the local table AND publishes the |
| 70 | // job.created notification over the local topic — which only works |
| 71 | // when the Subscription + invoke Permission were created on floci. |
| 72 | const content = `hello from alchemy dev ${crypto.randomUUID()}`; |
| 73 | const created = (await ( |
| 74 | await fetchOk(api, { |
| 75 | method: "POST", |
| 76 | headers: { "content-type": "text/plain" }, |
| 77 | body: content, |
| 78 | }) |
| 79 | ).json()) as { jobId: string }; |
| 80 | expect(created.jobId).toBeString(); |
| 81 | |
| 82 | // Read it back through the local DynamoDB storage. |
| 83 | const job = (await ( |
| 84 | await fetchOk(new URL(`/?jobId=${created.jobId}`, api)) |
| 85 | ).json()) as { id: string; content: string }; |
| 86 | expect(job.id).toBe(created.jobId); |
| 87 | expect(job.content).toBe(content); |
| 88 | }, |
| 89 | { timeout: 600_000 }, |
| 90 | ); |