|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `client.environments.delete` under the hosted control plane's two-step |
| 5 | + * delete (cloud ADR-0014) — #17636. |
| 6 | + * |
| 7 | + * ## The producer, measured |
| 8 | + * |
| 9 | + * `DELETE /api/v1/cloud/environments/:id` is served by `objectstack-ai/cloud`, |
| 10 | + * `packages/service-cloud/src/routes/environment-lifecycle.ts`, read at cloud |
| 11 | + * `eeac7b22` (cloud#2188). The route reads two query flags, `force` and |
| 12 | + * `purge`, independently, and a 200 carries exactly one of two bodies: |
| 13 | + * |
| 14 | + * archive — a live environment whatever the flags, or an archived one |
| 15 | + * without `purge`: |
| 16 | + * `{ environmentId, deleted: false, archived: true, |
| 17 | + * purgeDeferred, retentionDays, warnings: [], message }` |
| 18 | + * teardown — an archived environment with `purge`, or a `failed` one: |
| 19 | + * `{ environmentId, deleted: true, purged: true, warnings }` |
| 20 | + * |
| 21 | + * Every other outcome is a non-2xx error envelope, which this client's |
| 22 | + * `fetch` wrapper throws — it never reaches the declared return type. |
| 23 | + * |
| 24 | + * ## What each pin asserts |
| 25 | + * |
| 26 | + * - The URL for every combination of the two options. `force` and `purge` are |
| 27 | + * independent confirmations and a production teardown needs both on ONE |
| 28 | + * call, so a builder that let one option shadow the other would leave that |
| 29 | + * teardown unreachable from the SDK. |
| 30 | + * - Both 200 bodies relay untouched, key for key. |
| 31 | + * - A refusal rejects, carrying the envelope's `code` and the response status. |
| 32 | + * - The TYPE pins: the declared answer is the discriminated union of the two |
| 33 | + * bodies, no more and no less. They are compiled — `tsconfig.test.json` |
| 34 | + * includes `src/**` and the package's `typecheck` script names it — so each |
| 35 | + * `@ts-expect-error` is a real check. |
| 36 | + */ |
| 37 | + |
| 38 | +import { describe, it, expect, vi } from 'vitest'; |
| 39 | +import { ObjectStackClient } from './index'; |
| 40 | + |
| 41 | +type EnvironmentsNamespace = ObjectStackClient['environments']; |
| 42 | +type DeleteAnswer = Awaited<ReturnType<EnvironmentsNamespace['delete']>>; |
| 43 | +type DeleteOptions = NonNullable<Parameters<EnvironmentsNamespace['delete']>[1]>; |
| 44 | + |
| 45 | +/** |
| 46 | + * The archive answer. `deleted: false` selects it, and each key is declared at |
| 47 | + * its wire type: assigning into typed locals is the pin, so a key weakened to |
| 48 | + * optional or retyped goes red here rather than in a caller. |
| 49 | + */ |
| 50 | +export function archiveAnswerDeclaresItsKeys(answer: DeleteAnswer): void { |
| 51 | + if (answer.deleted) return; |
| 52 | + const environmentId: string = answer.environmentId; |
| 53 | + const archived: true = answer.archived; |
| 54 | + const purgeDeferred: boolean = answer.purgeDeferred; |
| 55 | + const retentionDays: number = answer.retentionDays; |
| 56 | + const warnings: string[] = answer.warnings; |
| 57 | + const message: string = answer.message; |
| 58 | + void [environmentId, archived, purgeDeferred, retentionDays, warnings, message]; |
| 59 | + // @ts-expect-error an archive tears nothing down; the archive answer carries no `purged` |
| 60 | + void answer.purged; |
| 61 | +} |
| 62 | + |
| 63 | +/** The teardown answer. `deleted: true` selects it, and it carries none of the archive-only keys. */ |
| 64 | +export function teardownAnswerDeclaresItsKeys(answer: DeleteAnswer): void { |
| 65 | + if (!answer.deleted) return; |
| 66 | + const environmentId: string = answer.environmentId; |
| 67 | + const purged: true = answer.purged; |
| 68 | + const warnings: string[] = answer.warnings; |
| 69 | + void [environmentId, purged, warnings]; |
| 70 | + // @ts-expect-error the teardown answer carries no `archived` |
| 71 | + void answer.archived; |
| 72 | + // @ts-expect-error the teardown answer carries no `purgeDeferred` |
| 73 | + void answer.purgeDeferred; |
| 74 | + // @ts-expect-error the teardown answer carries no `retentionDays` |
| 75 | + void answer.retentionDays; |
| 76 | + // @ts-expect-error the teardown answer carries no `message` |
| 77 | + void answer.message; |
| 78 | +} |
| 79 | + |
| 80 | +/** The three keys the previous declaration named sit on BOTH members, so a read written against it still compiles. */ |
| 81 | +export function previouslyDeclaredKeysStayReadable(answer: DeleteAnswer): void { |
| 82 | + const deleted: boolean = answer.deleted; |
| 83 | + const environmentId: string = answer.environmentId; |
| 84 | + const warnings: string[] = answer.warnings; |
| 85 | + void [deleted, environmentId, warnings]; |
| 86 | +} |
| 87 | + |
| 88 | +/** `force` and `purge` are both declared options and may be passed together. */ |
| 89 | +export function bothOptionsAreDeclared(): void { |
| 90 | + const productionTeardown: DeleteOptions = { force: true, purge: true }; |
| 91 | + void productionTeardown; |
| 92 | +} |
| 93 | + |
| 94 | +/** A client whose `fetch` answers one canned response. */ |
| 95 | +function clientAnswering(status: number, body: unknown) { |
| 96 | + const fetchMock = vi.fn().mockResolvedValue({ |
| 97 | + ok: status >= 200 && status < 300, |
| 98 | + status, |
| 99 | + statusText: status === 200 ? 'OK' : 'Conflict', |
| 100 | + json: async () => body, |
| 101 | + headers: new Headers(), |
| 102 | + }); |
| 103 | + const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock }); |
| 104 | + return { client, fetchMock }; |
| 105 | +} |
| 106 | + |
| 107 | +const ARCHIVED = { |
| 108 | + environmentId: 'env_1', |
| 109 | + deleted: false, |
| 110 | + archived: true, |
| 111 | + purgeDeferred: false, |
| 112 | + retentionDays: 30, |
| 113 | + warnings: [], |
| 114 | + message: 'Environment archived (soft-deleted).', |
| 115 | +}; |
| 116 | + |
| 117 | +const TORN_DOWN = { |
| 118 | + environmentId: 'env_1', |
| 119 | + deleted: true, |
| 120 | + purged: true, |
| 121 | + warnings: ['attachment storage sweep failed: timeout'], |
| 122 | +}; |
| 123 | + |
| 124 | +describe('client.environments.delete — the two-step delete (cloud ADR-0014)', () => { |
| 125 | + const CASES: Array<{ opts: DeleteOptions | undefined; query: string }> = [ |
| 126 | + { opts: undefined, query: '' }, |
| 127 | + { opts: {}, query: '' }, |
| 128 | + { opts: { force: false, purge: false }, query: '' }, |
| 129 | + { opts: { force: true }, query: '?force=1' }, |
| 130 | + { opts: { force: true, purge: false }, query: '?force=1' }, |
| 131 | + { opts: { purge: true }, query: '?purge=1' }, |
| 132 | + { opts: { force: false, purge: true }, query: '?purge=1' }, |
| 133 | + { opts: { force: true, purge: true }, query: '?force=1&purge=1' }, |
| 134 | + ]; |
| 135 | + |
| 136 | + for (const { opts, query } of CASES) { |
| 137 | + const label = opts === undefined ? 'no options' : JSON.stringify(opts); |
| 138 | + it(`${label} → DELETE …/environments/env_1${query}`, async () => { |
| 139 | + const { client, fetchMock } = clientAnswering(200, { success: true, data: ARCHIVED }); |
| 140 | + |
| 141 | + await client.environments.delete('env_1', opts); |
| 142 | + |
| 143 | + expect(fetchMock).toHaveBeenCalledTimes(1); |
| 144 | + expect(fetchMock.mock.calls[0][0]).toBe(`http://localhost:3000/api/v1/cloud/environments/env_1${query}`); |
| 145 | + expect(fetchMock.mock.calls[0][1].method).toBe('DELETE'); |
| 146 | + }); |
| 147 | + } |
| 148 | + |
| 149 | + it('encodes the id and keeps both flags after it', async () => { |
| 150 | + const { client, fetchMock } = clientAnswering(200, { success: true, data: TORN_DOWN }); |
| 151 | + |
| 152 | + await client.environments.delete('env/1 x', { force: true, purge: true }); |
| 153 | + |
| 154 | + expect(fetchMock.mock.calls[0][0]).toBe( |
| 155 | + 'http://localhost:3000/api/v1/cloud/environments/env%2F1%20x?force=1&purge=1', |
| 156 | + ); |
| 157 | + }); |
| 158 | + |
| 159 | + it('relays the archive answer key for key, and `deleted: false` narrows to it', async () => { |
| 160 | + const { client } = clientAnswering(200, { success: true, data: ARCHIVED }); |
| 161 | + |
| 162 | + const answer = await client.environments.delete('env_1'); |
| 163 | + |
| 164 | + expect(Object.keys(answer).sort()).toEqual([ |
| 165 | + 'archived', 'deleted', 'environmentId', 'message', 'purgeDeferred', 'retentionDays', 'warnings', |
| 166 | + ]); |
| 167 | + if (answer.deleted) throw new Error('expected the archive answer'); |
| 168 | + expect(answer.archived).toBe(true); |
| 169 | + expect(answer.purgeDeferred).toBe(false); |
| 170 | + expect(answer.retentionDays).toBe(30); |
| 171 | + expect(answer.warnings).toEqual([]); |
| 172 | + }); |
| 173 | + |
| 174 | + it('relays a deferred purge: a LIVE environment asked to purge is archived with `purgeDeferred: true`', async () => { |
| 175 | + const { client } = clientAnswering(200, { success: true, data: { ...ARCHIVED, purgeDeferred: true } }); |
| 176 | + |
| 177 | + const answer = await client.environments.delete('env_1', { purge: true }); |
| 178 | + |
| 179 | + if (answer.deleted) throw new Error('expected the archive answer'); |
| 180 | + expect(answer.archived).toBe(true); |
| 181 | + expect(answer.purgeDeferred).toBe(true); |
| 182 | + }); |
| 183 | + |
| 184 | + it('relays the teardown answer key for key, and `deleted: true` narrows to it', async () => { |
| 185 | + const { client } = clientAnswering(200, { success: true, data: TORN_DOWN }); |
| 186 | + |
| 187 | + const answer = await client.environments.delete('env_1', { purge: true }); |
| 188 | + |
| 189 | + expect(Object.keys(answer).sort()).toEqual(['deleted', 'environmentId', 'purged', 'warnings']); |
| 190 | + if (!answer.deleted) throw new Error('expected the teardown answer'); |
| 191 | + expect(answer.purged).toBe(true); |
| 192 | + expect(answer.warnings).toEqual(['attachment storage sweep failed: timeout']); |
| 193 | + }); |
| 194 | + |
| 195 | + it('rejects a refusal instead of resolving it: a production environment deleted without `force`', async () => { |
| 196 | + const { client } = clientAnswering(409, { |
| 197 | + success: false, |
| 198 | + error: { |
| 199 | + code: 'RESOURCE_CONFLICT', |
| 200 | + message: 'This is the organization\'s production environment. Re-run with force to delete it.', |
| 201 | + httpStatus: 409, |
| 202 | + }, |
| 203 | + }); |
| 204 | + |
| 205 | + await expect(client.environments.delete('env_prod')).rejects.toMatchObject({ |
| 206 | + code: 'RESOURCE_CONFLICT', |
| 207 | + httpStatus: 409, |
| 208 | + }); |
| 209 | + }); |
| 210 | + |
| 211 | + it('anti-vacuity: the type pins above are real bindings in this module', () => { |
| 212 | + expect(typeof archiveAnswerDeclaresItsKeys).toBe('function'); |
| 213 | + expect(typeof teardownAnswerDeclaresItsKeys).toBe('function'); |
| 214 | + expect(typeof previouslyDeclaredKeysStayReadable).toBe('function'); |
| 215 | + expect(typeof bothOptionsAreDeclared).toBe('function'); |
| 216 | + }); |
| 217 | +}); |
0 commit comments