Skip to content

Commit de166c6

Browse files
Merge branch 'main' into fix/docs-per-section-md-mirrors
2 parents 2f720c3 + 8bfa90b commit de166c6

2 files changed

Lines changed: 284 additions & 104 deletions

File tree

src/api/index.test.ts

Lines changed: 172 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -641,35 +641,36 @@ describe('changePlan()', () => {
641641
})
642642
})
643643

644-
// ─── validatePromotion() (P3) ────────────────────────────────────────────
645-
// Until api ships POST /api/v1/billing/promotion/validate, this helper
646-
// falls back to a small set of seed codes on a 404. The mock + fallback
647-
// path together must:
648-
// - return a Promotion shape when the api responds 200
649-
// - return the seed Promotion for a known seed code when the api 404s
650-
// - throw promotion_not_found for an unknown code when the api 404s
651-
// - propagate non-404 errors (e.g. 410 expired) untouched
652-
describe('validatePromotion() (P3)', () => {
653-
it('returns the api Promotion on a 200 response', async () => {
644+
// ─── validatePromotion() (P1-3 — LIVE endpoint, 200 ok:false contract) ─────
645+
// POST /api/v1/billing/promotion/validate is LIVE. Its quirk: VALIDATION
646+
// FAILURES come back as 200 with ok:false (NOT a 4xx). The helper must:
647+
// - return a Promotion when the api responds 200 ok:true + discount
648+
// - REJECT a 200 ok:false body (invalid / expired / wrong-plan) — the bug
649+
// this fixes: those used to surface as a phantom "valid" promo
650+
// - propagate true non-2xx (400/401/429/5xx) untouched
651+
// - reject empty input before any network call
652+
describe('validatePromotion() (P1-3)', () => {
653+
it('returns the api Promotion on a 200 ok:true response', async () => {
654654
const m = installFetch()
655655
m.mockResolvedValueOnce(jsonResponse({
656656
ok: true,
657657
code: 'PARTNER25',
658-
discount: { kind: 'percent_off', value: 25, applies_to: 6, unit: 'months' },
659-
valid_until: '2026-12-31T00:00:00Z',
658+
discount: { kind: 'percent_off', value: 25, applies_to: ['pro'], max_uses: 100 },
659+
valid_until: '2026-12-31T23:59:59Z',
660660
}))
661661
const r = await validatePromotion('PARTNER25', 'pro')
662662
expect(r.promotion.code).toBe('PARTNER25')
663-
expect(r.promotion.discount).toEqual({ kind: 'percent_off', value: 25, applies_to: 6, unit: 'months' })
664-
expect(r.promotion.valid_until).toBe('2026-12-31T00:00:00Z')
663+
expect(r.promotion.discount.kind).toBe('percent_off')
664+
expect(r.promotion.discount.value).toBe(25)
665+
expect(r.promotion.valid_until).toBe('2026-12-31T23:59:59Z')
665666
})
666667

667668
it('POSTs {code, plan} to /api/v1/billing/promotion/validate (uppercased + trimmed)', async () => {
668669
const m = installFetch()
669670
m.mockResolvedValueOnce(jsonResponse({
670671
ok: true,
671672
code: 'TWITTER15',
672-
discount: { kind: 'percent_off', value: 15, applies_to: 3, unit: 'months' },
673+
discount: { kind: 'percent_off', value: 15, applies_to: ['pro'] },
673674
valid_until: '2026-09-01T00:00:00Z',
674675
}))
675676
await validatePromotion(' twitter15 ', 'pro')
@@ -679,39 +680,77 @@ describe('validatePromotion() (P3)', () => {
679680
expect(JSON.parse(init.body as string)).toEqual({ code: 'TWITTER15', plan: 'pro' })
680681
})
681682

682-
it('falls back to the seed table when the api 404s on a known seed code', async () => {
683+
// REGRESSION GUARD (P1-3): a 200 ok:false body is a REJECTION. Before the
684+
// fix this resolved to a "valid" promo with an undefined discount. It must
685+
// now throw, carrying the api's error code + message.
686+
it('rejects a 200 ok:false body (invalid code) instead of reporting valid', async () => {
683687
const m = installFetch()
684-
m.mockResolvedValueOnce(jsonResponse(
685-
{ error: 'not_found', message: 'no such route' },
686-
{ status: 404, statusText: 'Not Found' },
687-
))
688-
const r = await validatePromotion('TWITTER15', 'pro')
689-
expect(r.promotion.code).toBe('TWITTER15')
690-
expect(r.promotion.discount.kind).toBe('percent_off')
691-
expect(r.promotion.discount.value).toBe(15)
688+
m.mockResolvedValueOnce(jsonResponse({
689+
ok: false,
690+
error: 'promotion_not_found',
691+
message: 'Code not found.',
692+
agent_action: 'Tell the user this promo code is invalid.',
693+
}))
694+
await expect(validatePromotion('NONEXISTENT', 'pro')).rejects.toMatchObject({
695+
code: 'promotion_not_found',
696+
message: 'Code not found.',
697+
})
698+
// It DID hit the live endpoint (no dead seed fallback short-circuit).
699+
expect(m).toHaveBeenCalledTimes(1)
692700
})
693701

694-
it('throws promotion_not_found on 404 for an unknown code', async () => {
702+
it('rejects a 200 ok:false body for a wrong-plan / expired code', async () => {
695703
const m = installFetch()
696-
m.mockResolvedValueOnce(jsonResponse(
697-
{ error: 'not_found' },
698-
{ status: 404, statusText: 'Not Found' },
699-
))
700-
await expect(validatePromotion('NONEXISTENT', 'pro')).rejects.toMatchObject({
701-
status: 404,
702-
code: 'promotion_not_found',
704+
m.mockResolvedValueOnce(jsonResponse({
705+
ok: false,
706+
error: 'promotion_invalid',
707+
message: "Promotion code \"OLDCODE\" is not valid for the pro plan.",
708+
}))
709+
await expect(validatePromotion('OLDCODE', 'pro')).rejects.toMatchObject({
710+
code: 'promotion_invalid',
711+
})
712+
})
713+
714+
// Defensive: a malformed 200 ok:true WITHOUT a discount is still a rejection
715+
// (we never surface a phantom valid promo with an empty discount).
716+
it('rejects a 200 ok:true body that is missing the discount field', async () => {
717+
const m = installFetch()
718+
m.mockResolvedValueOnce(jsonResponse({ ok: true, code: 'WEIRD' }))
719+
await expect(validatePromotion('WEIRD', 'pro')).rejects.toMatchObject({
720+
code: 'promotion_invalid',
703721
})
704722
})
705723

706-
it('propagates 410 expired errors with the api message', async () => {
724+
it('propagates a true 4xx (e.g. 429 rate-limited) untouched', async () => {
707725
const m = installFetch()
708726
m.mockResolvedValueOnce(jsonResponse(
709-
{ error: 'promotion_expired', message: 'This code has expired.' },
710-
{ status: 410, statusText: 'Gone' },
727+
{ error: 'rate_limit_exceeded', message: 'Too many validations.' },
728+
{ status: 429, statusText: 'Too Many Requests' },
711729
))
712-
await expect(validatePromotion('OLDCODE', 'pro')).rejects.toMatchObject({
713-
status: 410,
714-
code: 'promotion_expired',
730+
await expect(validatePromotion('ANYCODE', 'pro')).rejects.toMatchObject({
731+
status: 429,
732+
code: 'rate_limit_exceeded',
733+
})
734+
})
735+
736+
it('falls back to the normalised input code when the api omits code on success', async () => {
737+
const m = installFetch()
738+
m.mockResolvedValueOnce(jsonResponse({
739+
ok: true,
740+
discount: { kind: 'percent_off', value: 10, applies_to: ['pro'] },
741+
// no `code`, no `valid_until` (never-expiring code)
742+
}))
743+
const r = await validatePromotion('partner10', 'pro')
744+
expect(r.promotion.code).toBe('PARTNER10')
745+
expect(r.promotion.valid_until).toBeUndefined()
746+
})
747+
748+
it('uses default reason copy when a 200 ok:false body omits error/message', async () => {
749+
const m = installFetch()
750+
m.mockResolvedValueOnce(jsonResponse({ ok: false }))
751+
await expect(validatePromotion('BARE', 'pro')).rejects.toMatchObject({
752+
code: 'promotion_invalid',
753+
message: 'This promotion code is not valid for the selected plan.',
715754
})
716755
})
717756

@@ -1065,6 +1104,23 @@ describe('listStacks() env field', () => {
10651104
const r = await listStacks()
10661105
expect(r.items[0].env).toBe('production')
10671106
})
1107+
1108+
// REGRESSION GUARD (P1-2): list rows from the api carry 'healthy' for live
1109+
// stacks. listStacks() must normalise it to 'running' so list/detail status
1110+
// comparisons (and the StatusPill) agree with the StackStatus type.
1111+
it("normalises 'healthy' list rows to 'running'", async () => {
1112+
const m = installFetch()
1113+
m.mockResolvedValueOnce(jsonResponse({
1114+
ok: true,
1115+
items: [
1116+
{ stack_id: 'stk-live', name: 'demo', status: 'healthy', tier: 'pro', env: 'production', created_at: 'x' },
1117+
{ stack_id: 'stk-deploying', name: 'demo2', status: 'deploying', tier: 'pro', env: 'production', created_at: 'y' },
1118+
],
1119+
}))
1120+
const r = await listStacks()
1121+
expect(r.items[0].status).toBe('running')
1122+
expect(r.items[1].status).toBe('running')
1123+
})
10681124
})
10691125

10701126
// ─── listDeployments() — GET /api/v1/deployments adapter ─────────────────
@@ -1796,6 +1852,19 @@ describe('createStack()', () => {
17961852
expect(r.stack.status).toBe('building')
17971853
expect(r.stack.url).toBeNull()
17981854
})
1855+
1856+
// P1-2: a rare synchronous cached-build returns 'healthy'. createStack() must
1857+
// normalise it to 'running' so StackCreatePage.onSubmit skips to the live
1858+
// stage instead of polling.
1859+
it("normalises a synchronous 'healthy' response to 'running'", async () => {
1860+
const m = installFetch()
1861+
m.mockResolvedValueOnce(jsonResponse({
1862+
ok: true, slug: 'cached-1', status: 'healthy',
1863+
url: 'https://cached-1.deployment.instanode.dev',
1864+
}))
1865+
const r = await createStack(fakeFile('a.tar.gz', 100), {})
1866+
expect(r.stack.status).toBe('running')
1867+
})
17991868
})
18001869

18011870
// ─── fetchStackStatus() — GET /api/v1/stacks/:slug polling helper (D09/C06) ──
@@ -1853,6 +1922,71 @@ describe('fetchStackStatus()', () => {
18531922
m.mockResolvedValueOnce(jsonResponse({ error: 'internal' }, { status: 500 }))
18541923
await expect(fetchStackStatus('s1')).rejects.toMatchObject({ status: 500 })
18551924
})
1925+
1926+
// REGRESSION GUARD (P1-2): the api emits 'healthy' for a LIVE stack, but the
1927+
// dashboard's StackStatus / StackCreatePage poll only treat 'running' as
1928+
// live. Without normalisation a successful deploy spins to the 5-min poll
1929+
// timeout. fetchStackStatus() must map 'healthy' → 'running' so the
1930+
// create-page poll's `status === 'running'` live-test trips.
1931+
it("normalises a live stack's 'healthy' status to 'running'", async () => {
1932+
const m = installFetch()
1933+
m.mockResolvedValueOnce(jsonResponse({
1934+
ok: true,
1935+
stack_id: 'live-stack',
1936+
name: 'shipped',
1937+
status: 'healthy', // ← raw api live state
1938+
tier: 'pro',
1939+
services: [{ name: 'app', url: 'https://live-stack.deployment.instanode.dev', status: 'healthy' }],
1940+
}))
1941+
const r = await fetchStackStatus('live-stack')
1942+
expect(r.stack?.status).toBe('running')
1943+
expect(r.stack?.url).toBe('https://live-stack.deployment.instanode.dev')
1944+
})
1945+
1946+
it("normalises 'deploying' (rolling out) to 'running'", async () => {
1947+
const m = installFetch()
1948+
m.mockResolvedValueOnce(jsonResponse({
1949+
ok: true, stack_id: 'rolling', status: 'deploying', tier: 'pro',
1950+
}))
1951+
const r = await fetchStackStatus('rolling')
1952+
expect(r.stack?.status).toBe('running')
1953+
})
1954+
1955+
it("passes 'failed' through unchanged so the poll can stop", async () => {
1956+
const m = installFetch()
1957+
m.mockResolvedValueOnce(jsonResponse({
1958+
ok: true, stack_id: 'broke', status: 'failed', tier: 'pro',
1959+
}))
1960+
const r = await fetchStackStatus('broke')
1961+
expect(r.stack?.status).toBe('failed')
1962+
})
1963+
1964+
it("maps an unknown/empty status to 'building' (spinner, not crash)", async () => {
1965+
const m = installFetch()
1966+
m.mockResolvedValueOnce(jsonResponse({
1967+
ok: true, stack_id: 'mystery', status: 'pending-something', tier: 'pro',
1968+
}))
1969+
const r = await fetchStackStatus('mystery')
1970+
expect(r.stack?.status).toBe('building')
1971+
})
1972+
1973+
it("maps 'deleting' to 'stopped' (terminal-ish, no compute)", async () => {
1974+
const m = installFetch()
1975+
m.mockResolvedValueOnce(jsonResponse({
1976+
ok: true, stack_id: 'going-away', status: 'deleting', tier: 'pro',
1977+
}))
1978+
const r = await fetchStackStatus('going-away')
1979+
expect(r.stack?.status).toBe('stopped')
1980+
})
1981+
1982+
it("passes 'building' through unchanged (still in flight)", async () => {
1983+
const m = installFetch()
1984+
m.mockResolvedValueOnce(jsonResponse({
1985+
ok: true, stack_id: 'wip', status: 'building', tier: 'pro',
1986+
}))
1987+
const r = await fetchStackStatus('wip')
1988+
expect(r.stack?.status).toBe('building')
1989+
})
18561990
})
18571991

18581992
// ─── APIError tier-wall envelope (P2-W2-16) ──────────────────────────────

0 commit comments

Comments
 (0)