11import { describe , test , expect } from 'bun:test'
2- import { readFileSync } from 'node:fs'
2+ import { readFileSync , existsSync } from 'node:fs'
33import { resolve } from 'node:path'
44
55const ROOT = resolve ( import . meta. dirname , '..' , '..' , '..' )
@@ -9,6 +9,10 @@ function readFile(path: string): string {
99 return readFileSync ( resolve ( ROOT , path ) , 'utf-8' )
1010}
1111
12+ // ---------------------------------------------------------------------------
13+ // Deploy workflow (SST)
14+ // ---------------------------------------------------------------------------
15+
1216describe ( 'deploy.yml' , ( ) => {
1317 const workflow = readFile ( '.github/workflows/deploy.yml' )
1418
@@ -17,45 +21,39 @@ describe('deploy.yml', () => {
1721 expect ( workflow ) . toContain ( 'workflow_dispatch' )
1822 } )
1923
20- test ( 'has migrate job that runs before deploys ' , ( ) => {
24+ test ( 'has migrate job that runs before deploy ' , ( ) => {
2125 expect ( workflow ) . toContain ( 'migrate:' )
2226 expect ( workflow ) . toContain ( 'Run database migrations' )
2327 expect ( workflow ) . toContain ( 'bun run db:migrate:run' )
2428 expect ( workflow ) . toContain ( 'DATABASE_URL' )
2529 } )
2630
27- test ( 'has deploy-api job using Fly.io ' , ( ) => {
28- expect ( workflow ) . toContain ( 'deploy-api :' )
31+ test ( 'has deploy job using SST ' , ( ) => {
32+ expect ( workflow ) . toContain ( 'deploy:' )
2933 expect ( workflow ) . toContain ( 'needs: [migrate]' )
30- expect ( workflow ) . toContain ( 'superfly/flyctl-actions/setup-flyctl' )
31- expect ( workflow ) . toContain ( 'flyctl deploy' )
32- expect ( workflow ) . toContain ( 'FLY_API_TOKEN' )
34+ expect ( workflow ) . toContain ( 'bunx sst deploy' )
3335 } )
3436
35- test ( 'deploy-api references correct Dockerfile and fly.toml paths' , ( ) => {
36- expect ( workflow ) . toContain ( '--config apps/api/fly.toml' )
37- expect ( workflow ) . toContain ( '--dockerfile apps/api/Dockerfile' )
37+ test ( 'deploy job assumes AWS role via OIDC' , ( ) => {
38+ expect ( workflow ) . toContain ( 'aws-actions/configure-aws-credentials@v4' )
39+ expect ( workflow ) . toContain ( 'role-to-assume' )
40+ expect ( workflow ) . toContain ( 'AWS_ROLE_ARN' )
3841 } )
3942
40- test ( 'has deploy-web job using Vercel' , ( ) => {
41- expect ( workflow ) . toContain ( 'deploy-web:' )
42- expect ( workflow ) . toContain ( 'vercel deploy --prod' )
43- expect ( workflow ) . toContain ( 'VERCEL_TOKEN' )
44- expect ( workflow ) . toContain ( 'VERCEL_ORG_ID' )
45- expect ( workflow ) . toContain ( 'VERCEL_PROJECT_ID' )
43+ test ( 'requests id-token permission for OIDC' , ( ) => {
44+ expect ( workflow ) . toContain ( 'id-token: write' )
45+ expect ( workflow ) . toContain ( 'contents: read' )
4646 } )
4747
48- test ( 'both deploy jobs depend on migrate' , ( ) => {
49- const apiNeeds = workflow . match ( / d e p l o y - a p i : [ \s \S ] * ?n e e d s : \s * \[ m i g r a t e \] / )
50- const webNeeds = workflow . match ( / d e p l o y - w e b : [ \s \S ] * ?n e e d s : \s * \[ m i g r a t e \] / )
51- expect ( apiNeeds ) . not . toBeNull ( )
52- expect ( webNeeds ) . not . toBeNull ( )
48+ test ( 'supports stage selection via workflow_dispatch' , ( ) => {
49+ expect ( workflow ) . toContain ( 'stage:' )
50+ expect ( workflow ) . toContain ( 'SST stage to deploy' )
51+ expect ( workflow ) . toMatch ( / o p t i o n s : \s * \n \s * - d e v \s * \n \s * - s t a g i n g \s * \n \s * - p r o d u c t i o n / )
5352 } )
5453
55- test ( 'all jobs use production environment' , ( ) => {
56- const envMatches = workflow . match ( / e n v i r o n m e n t : \s * p r o d u c t i o n / g)
57- expect ( envMatches ) . not . toBeNull ( )
58- expect ( envMatches ! . length ) . toBeGreaterThanOrEqual ( 3 )
54+ test ( 'uses concurrency group to prevent parallel deploys' , ( ) => {
55+ expect ( workflow ) . toContain ( 'concurrency:' )
56+ expect ( workflow ) . toContain ( 'cancel-in-progress: false' )
5957 } )
6058
6159 test ( 'has no TODO placeholders' , ( ) => {
@@ -64,6 +62,106 @@ describe('deploy.yml', () => {
6462 } )
6563} )
6664
65+ // ---------------------------------------------------------------------------
66+ // Docker build workflow
67+ // ---------------------------------------------------------------------------
68+
69+ describe ( 'docker-build.yml' , ( ) => {
70+ const workflow = readFile ( '.github/workflows/docker-build.yml' )
71+
72+ test ( 'triggers on API and dependency path changes' , ( ) => {
73+ expect ( workflow ) . toContain ( 'apps/api/**' )
74+ expect ( workflow ) . toContain ( 'packages/contract/**' )
75+ expect ( workflow ) . toContain ( 'packages/db/**' )
76+ } )
77+
78+ test ( 'uses ECR for container registry' , ( ) => {
79+ expect ( workflow ) . toContain ( 'amazon-ecr-login@v2' )
80+ expect ( workflow ) . toContain ( 'sandchest-api' )
81+ } )
82+
83+ test ( 'references correct Dockerfile path' , ( ) => {
84+ expect ( workflow ) . toContain ( 'file: apps/api/Dockerfile' )
85+ } )
86+
87+ test ( 'tags images with SHA and latest' , ( ) => {
88+ expect ( workflow ) . toContain ( 'github.sha' )
89+ expect ( workflow ) . toContain ( 'sandchest-api:latest' )
90+ } )
91+
92+ test ( 'uses GitHub Actions cache for buildx' , ( ) => {
93+ expect ( workflow ) . toContain ( 'cache-from: type=gha' )
94+ expect ( workflow ) . toContain ( 'cache-to: type=gha,mode=max' )
95+ } )
96+ } )
97+
98+ // ---------------------------------------------------------------------------
99+ // Rust build workflow
100+ // ---------------------------------------------------------------------------
101+
102+ describe ( 'rust-build.yml' , ( ) => {
103+ const workflow = readFile ( '.github/workflows/rust-build.yml' )
104+
105+ test ( 'triggers on node daemon and proto changes' , ( ) => {
106+ expect ( workflow ) . toContain ( 'crates/sandchest-node/**' )
107+ expect ( workflow ) . toContain ( 'packages/contract/proto/**' )
108+ } )
109+
110+ test ( 'builds release binary for sandchest-node' , ( ) => {
111+ expect ( workflow ) . toContain ( 'cargo build --release --package sandchest-node' )
112+ } )
113+
114+ test ( 'installs protobuf compiler' , ( ) => {
115+ expect ( workflow ) . toContain ( 'protobuf-compiler' )
116+ } )
117+
118+ test ( 'uploads binary artifact and pushes to S3' , ( ) => {
119+ expect ( workflow ) . toContain ( 'upload-artifact@v4' )
120+ expect ( workflow ) . toContain ( 'retention-days: 30' )
121+ expect ( workflow ) . toContain ( 'aws s3 cp' )
122+ expect ( workflow ) . toContain ( 'binaries/sandchest-node' )
123+ } )
124+ } )
125+
126+ // ---------------------------------------------------------------------------
127+ // CI workflow
128+ // ---------------------------------------------------------------------------
129+
130+ describe ( 'ci.yml' , ( ) => {
131+ const workflow = readFile ( '.github/workflows/ci.yml' )
132+
133+ test ( 'runs on PR and push to main' , ( ) => {
134+ expect ( workflow ) . toContain ( 'pull_request:' )
135+ expect ( workflow ) . toContain ( 'branches: [main]' )
136+ } )
137+
138+ test ( 'has typecheck and lint job' , ( ) => {
139+ expect ( workflow ) . toContain ( 'typecheck-and-lint' )
140+ expect ( workflow ) . toContain ( 'bun run typecheck' )
141+ expect ( workflow ) . toContain ( 'bun run lint' )
142+ } )
143+
144+ test ( 'has TypeScript test job' , ( ) => {
145+ expect ( workflow ) . toContain ( 'test-ts' )
146+ expect ( workflow ) . toContain ( 'bun run test' )
147+ } )
148+
149+ test ( 'has Rust check job with clippy' , ( ) => {
150+ expect ( workflow ) . toContain ( 'check-rust' )
151+ expect ( workflow ) . toContain ( 'cargo check --workspace' )
152+ expect ( workflow ) . toContain ( 'cargo test --workspace' )
153+ expect ( workflow ) . toContain ( 'cargo clippy --workspace -- -D warnings' )
154+ } )
155+
156+ test ( 'cancels in-progress runs on same ref' , ( ) => {
157+ expect ( workflow ) . toContain ( 'cancel-in-progress: true' )
158+ } )
159+ } )
160+
161+ // ---------------------------------------------------------------------------
162+ // Dockerfile
163+ // ---------------------------------------------------------------------------
164+
67165describe ( 'Dockerfile' , ( ) => {
68166 const dockerfile = readFileSync ( resolve ( API_DIR , 'Dockerfile' ) , 'utf-8' )
69167
@@ -108,28 +206,91 @@ describe('Dockerfile', () => {
108206 } )
109207} )
110208
111- describe ( 'fly.toml' , ( ) => {
112- const flytoml = readFileSync ( resolve ( API_DIR , 'fly.toml' ) , 'utf-8' )
209+ // ---------------------------------------------------------------------------
210+ // SST config
211+ // ---------------------------------------------------------------------------
212+
213+ describe ( 'sst.config.ts' , ( ) => {
214+ const config = readFile ( 'sst.config.ts' )
215+
216+ test ( 'exists and imports all infra modules' , ( ) => {
217+ expect ( config ) . toContain ( 'infra/alarms' )
218+ expect ( config ) . toContain ( 'infra/app' )
219+ expect ( config ) . toContain ( 'infra/bucket' )
220+ expect ( config ) . toContain ( 'infra/cluster' )
221+ expect ( config ) . toContain ( 'infra/node' )
222+ expect ( config ) . toContain ( 'infra/oidc' )
223+ expect ( config ) . toContain ( 'infra/redis' )
224+ expect ( config ) . toContain ( 'infra/vpc' )
225+ } )
226+
227+ test ( 'creates all core infrastructure resources' , ( ) => {
228+ expect ( config ) . toContain ( 'sst.aws.Vpc' )
229+ expect ( config ) . toContain ( 'sst.aws.Redis' )
230+ expect ( config ) . toContain ( 'sst.aws.Bucket' )
231+ expect ( config ) . toContain ( 'sst.aws.Cluster' )
232+ expect ( config ) . toContain ( 'sst.Secret' )
233+ } )
234+
235+ test ( 'links secrets and resources to API service' , ( ) => {
236+ expect ( config ) . toContain ( 'link: [' )
237+ expect ( config ) . toContain ( 'redis' )
238+ expect ( config ) . toContain ( 'artifactBucket' )
239+ expect ( config ) . toContain ( 'databaseUrl' )
240+ expect ( config ) . toContain ( 'betterAuthSecret' )
241+ expect ( config ) . toContain ( 'resendApiKey' )
242+ } )
243+
244+ test ( 'creates node daemon EC2 instance' , ( ) => {
245+ expect ( config ) . toContain ( 'aws.ec2.Instance' )
246+ expect ( config ) . toContain ( 'NodeInstance' )
247+ } )
248+
249+ test ( 'creates GitHub OIDC provider and deploy role' , ( ) => {
250+ expect ( config ) . toContain ( 'aws.iam.OpenIdConnectProvider' )
251+ expect ( config ) . toContain ( 'GitHubOidc' )
252+ expect ( config ) . toContain ( 'DeployRole' )
253+ } )
113254
114- test ( 'sets app name' , ( ) => {
115- expect ( flytoml ) . toContain ( 'app = "sandchest-api"' )
255+ test ( 'creates CloudWatch alarms' , ( ) => {
256+ expect ( config ) . toContain ( 'EcsRunningTaskAlarm' )
257+ expect ( config ) . toContain ( 'EcsCpuAlarm' )
258+ expect ( config ) . toContain ( 'EcsMemoryAlarm' )
259+ expect ( config ) . toContain ( 'Alb5xxAlarm' )
260+ expect ( config ) . toContain ( 'AlbResponseTimeAlarm' )
261+ expect ( config ) . toContain ( 'RedisMemoryAlarm' )
262+ expect ( config ) . toContain ( 'RedisEvictionAlarm' )
263+ expect ( config ) . toContain ( 'NodeHeartbeatAlarm' )
116264 } )
117265
118- test ( 'configures HTTP service on port 3001' , ( ) => {
119- expect ( flytoml ) . toContain ( 'internal_port = 3001' )
120- expect ( flytoml ) . toContain ( 'force_https = true' )
266+ test ( 'exports infrastructure outputs' , ( ) => {
267+ expect ( config ) . toContain ( 'vpcId' )
268+ expect ( config ) . toContain ( 'redisHost' )
269+ expect ( config ) . toContain ( 'artifactBucketName' )
270+ expect ( config ) . toContain ( 'apiUrl' )
271+ expect ( config ) . toContain ( 'nodeInstanceId' )
272+ expect ( config ) . toContain ( 'deployRoleArn' )
273+ expect ( config ) . toContain ( 'alarmTopicArn' )
121274 } )
122275
123- test ( 'has health check on /healthz' , ( ) => {
124- expect ( flytoml ) . toContain ( 'path = "/healthz"' )
276+ test ( 'grants node IAM roles for S3 and CloudWatch' , ( ) => {
277+ expect ( config ) . toContain ( 'NodeS3Policy' )
278+ expect ( config ) . toContain ( 'NodeCloudWatchPolicy' )
279+ expect ( config ) . toContain ( 's3:PutObject' )
280+ expect ( config ) . toContain ( 's3:GetObject' )
281+ expect ( config ) . toContain ( 'cloudwatch:PutMetricData' )
125282 } )
126283
127- test ( 'configures auto-start and minimum machines' , ( ) => {
128- expect ( flytoml ) . toContain ( 'auto_start_machines = true' )
129- expect ( flytoml ) . toContain ( 'min_machines_running = 1' )
284+ test ( 'references Dockerfile in correct location' , ( ) => {
285+ expect ( config ) . toContain ( 'dockerfile: "apps/api/Dockerfile"' )
130286 } )
131287
132- test ( 'references correct Dockerfile' , ( ) => {
133- expect ( flytoml ) . toContain ( 'dockerfile = "Dockerfile"' )
288+ test ( 'required files exist' , ( ) => {
289+ expect ( existsSync ( resolve ( ROOT , 'sst.config.ts' ) ) ) . toBe ( true )
290+ expect ( existsSync ( resolve ( ROOT , 'apps/api/Dockerfile' ) ) ) . toBe ( true )
291+ expect ( existsSync ( resolve ( ROOT , '.github/workflows/deploy.yml' ) ) ) . toBe ( true )
292+ expect ( existsSync ( resolve ( ROOT , '.github/workflows/docker-build.yml' ) ) ) . toBe ( true )
293+ expect ( existsSync ( resolve ( ROOT , '.github/workflows/rust-build.yml' ) ) ) . toBe ( true )
294+ expect ( existsSync ( resolve ( ROOT , '.github/workflows/ci.yml' ) ) ) . toBe ( true )
134295 } )
135296} )
0 commit comments