Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
listResources,
deleteResource,
listAPIKeys,
listStacks,
fetchStackFamily,
} from './index'
// §10.21: FIXTURE_BILLING / FIXTURE_INVOICES imports retired. The 503
// fallback paths in fetchBilling() and listInvoices() were removed —
Expand Down Expand Up @@ -573,3 +575,123 @@ describe('non-JSON response bodies', () => {
await expect(cancelSubscription()).rejects.toMatchObject({ status: 502 })
})
})

// ─── listStacks() — env field plumbed through ────────────────────────────
// Verifies the §10.17 follow-up: dashboard reads real `env` from the API
// response instead of hardcoding 'production'. Locks in the contract the
// agent API now serves (GET /api/v1/stacks includes env + parent_stack_id).
describe('listStacks() env field', () => {
it('returns the real env value from the API', async () => {
const m = installFetch()
m.mockResolvedValueOnce(jsonResponse({
ok: true,
total: 2,
items: [
{
stack_id: 'stk-prod', name: 'demo', status: 'running', tier: 'pro',
namespace: 'ns', env: 'production', parent_stack_id: '',
created_at: '2026-05-12T00:00:00Z',
},
{
stack_id: 'stk-staging', name: 'demo', status: 'running', tier: 'pro',
namespace: 'ns', env: 'staging', parent_stack_id: 'root-id',
created_at: '2026-05-12T00:01:00Z',
},
],
}))
const r = await listStacks()
expect(r.items[0].env).toBe('production')
expect(r.items[1].env).toBe('staging')
})

it("falls back to 'production' when the API omits env (legacy stack rows)", async () => {
const m = installFetch()
m.mockResolvedValueOnce(jsonResponse({
ok: true,
items: [{ stack_id: 'stk-old', name: 'legacy', status: 'running', tier: 'pro', namespace: 'ns', created_at: 'x' }],
}))
const r = await listStacks()
expect(r.items[0].env).toBe('production')
})
})

// ─── fetchStackFamily() — Pro+ env grid loader ───────────────────────────
// The discriminated-union return shape is load-bearing for the dashboard's
// Environments grid: it decides between rendering the grid (ok=true),
// the existing PromoteUpsell card (upgrade_required), or the silent fall-
// through (not_found / unknown). Cover each branch explicitly so a future
// shape change can't silently regress the UI behaviour.
describe('fetchStackFamily()', () => {
it('adapts the family payload and preserves order', async () => {
const m = installFetch()
m.mockResolvedValueOnce(jsonResponse({
ok: true,
slug: 'stk-prod',
total: 3,
family: [
{
slug: 'stk-prod', name: 'demo', env: 'production', status: 'running', tier: 'pro',
url: 'https://demo.deployment.instanode.dev', is_root: true, parent_stack_id: '',
last_deploy_at: '2026-05-12T01:00:00Z', created_at: '2026-05-12T00:00:00Z',
},
{
slug: 'stk-staging', name: 'demo', env: 'staging', status: 'building', tier: 'pro',
url: '', is_root: false, parent_stack_id: 'root-id',
last_deploy_at: '2026-05-12T02:00:00Z', created_at: '2026-05-12T00:02:00Z',
},
{
slug: 'stk-dev', name: 'demo', env: 'dev', status: 'running', tier: 'pro',
url: 'https://dev-demo.deployment.instanode.dev', is_root: false, parent_stack_id: 'root-id',
last_deploy_at: '2026-05-12T03:00:00Z', created_at: '2026-05-12T00:03:00Z',
},
],
}))
const r = await fetchStackFamily('stk-prod')
expect(r.ok).toBe(true)
if (!r.ok) throw new Error('typeguard')
expect(r.slug).toBe('stk-prod')
expect(r.total).toBe(3)
expect(r.family.map((m) => m.env)).toEqual(['production', 'staging', 'dev'])
expect(r.family[0].is_root).toBe(true)
expect(r.family[1].is_root).toBe(false)
expect(r.family[1].parent_stack_id).toBe('root-id')
})

it('returns upgrade_required on 402 so the UI can render PromoteUpsell', async () => {
const m = installFetch()
m.mockResolvedValueOnce(jsonResponse(
{ ok: false, error: 'upgrade_required', agent_action: 'Tell user to upgrade...' },
{ status: 402 },
))
const r = await fetchStackFamily('stk-hobby')
expect(r.ok).toBe(false)
if (r.ok) throw new Error('typeguard')
expect(r.reason).toBe('upgrade_required')
})

it('returns not_found on 404 so the UI silently falls back', async () => {
const m = installFetch()
m.mockResolvedValueOnce(jsonResponse({ ok: false, error: 'not_found' }, { status: 404 }))
const r = await fetchStackFamily('stk-missing')
expect(r.ok).toBe(false)
if (r.ok) throw new Error('typeguard')
expect(r.reason).toBe('not_found')
})

it("buckets every other failure under reason='unknown'", async () => {
const m = installFetch()
m.mockResolvedValueOnce(jsonResponse({ ok: false, error: 'internal' }, { status: 500 }))
const r = await fetchStackFamily('stk-x')
expect(r.ok).toBe(false)
if (r.ok) throw new Error('typeguard')
expect(r.reason).toBe('unknown')
})

it('URI-encodes the slug so weird inputs do not bypass the route', async () => {
const m = installFetch()
m.mockResolvedValueOnce(jsonResponse({ ok: true, slug: '', family: [], total: 0 }))
await fetchStackFamily('stk weird/slug')
const [url] = m.mock.calls[0]
expect(String(url)).toContain('/api/v1/stacks/stk%20weird%2Fslug/family')
})
})
103 changes: 97 additions & 6 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,12 @@ export async function rotateResource(id: string): Promise<{ ok: true; connection
}

// ─── Stacks / deployments ───
// GET /api/v1/stacks exists on the agent API but returns a thinner shape than
// the dashboard's DashboardStack (no env, no url, no last_deploy_at, no
// build_duration_s yet). We adapt what's available and leave optional fields
// undefined — the UI handles missing fields gracefully. Until POST /deploy/new
// ships in Phase 1, expect this to return an empty list for most teams.
// GET /api/v1/stacks returns one row per stack including the real env
// (production / staging / dev / ...) and parent_stack_id linkage. We adapt
// the shape into DashboardStack and leave still-missing fields (url,
// last_deploy_at, build_duration_s) undefined — the UI handles missing
// fields gracefully. Until POST /deploy/new ships in Phase 1, expect this
// to return an empty list for most teams.
type StacksListResp = {
ok: boolean
items?: Array<{
Expand All @@ -333,6 +334,8 @@ type StacksListResp = {
status?: string
tier?: string
namespace?: string
env?: string
parent_stack_id?: string
created_at?: string
}>
total?: number
Expand All @@ -349,7 +352,11 @@ export async function listStacks(): Promise<{ ok: true; items: DashboardStack[];
url: null,
created_at: s.created_at ?? '',
team_id: '',
env: 'production',
// env defaults to 'production' for legacy stacks pre-dating migration
// 015. The API never returns null for env (the column has NOT NULL
// DEFAULT 'production'), so the ?? branch is only exercised when the
// backend predates env-aware deployments entirely.
env: (s.env as DashboardStack['env']) ?? 'production',
tier: (s.tier as DashboardStack['tier']) ?? 'free',
}))
return { ok: true, items, total: r.total ?? items.length }
Expand All @@ -360,6 +367,90 @@ export async function listStacks(): Promise<{ ok: true; items: DashboardStack[];
}
}

// ─── Stack family — env-sibling grid ─────────────────────────────────────
// GET /api/v1/stacks/:slug/family returns root + every direct child as a
// flat list (root first) so the dashboard can render "production · staging
// · dev" cards side-by-side without doing N round-trips. Pro+ only — the
// agent API returns 402 with agent_action for hobby/free teams; we surface
// that with a tagged failure so the UI shows the existing PromoteUpsell
// instead of trying to render an empty grid.

export type StackFamilyMember = {
slug: string
name: string
env: string
status: DashboardStack['status']
tier: DashboardStack['tier']
url: string
is_root: boolean
parent_stack_id: string
last_deploy_at: string
created_at: string
}

type StackFamilyResp = {
ok: boolean
slug?: string
family?: Array<{
slug?: string
name?: string
env?: string
status?: string
tier?: string
url?: string
is_root?: boolean
parent_stack_id?: string
last_deploy_at?: string
created_at?: string
}>
total?: number
}

/**
* Fetch the env-sibling family for a stack. Returns:
* { ok: true, family, slug } — Pro+ team, family fetched
* { ok: false, reason: 'upgrade_required' } — hobby/free, 402 from API
* { ok: false, reason: 'not_found' } — slug missing or another team's
* { ok: false, reason: 'unknown' } — transient failure
*
* The discriminated-union return shape lets the calling UI choose between
* rendering the env grid, the PromoteUpsell card, or an error state without
* leaking APIError into the page component.
*/
export async function fetchStackFamily(
slug: string,
): Promise<
| { ok: true; slug: string; family: StackFamilyMember[]; total: number }
| { ok: false; reason: 'upgrade_required' | 'not_found' | 'unknown' }
> {
try {
const r = await call<StackFamilyResp>(`/api/v1/stacks/${encodeURIComponent(slug)}/family`)
const family: StackFamilyMember[] = (r.family ?? []).map((m) => ({
slug: m.slug ?? '',
name: m.name ?? '',
env: m.env ?? 'production',
status: (m.status as DashboardStack['status']) ?? 'building',
tier: (m.tier as DashboardStack['tier']) ?? 'free',
url: m.url ?? '',
is_root: m.is_root ?? false,
parent_stack_id: m.parent_stack_id ?? '',
last_deploy_at: m.last_deploy_at ?? '',
created_at: m.created_at ?? '',
}))
return { ok: true, slug: r.slug ?? slug, family, total: r.total ?? family.length }
} catch (err) {
// APIError exposes status; treat 402 as the explicit upgrade signal and
// 404 as not-yet-promoted (the slug exists but the team can't see it),
// and lump everything else into 'unknown' so the UI keeps showing the
// single-env fallback. Inspect status defensively because non-APIError
// throwables (network failures, jsdom) reach here too.
const status = (err as { status?: number })?.status
if (status === 402) return { ok: false as const, reason: 'upgrade_required' }
if (status === 404) return { ok: false as const, reason: 'not_found' }
return { ok: false as const, reason: 'unknown' }
}
}

// §10.21: no live GET /api/v1/stacks/:slug yet. Derive the detail from
// listStacks() so the dashboard stops fabricating stack metadata. Returns
// `stack: null` honestly when the slug isn't found instead of silently
Expand Down
Loading
Loading