| 1 | import * as Nuke from "@/Nuke.ts"; |
| 2 | import type { ProviderService } from "@/Provider.ts"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import { expect, it } from "alchemy-test"; |
| 7 | |
| 8 | it.effect( |
| 9 | "reports each deletion before a slow coordinated pass finishes", |
| 10 | () => |
| 11 | Effect.gen(function* () { |
| 12 | const fastDeleted = yield* Deferred.make<void>(); |
| 13 | const events: string[] = []; |
| 14 | const provider: ProviderService = { |
| 15 | list: () => Effect.succeed([]), |
| 16 | reconcile: () => Effect.succeed({}), |
| 17 | delete: ({ id }) => |
| 18 | id === "slow" ? Deferred.await(fastDeleted) : Effect.void, |
| 19 | }; |
| 20 | const result = yield* Nuke.destroy({ |
| 21 | targets: ["fast", "slow"].map((displayName) => ({ |
| 22 | providerId: "Test.Resource", |
| 23 | displayName, |
| 24 | attributes: {}, |
| 25 | provider, |
| 26 | })), |
| 27 | context: Context.empty(), |
| 28 | strategy: { _tag: "coordinated" }, |
| 29 | onPass: (pass) => |
| 30 | Effect.sync(() => { |
| 31 | events.push(`pass ${pass}`); |
| 32 | }), |
| 33 | onDeleted: (resource) => |
| 34 | Effect.gen(function* () { |
| 35 | events.push(resource.displayName); |
| 36 | if (resource.displayName === "fast") |
| 37 | yield* Deferred.succeed(fastDeleted, undefined); |
| 38 | }), |
| 39 | }).pipe(Effect.timeout("2 seconds")); |
| 40 | expect(result.deleted).toHaveLength(2); |
| 41 | expect(events).toEqual(["pass 1", "fast", "slow"]); |
| 42 | }), |
| 43 | { tags: ["unit", "local"] }, |
| 44 | ); |
| 45 | |
| 46 | it.effect( |
| 47 | "reports scan totals and settles failed providers", |
| 48 | () => |
| 49 | Effect.gen(function* () { |
| 50 | const events: string[] = []; |
| 51 | const provider: ProviderService = { |
| 52 | list: () => Effect.fail(new Error("unavailable")), |
| 53 | reconcile: () => Effect.succeed({}), |
| 54 | delete: () => Effect.void, |
| 55 | }; |
| 56 | const tag = Context.Service<ProviderService>("Test.Resource"); |
| 57 | const result = yield* Nuke.list({ |
| 58 | context: Context.make(tag, provider), |
| 59 | mode: "live", |
| 60 | onScan: (total) => |
| 61 | Effect.sync(() => { |
| 62 | events.push(`total ${total}`); |
| 63 | }), |
| 64 | onProviderStarted: (id) => |
| 65 | Effect.sync(() => { |
| 66 | events.push(`start ${id}`); |
| 67 | }), |
| 68 | onProvider: (id, count) => |
| 69 | Effect.sync(() => { |
| 70 | events.push(`done ${id} ${count}`); |
| 71 | }), |
| 72 | }); |
| 73 | expect(result.failures).toHaveLength(1); |
| 74 | expect(events).toEqual([ |
| 75 | "total 1", |
| 76 | "start Test.Resource", |
| 77 | "done Test.Resource 0", |
| 78 | ]); |
| 79 | }), |
| 80 | { tags: ["unit", "local"] }, |
| 81 | ); |
| 82 | |
| 83 | it.effect( |
| 84 | "preserves structured provider errors in scan and deletion reports", |
| 85 | () => |
| 86 | Effect.gen(function* () { |
| 87 | const error = { |
| 88 | _tag: "AccessDeniedException", |
| 89 | message: "This service is unavailable for this account", |
| 90 | details: { code: 403 }, |
| 91 | }; |
| 92 | const provider: ProviderService = { |
| 93 | list: () => Effect.fail(error), |
| 94 | reconcile: () => Effect.succeed({}), |
| 95 | delete: () => Effect.fail(error), |
| 96 | }; |
| 97 | const context = Context.make( |
| 98 | Context.Service<ProviderService>("Test.Resource"), |
| 99 | provider, |
| 100 | ); |
| 101 | const scan = yield* Nuke.list({ context, mode: "live" }); |
| 102 | const messages = scan.failures.map((failure) => failure.message); |
| 103 | for (const strategy of [ |
| 104 | { _tag: "coordinated" }, |
| 105 | { _tag: "independent", retries: 0 }, |
| 106 | ] as const) { |
| 107 | const result = yield* Nuke.destroy({ |
| 108 | targets: [ |
| 109 | { |
| 110 | providerId: "Test.Resource", |
| 111 | displayName: "example", |
| 112 | attributes: {}, |
| 113 | provider, |
| 114 | }, |
| 115 | ], |
| 116 | context, |
| 117 | strategy, |
| 118 | onFailed: (_resource, message) => |
| 119 | Effect.sync(() => { |
| 120 | messages.push(message); |
| 121 | }), |
| 122 | }); |
| 123 | expect(result.failed).toHaveLength(1); |
| 124 | messages.push(result.failed[0]!.failure.message); |
| 125 | } |
| 126 | expect(messages).toHaveLength(5); |
| 127 | for (const message of messages) { |
| 128 | expect(message).toContain("AccessDeniedException"); |
| 129 | expect(message).toContain(error.message); |
| 130 | expect(message).toContain("403"); |
| 131 | expect(message).not.toContain("[object Object]"); |
| 132 | expect(message).not.toContain("no progress"); |
| 133 | } |
| 134 | }), |
| 135 | { tags: ["unit", "local"] }, |
| 136 | ); |
| 137 | |
| 138 | it.effect( |
| 139 | "reports scan failures before other providers finish listing", |
| 140 | () => |
| 141 | Effect.gen(function* () { |
| 142 | const reported = yield* Deferred.make<void>(); |
| 143 | const failed: ProviderService = { |
| 144 | list: () => |
| 145 | Effect.fail({ _tag: "AccessDenied", message: "denied during scan" }), |
| 146 | reconcile: () => Effect.succeed({}), |
| 147 | delete: () => Effect.void, |
| 148 | }; |
| 149 | const slow: ProviderService = { |
| 150 | ...failed, |
| 151 | list: () => Deferred.await(reported).pipe(Effect.as([])), |
| 152 | }; |
| 153 | const context = Context.make( |
| 154 | Context.Service<ProviderService>("Test.Failed"), |
| 155 | failed, |
| 156 | ).pipe(Context.add(Context.Service<ProviderService>("Test.Slow"), slow)); |
| 157 | const errors: string[] = []; |
| 158 | const result = yield* Nuke.list({ |
| 159 | context, |
| 160 | mode: "live", |
| 161 | onProvider: (provider, count, error) => |
| 162 | Effect.gen(function* () { |
| 163 | if (error === undefined) return; |
| 164 | expect(provider).toBe("Test.Failed"); |
| 165 | expect(count).toBe(0); |
| 166 | errors.push(error); |
| 167 | yield* Deferred.succeed(reported, undefined); |
| 168 | }), |
| 169 | }).pipe(Effect.timeout("2 seconds")); |
| 170 | expect(errors).toHaveLength(1); |
| 171 | expect(errors[0]).toContain("denied during scan"); |
| 172 | expect(result.failures[0]?.message).toBe(errors[0]); |
| 173 | }), |
| 174 | { tags: ["unit", "local"] }, |
| 175 | ); |