@@ -34,6 +34,8 @@ import {
3434 updateDeploymentAccess ,
3535 reportExperimentConverted ,
3636 validatePromotion ,
37+ createStack ,
38+ fetchStackStatus ,
3739} from './index'
3840// §10.21: FIXTURE_BILLING / FIXTURE_INVOICES imports retired. The 503
3941// fallback paths in fetchBilling() and listInvoices() were removed —
@@ -1390,3 +1392,166 @@ describe('Admin URL prefix wiring', () => {
13901392 expect ( url ) . not . toContain ( '/api/v1/admin/customers' )
13911393 } )
13921394} )
1395+
1396+ // ─── createStack() — POST /stacks/new multipart upload ───────────────────
1397+ // W9: the human-driven "Create stack" form. The api endpoint is multipart-
1398+ // only; this helper builds the FormData body. We lock the field names
1399+ // because the api parser is strict about them.
1400+ describe ( 'createStack()' , ( ) => {
1401+ function fakeFile ( name : string , size : number ) : File {
1402+ const f = new File ( [ new ArrayBuffer ( size ) ] , name , { type : 'application/gzip' } )
1403+ return f
1404+ }
1405+
1406+ it ( 'POSTs to /stacks/new with a multipart body containing the tarball' , async ( ) => {
1407+ const m = installFetch ( )
1408+ m . mockResolvedValueOnce ( jsonResponse ( {
1409+ ok : true , slug : 'sunny-cat-9' , status : 'building' , url : null , name : 'sunny-cat-9' , env : 'development' ,
1410+ } ) )
1411+ const f = fakeFile ( 'app.tar.gz' , 2048 )
1412+ const r = await createStack ( f , {
1413+ name : 'my-app' ,
1414+ port : 3000 ,
1415+ env : 'staging' ,
1416+ env_vars : { API_KEY : 'secret' } ,
1417+ } )
1418+ expect ( r . ok ) . toBe ( true )
1419+ expect ( r . stack . slug ) . toBe ( 'sunny-cat-9' )
1420+ expect ( r . stack . status ) . toBe ( 'building' )
1421+
1422+ const [ url , init ] = m . mock . calls [ 0 ]
1423+ expect ( String ( url ) ) . toContain ( '/stacks/new' )
1424+ expect ( init ?. method ) . toBe ( 'POST' )
1425+ // Body is a FormData instance; assert the fields we appended.
1426+ const body = init ! . body as FormData
1427+ expect ( body ) . toBeInstanceOf ( FormData )
1428+ expect ( body . get ( 'name' ) ) . toBe ( 'my-app' )
1429+ expect ( body . get ( 'port' ) ) . toBe ( '3000' )
1430+ expect ( body . get ( 'env' ) ) . toBe ( 'staging' )
1431+ expect ( body . get ( 'env_vars' ) ) . toBe ( JSON . stringify ( { API_KEY : 'secret' } ) )
1432+ expect ( body . get ( 'tarball' ) ) . toBe ( f )
1433+ // CRITICAL — Content-Type must NOT be set on the request headers; the
1434+ // browser generates a multipart boundary automatically. Set it
1435+ // ourselves and the api parser bails.
1436+ const headers = init ?. headers as Headers
1437+ expect ( headers . has ( 'Content-Type' ) ) . toBe ( false )
1438+ } )
1439+
1440+ it ( 'defaults env to "development" when caller omits it (matches platform memory 2026-05-13)' , async ( ) => {
1441+ const m = installFetch ( )
1442+ m . mockResolvedValueOnce ( jsonResponse ( {
1443+ ok : true , slug : 's' , status : 'building' , url : null ,
1444+ } ) )
1445+ await createStack ( fakeFile ( 'a.tar.gz' , 100 ) , { } )
1446+ const body = m . mock . calls [ 0 ] [ 1 ] ! . body as FormData
1447+ expect ( body . get ( 'env' ) ) . toBe ( 'development' )
1448+ } )
1449+
1450+ it ( 'omits name + port + env_vars from the body when not provided' , async ( ) => {
1451+ const m = installFetch ( )
1452+ m . mockResolvedValueOnce ( jsonResponse ( {
1453+ ok : true , slug : 's' , status : 'building' , url : null ,
1454+ } ) )
1455+ await createStack ( fakeFile ( 'a.tar.gz' , 100 ) , { } )
1456+ const body = m . mock . calls [ 0 ] [ 1 ] ! . body as FormData
1457+ expect ( body . has ( 'name' ) ) . toBe ( false )
1458+ expect ( body . has ( 'port' ) ) . toBe ( false )
1459+ expect ( body . has ( 'env_vars' ) ) . toBe ( false )
1460+ // env always lands (defaults to 'development').
1461+ expect ( body . has ( 'env' ) ) . toBe ( true )
1462+ } )
1463+
1464+ it ( 'sends an Authorization: Bearer header when a token is present' , async ( ) => {
1465+ const m = installFetch ( )
1466+ setToken ( 'test-token-xyz' )
1467+ m . mockResolvedValueOnce ( jsonResponse ( { ok : true , slug : 's' , status : 'building' , url : null } ) )
1468+ await createStack ( fakeFile ( 'a.tar.gz' , 100 ) , { } )
1469+ const headers = m . mock . calls [ 0 ] [ 1 ] ?. headers as Headers
1470+ expect ( headers . get ( 'Authorization' ) ) . toBe ( 'Bearer test-token-xyz' )
1471+ } )
1472+
1473+ it ( 'propagates 402 (tier wall) so the page can show the upgrade banner' , async ( ) => {
1474+ const m = installFetch ( )
1475+ m . mockResolvedValueOnce ( jsonResponse (
1476+ { error : 'tier_limit' , message : 'upgrade to pro for more stacks' , agent_action : 'upgrade_to_pro' } ,
1477+ { status : 402 } ,
1478+ ) )
1479+ await expect ( createStack ( fakeFile ( 'a.tar.gz' , 100 ) , { } ) )
1480+ . rejects . toMatchObject ( { status : 402 } )
1481+ } )
1482+
1483+ it ( 'propagates 400 (invalid_tarball) so the form can render the message inline' , async ( ) => {
1484+ const m = installFetch ( )
1485+ m . mockResolvedValueOnce ( jsonResponse (
1486+ { error : 'invalid_tarball' , message : 'missing Dockerfile' } ,
1487+ { status : 400 } ,
1488+ ) )
1489+ await expect ( createStack ( fakeFile ( 'a.tar.gz' , 100 ) , { } ) )
1490+ . rejects . toMatchObject ( { status : 400 , message : 'missing Dockerfile' } )
1491+ } )
1492+
1493+ it ( 'propagates 413 (payload too large)' , async ( ) => {
1494+ const m = installFetch ( )
1495+ m . mockResolvedValueOnce ( jsonResponse ( { error : 'too_large' } , { status : 413 } ) )
1496+ await expect ( createStack ( fakeFile ( 'a.tar.gz' , 100 ) , { } ) )
1497+ . rejects . toMatchObject ( { status : 413 } )
1498+ } )
1499+
1500+ it ( 'returns slug + status from the 202 response shape' , async ( ) => {
1501+ const m = installFetch ( )
1502+ m . mockResolvedValueOnce ( jsonResponse ( {
1503+ ok : true ,
1504+ slug : 'rainy-tree-3' ,
1505+ status : 'building' ,
1506+ url : null ,
1507+ name : 'rainy-tree-3' ,
1508+ env : 'production' ,
1509+ } , { status : 202 } ) )
1510+ const r = await createStack ( fakeFile ( 'a.tar.gz' , 100 ) , { } )
1511+ expect ( r . stack . slug ) . toBe ( 'rainy-tree-3' )
1512+ expect ( r . stack . status ) . toBe ( 'building' )
1513+ expect ( r . stack . url ) . toBeNull ( )
1514+ } )
1515+ } )
1516+
1517+ // ─── fetchStackStatus() — GET /api/v1/stacks/:slug polling helper ───────
1518+ describe ( 'fetchStackStatus()' , ( ) => {
1519+ it ( 'GETs /api/v1/stacks/:slug and adapts the response shape' , async ( ) => {
1520+ const m = installFetch ( )
1521+ m . mockResolvedValueOnce ( jsonResponse ( {
1522+ ok : true ,
1523+ stack : {
1524+ stack_id : 's1' , slug : 's1' , name : 'my-stack' , status : 'running' ,
1525+ url : 'https://s1.deployment.instanode.dev' , env : 'production' , tier : 'pro' ,
1526+ created_at : '2026-05-13T00:00:00Z' ,
1527+ } ,
1528+ } ) )
1529+ const r = await fetchStackStatus ( 's1' )
1530+ expect ( r . ok ) . toBe ( true )
1531+ expect ( r . stack ?. slug ) . toBe ( 's1' )
1532+ expect ( r . stack ?. status ) . toBe ( 'running' )
1533+ expect ( r . stack ?. url ) . toBe ( 'https://s1.deployment.instanode.dev' )
1534+ expect ( String ( m . mock . calls [ 0 ] [ 0 ] ) ) . toContain ( '/api/v1/stacks/s1' )
1535+ } )
1536+
1537+ it ( 'returns stack=null on 404 instead of throwing' , async ( ) => {
1538+ const m = installFetch ( )
1539+ m . mockResolvedValueOnce ( jsonResponse ( { error : 'not_found' } , { status : 404 } ) )
1540+ const r = await fetchStackStatus ( 'missing' )
1541+ expect ( r . ok ) . toBe ( true )
1542+ expect ( r . stack ) . toBeNull ( )
1543+ } )
1544+
1545+ it ( 'URI-encodes the slug' , async ( ) => {
1546+ const m = installFetch ( )
1547+ m . mockResolvedValueOnce ( jsonResponse ( { ok : true , stack : { slug : 'a b' , status : 'building' } } ) )
1548+ await fetchStackStatus ( 'a b' )
1549+ expect ( String ( m . mock . calls [ 0 ] [ 0 ] ) ) . toContain ( '/api/v1/stacks/a%20b' )
1550+ } )
1551+
1552+ it ( 'propagates 5xx (not 404) so the polling caller can decide to retry' , async ( ) => {
1553+ const m = installFetch ( )
1554+ m . mockResolvedValueOnce ( jsonResponse ( { error : 'internal' } , { status : 500 } ) )
1555+ await expect ( fetchStackStatus ( 's1' ) ) . rejects . toMatchObject ( { status : 500 } )
1556+ } )
1557+ } )
0 commit comments