@mandujs/core 0.53.2 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +654 -654
- package/package.json +2 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -380
- package/src/diagnose/checks.ts +832 -720
- package/src/diagnose/index.ts +17 -16
- package/src/diagnose/run.ts +93 -91
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +24 -31
- package/src/runtime/streaming-ssr.ts +32 -37
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
|
@@ -1,479 +1,479 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @mandujs/core/storage/s3 — unit tests
|
|
3
|
-
*
|
|
4
|
-
* These tests never touch the network. They substitute a controllable
|
|
5
|
-
* in-memory fake for `Bun.S3Client` via the internal `_createS3ClientWith`
|
|
6
|
-
* entry point, then assert that our wrapper forwards calls correctly and
|
|
7
|
-
* applies the content-type / URL / error-mapping heuristics we promise.
|
|
8
|
-
*
|
|
9
|
-
* A separate integration suite (`packages/core/tests/storage/s3-minio.test.ts`)
|
|
10
|
-
* exercises the real Bun.s3 code path against MinIO.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { describe, it, expect } from "bun:test";
|
|
14
|
-
import {
|
|
15
|
-
_createS3ClientWith,
|
|
16
|
-
getContentType,
|
|
17
|
-
type BunS3ClientCtor,
|
|
18
|
-
type BunS3ClientInstance,
|
|
19
|
-
} from "../index";
|
|
20
|
-
|
|
21
|
-
// ─── Fake Bun.S3Client ──────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
/** Args captured on each fake method call — shape matches what we forward. */
|
|
24
|
-
interface CapturedWrite {
|
|
25
|
-
key: string;
|
|
26
|
-
body: Blob | ArrayBuffer | Uint8Array;
|
|
27
|
-
options: { type?: string; acl?: string } | undefined;
|
|
28
|
-
}
|
|
29
|
-
interface CapturedPresign {
|
|
30
|
-
key: string;
|
|
31
|
-
options: { method?: string; expiresIn?: number; type?: string } | undefined;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface FakeState {
|
|
35
|
-
ctorCalls: Array<{
|
|
36
|
-
bucket: string;
|
|
37
|
-
endpoint?: string;
|
|
38
|
-
region?: string;
|
|
39
|
-
virtualHostedStyle?: boolean;
|
|
40
|
-
}>;
|
|
41
|
-
writes: CapturedWrite[];
|
|
42
|
-
presigns: CapturedPresign[];
|
|
43
|
-
deletes: string[];
|
|
44
|
-
existsCalls: string[];
|
|
45
|
-
streamCalls: string[];
|
|
46
|
-
/** `true` = resolves true, `false` = resolves false, Error = throws. */
|
|
47
|
-
existsBehavior: Map<string, true | false | Error>;
|
|
48
|
-
deleteBehavior: Map<string, Error>;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function createFakeCtor(): { Ctor: BunS3ClientCtor; state: FakeState } {
|
|
52
|
-
const state: FakeState = {
|
|
53
|
-
ctorCalls: [],
|
|
54
|
-
writes: [],
|
|
55
|
-
presigns: [],
|
|
56
|
-
deletes: [],
|
|
57
|
-
existsCalls: [],
|
|
58
|
-
streamCalls: [],
|
|
59
|
-
existsBehavior: new Map(),
|
|
60
|
-
deleteBehavior: new Map(),
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
class FakeS3Client implements BunS3ClientInstance {
|
|
64
|
-
constructor(config: {
|
|
65
|
-
bucket: string;
|
|
66
|
-
endpoint?: string;
|
|
67
|
-
region?: string;
|
|
68
|
-
virtualHostedStyle?: boolean;
|
|
69
|
-
}) {
|
|
70
|
-
state.ctorCalls.push(config);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
file(key: string) {
|
|
74
|
-
return {
|
|
75
|
-
async write(
|
|
76
|
-
body: Blob | ArrayBuffer | Uint8Array,
|
|
77
|
-
options?: { type?: string; acl?: string },
|
|
78
|
-
) {
|
|
79
|
-
state.writes.push({ key, body, options });
|
|
80
|
-
// Bun's write returns the byte count; fake a plausible number.
|
|
81
|
-
return 0;
|
|
82
|
-
},
|
|
83
|
-
presign(options?: {
|
|
84
|
-
method?: string;
|
|
85
|
-
expiresIn?: number;
|
|
86
|
-
type?: string;
|
|
87
|
-
}): string {
|
|
88
|
-
state.presigns.push({ key, options });
|
|
89
|
-
const method = options?.method ?? "PUT";
|
|
90
|
-
const expires = options?.expiresIn ?? 900;
|
|
91
|
-
return `https://fake.s3.local/${key}?method=${method}&expires=${expires}`;
|
|
92
|
-
},
|
|
93
|
-
async delete() {
|
|
94
|
-
const override = state.deleteBehavior.get(key);
|
|
95
|
-
if (override) throw override;
|
|
96
|
-
state.deletes.push(key);
|
|
97
|
-
},
|
|
98
|
-
async exists() {
|
|
99
|
-
state.existsCalls.push(key);
|
|
100
|
-
const b = state.existsBehavior.get(key);
|
|
101
|
-
if (b instanceof Error) throw b;
|
|
102
|
-
if (b === undefined) return true;
|
|
103
|
-
return b;
|
|
104
|
-
},
|
|
105
|
-
stream(): ReadableStream {
|
|
106
|
-
state.streamCalls.push(key);
|
|
107
|
-
return new ReadableStream<Uint8Array>({
|
|
108
|
-
start(controller) {
|
|
109
|
-
controller.enqueue(new Uint8Array([1, 2, 3]));
|
|
110
|
-
controller.close();
|
|
111
|
-
},
|
|
112
|
-
});
|
|
113
|
-
},
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// The ctor signature we export is `new (config) => instance` — cast through
|
|
119
|
-
// unknown once to satisfy strict TS without widening the public type.
|
|
120
|
-
return {
|
|
121
|
-
Ctor: FakeS3Client as unknown as BunS3ClientCtor,
|
|
122
|
-
state,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
127
|
-
|
|
128
|
-
describe("@mandujs/core/storage/s3 — upload", () => {
|
|
129
|
-
it("calls bunClient.file(key).write(body) with the correct key and body", async () => {
|
|
130
|
-
const { Ctor, state } = createFakeCtor();
|
|
131
|
-
const client = _createS3ClientWith(Ctor, {
|
|
132
|
-
bucket: "my-bucket",
|
|
133
|
-
endpoint: "https://s3.example.com",
|
|
134
|
-
forcePathStyle: true,
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
const body = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
|
|
138
|
-
await client.upload(body, { key: "a/b.txt" });
|
|
139
|
-
|
|
140
|
-
expect(state.writes).toHaveLength(1);
|
|
141
|
-
expect(state.writes[0]!.key).toBe("a/b.txt");
|
|
142
|
-
expect(state.writes[0]!.body).toBe(body);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("infers Content-Type from key extension when not provided", async () => {
|
|
146
|
-
const { Ctor, state } = createFakeCtor();
|
|
147
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
148
|
-
|
|
149
|
-
await client.upload(new Uint8Array(), { key: "photos/hero.jpg" });
|
|
150
|
-
await client.upload(new Uint8Array(), { key: "data/report.json" });
|
|
151
|
-
|
|
152
|
-
expect(state.writes[0]!.options?.type).toBe("image/jpeg");
|
|
153
|
-
expect(state.writes[1]!.options?.type).toBe("application/json");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("passes through explicit contentType when provided", async () => {
|
|
157
|
-
const { Ctor, state } = createFakeCtor();
|
|
158
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
159
|
-
|
|
160
|
-
await client.upload(new Uint8Array(), {
|
|
161
|
-
key: "binary-with-jpg-extension.jpg",
|
|
162
|
-
contentType: "application/octet-stream",
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// Explicit contentType wins over extension-based inference.
|
|
166
|
-
expect(state.writes[0]!.options?.type).toBe("application/octet-stream");
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("passes through acl when provided and omits when not", async () => {
|
|
170
|
-
const { Ctor, state } = createFakeCtor();
|
|
171
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
172
|
-
|
|
173
|
-
await client.upload(new Uint8Array(), { key: "public.png", acl: "public-read" });
|
|
174
|
-
await client.upload(new Uint8Array(), { key: "private.png" });
|
|
175
|
-
|
|
176
|
-
expect(state.writes[0]!.options?.acl).toBe("public-read");
|
|
177
|
-
// When acl is omitted from our call, it must NOT appear in the forwarded options.
|
|
178
|
-
expect(state.writes[1]!.options?.acl).toBeUndefined();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("accepts Blob, ArrayBuffer, and Uint8Array bodies uniformly", async () => {
|
|
182
|
-
const { Ctor, state } = createFakeCtor();
|
|
183
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
184
|
-
|
|
185
|
-
const blob = new Blob(["hi"], { type: "text/plain" });
|
|
186
|
-
const ab = new ArrayBuffer(4);
|
|
187
|
-
const u8 = new Uint8Array([1, 2, 3]);
|
|
188
|
-
|
|
189
|
-
await client.upload(blob, { key: "a.txt" });
|
|
190
|
-
await client.upload(ab, { key: "b.bin" });
|
|
191
|
-
await client.upload(u8, { key: "c.bin" });
|
|
192
|
-
|
|
193
|
-
expect(state.writes[0]!.body).toBe(blob);
|
|
194
|
-
expect(state.writes[1]!.body).toBe(ab);
|
|
195
|
-
expect(state.writes[2]!.body).toBe(u8);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("throws when metadata is passed (not yet supported by Bun.S3Client)", async () => {
|
|
199
|
-
const { Ctor } = createFakeCtor();
|
|
200
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
201
|
-
|
|
202
|
-
await expect(
|
|
203
|
-
client.upload(new Uint8Array(), {
|
|
204
|
-
key: "x.txt",
|
|
205
|
-
metadata: { owner: "alice" },
|
|
206
|
-
}),
|
|
207
|
-
).rejects.toThrow(/metadata.*not yet supported/);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("returns a path-style URL when forcePathStyle=true", async () => {
|
|
211
|
-
const { Ctor } = createFakeCtor();
|
|
212
|
-
const client = _createS3ClientWith(Ctor, {
|
|
213
|
-
bucket: "uploads",
|
|
214
|
-
endpoint: "https://minio.local:9000",
|
|
215
|
-
forcePathStyle: true,
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
const url = await client.upload(new Uint8Array(), { key: "u/1.png" });
|
|
219
|
-
expect(url).toBe("https://minio.local:9000/uploads/u/1.png");
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("returns a virtual-hosted URL by default", async () => {
|
|
223
|
-
const { Ctor } = createFakeCtor();
|
|
224
|
-
const client = _createS3ClientWith(Ctor, {
|
|
225
|
-
bucket: "uploads",
|
|
226
|
-
endpoint: "https://s3.example.com",
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
const url = await client.upload(new Uint8Array(), { key: "u/1.png" });
|
|
230
|
-
expect(url).toBe("https://uploads.s3.example.com/u/1.png");
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("returns an s3:// URI when no endpoint is configured (AWS default)", async () => {
|
|
234
|
-
const { Ctor } = createFakeCtor();
|
|
235
|
-
const client = _createS3ClientWith(Ctor, { bucket: "my-bucket" });
|
|
236
|
-
|
|
237
|
-
const url = await client.upload(new Uint8Array(), { key: "a/b.txt" });
|
|
238
|
-
expect(url).toBe("s3://my-bucket/a/b.txt");
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it("URL-encodes path segments but preserves '/' separators", async () => {
|
|
242
|
-
const { Ctor } = createFakeCtor();
|
|
243
|
-
const client = _createS3ClientWith(Ctor, {
|
|
244
|
-
bucket: "b",
|
|
245
|
-
endpoint: "https://s3.example.com",
|
|
246
|
-
forcePathStyle: true,
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
const url = await client.upload(new Uint8Array(), {
|
|
250
|
-
key: "user uploads/hello world.txt",
|
|
251
|
-
});
|
|
252
|
-
expect(url).toBe("https://s3.example.com/b/user%20uploads/hello%20world.txt");
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it("throws a TypeError when key is missing", async () => {
|
|
256
|
-
const { Ctor } = createFakeCtor();
|
|
257
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
258
|
-
|
|
259
|
-
await expect(
|
|
260
|
-
client.upload(new Uint8Array(), { key: "" }),
|
|
261
|
-
).rejects.toThrow(TypeError);
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
describe("@mandujs/core/storage/s3 — presign", () => {
|
|
266
|
-
it("returns a string URL for method=PUT", async () => {
|
|
267
|
-
const { Ctor, state } = createFakeCtor();
|
|
268
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
269
|
-
|
|
270
|
-
const url = await client.presign({ key: "upload.png", method: "PUT" });
|
|
271
|
-
|
|
272
|
-
expect(typeof url).toBe("string");
|
|
273
|
-
expect(url.length).toBeGreaterThan(0);
|
|
274
|
-
expect(state.presigns[0]!.options?.method).toBe("PUT");
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it("honors expiresIn=60 for method=GET", async () => {
|
|
278
|
-
const { Ctor, state } = createFakeCtor();
|
|
279
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
280
|
-
|
|
281
|
-
await client.presign({ key: "download.txt", method: "GET", expiresIn: 60 });
|
|
282
|
-
|
|
283
|
-
expect(state.presigns[0]!.options?.method).toBe("GET");
|
|
284
|
-
expect(state.presigns[0]!.options?.expiresIn).toBe(60);
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it("defaults method to PUT and expiresIn to 900 seconds", async () => {
|
|
288
|
-
const { Ctor, state } = createFakeCtor();
|
|
289
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
290
|
-
|
|
291
|
-
await client.presign({ key: "thing" });
|
|
292
|
-
|
|
293
|
-
expect(state.presigns[0]!.options?.method).toBe("PUT");
|
|
294
|
-
expect(state.presigns[0]!.options?.expiresIn).toBe(900);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it("forwards contentType as Bun's `type` option", async () => {
|
|
298
|
-
const { Ctor, state } = createFakeCtor();
|
|
299
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
300
|
-
|
|
301
|
-
await client.presign({ key: "x.png", method: "PUT", contentType: "image/png" });
|
|
302
|
-
|
|
303
|
-
expect(state.presigns[0]!.options?.type).toBe("image/png");
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("throws a TypeError when key is missing", async () => {
|
|
307
|
-
const { Ctor } = createFakeCtor();
|
|
308
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
309
|
-
|
|
310
|
-
await expect(client.presign({ key: "" })).rejects.toThrow(TypeError);
|
|
311
|
-
});
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
describe("@mandujs/core/storage/s3 — delete", () => {
|
|
315
|
-
it("calls bunClient.file(key).delete() and resolves", async () => {
|
|
316
|
-
const { Ctor, state } = createFakeCtor();
|
|
317
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
318
|
-
|
|
319
|
-
await client.delete("to-remove.txt");
|
|
320
|
-
|
|
321
|
-
expect(state.deletes).toContain("to-remove.txt");
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it("resolves silently on NoSuchKey errors (idempotent delete)", async () => {
|
|
325
|
-
const { Ctor, state } = createFakeCtor();
|
|
326
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
327
|
-
|
|
328
|
-
const notFound = Object.assign(new Error("Not found"), {
|
|
329
|
-
name: "S3Error",
|
|
330
|
-
code: "NoSuchKey",
|
|
331
|
-
});
|
|
332
|
-
state.deleteBehavior.set("ghost.txt", notFound);
|
|
333
|
-
|
|
334
|
-
await expect(client.delete("ghost.txt")).resolves.toBeUndefined();
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
it("re-throws non-404 delete errors", async () => {
|
|
338
|
-
const { Ctor, state } = createFakeCtor();
|
|
339
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
340
|
-
|
|
341
|
-
const accessDenied = Object.assign(new Error("Access denied"), {
|
|
342
|
-
name: "S3Error",
|
|
343
|
-
code: "AccessDenied",
|
|
344
|
-
status: 403,
|
|
345
|
-
});
|
|
346
|
-
state.deleteBehavior.set("forbidden.txt", accessDenied);
|
|
347
|
-
|
|
348
|
-
await expect(client.delete("forbidden.txt")).rejects.toThrow(/Access denied/);
|
|
349
|
-
});
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
describe("@mandujs/core/storage/s3 — exists", () => {
|
|
353
|
-
it("returns true when the object exists", async () => {
|
|
354
|
-
const { Ctor, state } = createFakeCtor();
|
|
355
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
356
|
-
state.existsBehavior.set("here.txt", true);
|
|
357
|
-
|
|
358
|
-
expect(await client.exists("here.txt")).toBe(true);
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it("returns false when Bun.s3 reports the object as absent", async () => {
|
|
362
|
-
const { Ctor, state } = createFakeCtor();
|
|
363
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
364
|
-
state.existsBehavior.set("gone.txt", false);
|
|
365
|
-
|
|
366
|
-
expect(await client.exists("gone.txt")).toBe(false);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it("returns false when Bun.s3 throws a NoSuchKey-shaped error", async () => {
|
|
370
|
-
const { Ctor, state } = createFakeCtor();
|
|
371
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
372
|
-
const nf = Object.assign(new Error("no key"), {
|
|
373
|
-
name: "S3Error",
|
|
374
|
-
code: "NoSuchKey",
|
|
375
|
-
});
|
|
376
|
-
state.existsBehavior.set("missing.txt", nf);
|
|
377
|
-
|
|
378
|
-
expect(await client.exists("missing.txt")).toBe(false);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it("re-throws non-404 errors (e.g., AccessDenied/5xx)", async () => {
|
|
382
|
-
const { Ctor, state } = createFakeCtor();
|
|
383
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
384
|
-
const denied = Object.assign(new Error("403 denied"), {
|
|
385
|
-
name: "S3Error",
|
|
386
|
-
code: "AccessDenied",
|
|
387
|
-
status: 403,
|
|
388
|
-
});
|
|
389
|
-
state.existsBehavior.set("protected.txt", denied);
|
|
390
|
-
|
|
391
|
-
await expect(client.exists("protected.txt")).rejects.toThrow(/403 denied/);
|
|
392
|
-
});
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
describe("@mandujs/core/storage/s3 — getReadable", () => {
|
|
396
|
-
it("returns a ReadableStream", async () => {
|
|
397
|
-
const { Ctor, state } = createFakeCtor();
|
|
398
|
-
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
399
|
-
|
|
400
|
-
const s = await client.getReadable("file.bin");
|
|
401
|
-
|
|
402
|
-
expect(s).toBeInstanceOf(ReadableStream);
|
|
403
|
-
expect(state.streamCalls).toContain("file.bin");
|
|
404
|
-
});
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
describe("@mandujs/core/storage/s3 — createS3Client", () => {
|
|
408
|
-
it("forwards bucket, endpoint, and region to the underlying ctor", () => {
|
|
409
|
-
const { Ctor, state } = createFakeCtor();
|
|
410
|
-
_createS3ClientWith(Ctor, {
|
|
411
|
-
bucket: "prod-uploads",
|
|
412
|
-
endpoint: "https://abc.r2.cloudflarestorage.com",
|
|
413
|
-
region: "auto",
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
expect(state.ctorCalls).toHaveLength(1);
|
|
417
|
-
expect(state.ctorCalls[0]!.bucket).toBe("prod-uploads");
|
|
418
|
-
expect(state.ctorCalls[0]!.endpoint).toBe("https://abc.r2.cloudflarestorage.com");
|
|
419
|
-
expect(state.ctorCalls[0]!.region).toBe("auto");
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
it("maps forcePathStyle=true to virtualHostedStyle=false (Bun's flag name)", () => {
|
|
423
|
-
const { Ctor, state } = createFakeCtor();
|
|
424
|
-
_createS3ClientWith(Ctor, {
|
|
425
|
-
bucket: "b",
|
|
426
|
-
endpoint: "http://localhost:9000",
|
|
427
|
-
forcePathStyle: true,
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
expect(state.ctorCalls[0]!.virtualHostedStyle).toBe(false);
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
it("leaves virtualHostedStyle untouched when forcePathStyle is unset", () => {
|
|
434
|
-
const { Ctor, state } = createFakeCtor();
|
|
435
|
-
_createS3ClientWith(Ctor, { bucket: "b" });
|
|
436
|
-
|
|
437
|
-
expect(state.ctorCalls[0]!.virtualHostedStyle).toBeUndefined();
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
it("throws a TypeError when bucket is missing", () => {
|
|
441
|
-
const { Ctor } = createFakeCtor();
|
|
442
|
-
expect(() =>
|
|
443
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
444
|
-
_createS3ClientWith(Ctor, { bucket: "" } as S3ConfigLike),
|
|
445
|
-
).toThrow(TypeError);
|
|
446
|
-
});
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
// Helper alias for the "missing bucket" negative test — keeps us from having
|
|
450
|
-
// to stringify `any` in the test body.
|
|
451
|
-
type S3ConfigLike = Parameters<typeof _createS3ClientWith>[1];
|
|
452
|
-
|
|
453
|
-
describe("@mandujs/core/storage/s3 — getContentType", () => {
|
|
454
|
-
it.each([
|
|
455
|
-
["photo.jpg", "image/jpeg"],
|
|
456
|
-
["PHOTO.JPEG", "image/jpeg"], // case-insensitive
|
|
457
|
-
["a/b/c.png", "image/png"],
|
|
458
|
-
["logo.webp", "image/webp"],
|
|
459
|
-
["anim.gif", "image/gif"],
|
|
460
|
-
["doc.pdf", "application/pdf"],
|
|
461
|
-
["notes.txt", "text/plain"],
|
|
462
|
-
["data.json", "application/json"],
|
|
463
|
-
["report.csv", "text/csv"],
|
|
464
|
-
["archive.zip", "application/zip"],
|
|
465
|
-
["clip.mp4", "video/mp4"],
|
|
466
|
-
["clip.webm", "video/webm"],
|
|
467
|
-
])("maps %s to %s", (key, expected) => {
|
|
468
|
-
expect(getContentType(key)).toBe(expected);
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it.each([
|
|
472
|
-
["README", "application/octet-stream"], // no extension
|
|
473
|
-
["archive.", "application/octet-stream"], // trailing dot
|
|
474
|
-
["file.unknown", "application/octet-stream"], // unrecognized extension
|
|
475
|
-
[".hidden", "application/octet-stream"], // dotfile without real extension
|
|
476
|
-
])("falls back to octet-stream for %s", (key, expected) => {
|
|
477
|
-
expect(getContentType(key)).toBe(expected);
|
|
478
|
-
});
|
|
479
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/storage/s3 — unit tests
|
|
3
|
+
*
|
|
4
|
+
* These tests never touch the network. They substitute a controllable
|
|
5
|
+
* in-memory fake for `Bun.S3Client` via the internal `_createS3ClientWith`
|
|
6
|
+
* entry point, then assert that our wrapper forwards calls correctly and
|
|
7
|
+
* applies the content-type / URL / error-mapping heuristics we promise.
|
|
8
|
+
*
|
|
9
|
+
* A separate integration suite (`packages/core/tests/storage/s3-minio.test.ts`)
|
|
10
|
+
* exercises the real Bun.s3 code path against MinIO.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from "bun:test";
|
|
14
|
+
import {
|
|
15
|
+
_createS3ClientWith,
|
|
16
|
+
getContentType,
|
|
17
|
+
type BunS3ClientCtor,
|
|
18
|
+
type BunS3ClientInstance,
|
|
19
|
+
} from "../index";
|
|
20
|
+
|
|
21
|
+
// ─── Fake Bun.S3Client ──────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** Args captured on each fake method call — shape matches what we forward. */
|
|
24
|
+
interface CapturedWrite {
|
|
25
|
+
key: string;
|
|
26
|
+
body: Blob | ArrayBuffer | Uint8Array;
|
|
27
|
+
options: { type?: string; acl?: string } | undefined;
|
|
28
|
+
}
|
|
29
|
+
interface CapturedPresign {
|
|
30
|
+
key: string;
|
|
31
|
+
options: { method?: string; expiresIn?: number; type?: string } | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface FakeState {
|
|
35
|
+
ctorCalls: Array<{
|
|
36
|
+
bucket: string;
|
|
37
|
+
endpoint?: string;
|
|
38
|
+
region?: string;
|
|
39
|
+
virtualHostedStyle?: boolean;
|
|
40
|
+
}>;
|
|
41
|
+
writes: CapturedWrite[];
|
|
42
|
+
presigns: CapturedPresign[];
|
|
43
|
+
deletes: string[];
|
|
44
|
+
existsCalls: string[];
|
|
45
|
+
streamCalls: string[];
|
|
46
|
+
/** `true` = resolves true, `false` = resolves false, Error = throws. */
|
|
47
|
+
existsBehavior: Map<string, true | false | Error>;
|
|
48
|
+
deleteBehavior: Map<string, Error>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createFakeCtor(): { Ctor: BunS3ClientCtor; state: FakeState } {
|
|
52
|
+
const state: FakeState = {
|
|
53
|
+
ctorCalls: [],
|
|
54
|
+
writes: [],
|
|
55
|
+
presigns: [],
|
|
56
|
+
deletes: [],
|
|
57
|
+
existsCalls: [],
|
|
58
|
+
streamCalls: [],
|
|
59
|
+
existsBehavior: new Map(),
|
|
60
|
+
deleteBehavior: new Map(),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
class FakeS3Client implements BunS3ClientInstance {
|
|
64
|
+
constructor(config: {
|
|
65
|
+
bucket: string;
|
|
66
|
+
endpoint?: string;
|
|
67
|
+
region?: string;
|
|
68
|
+
virtualHostedStyle?: boolean;
|
|
69
|
+
}) {
|
|
70
|
+
state.ctorCalls.push(config);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
file(key: string) {
|
|
74
|
+
return {
|
|
75
|
+
async write(
|
|
76
|
+
body: Blob | ArrayBuffer | Uint8Array,
|
|
77
|
+
options?: { type?: string; acl?: string },
|
|
78
|
+
) {
|
|
79
|
+
state.writes.push({ key, body, options });
|
|
80
|
+
// Bun's write returns the byte count; fake a plausible number.
|
|
81
|
+
return 0;
|
|
82
|
+
},
|
|
83
|
+
presign(options?: {
|
|
84
|
+
method?: string;
|
|
85
|
+
expiresIn?: number;
|
|
86
|
+
type?: string;
|
|
87
|
+
}): string {
|
|
88
|
+
state.presigns.push({ key, options });
|
|
89
|
+
const method = options?.method ?? "PUT";
|
|
90
|
+
const expires = options?.expiresIn ?? 900;
|
|
91
|
+
return `https://fake.s3.local/${key}?method=${method}&expires=${expires}`;
|
|
92
|
+
},
|
|
93
|
+
async delete() {
|
|
94
|
+
const override = state.deleteBehavior.get(key);
|
|
95
|
+
if (override) throw override;
|
|
96
|
+
state.deletes.push(key);
|
|
97
|
+
},
|
|
98
|
+
async exists() {
|
|
99
|
+
state.existsCalls.push(key);
|
|
100
|
+
const b = state.existsBehavior.get(key);
|
|
101
|
+
if (b instanceof Error) throw b;
|
|
102
|
+
if (b === undefined) return true;
|
|
103
|
+
return b;
|
|
104
|
+
},
|
|
105
|
+
stream(): ReadableStream {
|
|
106
|
+
state.streamCalls.push(key);
|
|
107
|
+
return new ReadableStream<Uint8Array>({
|
|
108
|
+
start(controller) {
|
|
109
|
+
controller.enqueue(new Uint8Array([1, 2, 3]));
|
|
110
|
+
controller.close();
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The ctor signature we export is `new (config) => instance` — cast through
|
|
119
|
+
// unknown once to satisfy strict TS without widening the public type.
|
|
120
|
+
return {
|
|
121
|
+
Ctor: FakeS3Client as unknown as BunS3ClientCtor,
|
|
122
|
+
state,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
describe("@mandujs/core/storage/s3 — upload", () => {
|
|
129
|
+
it("calls bunClient.file(key).write(body) with the correct key and body", async () => {
|
|
130
|
+
const { Ctor, state } = createFakeCtor();
|
|
131
|
+
const client = _createS3ClientWith(Ctor, {
|
|
132
|
+
bucket: "my-bucket",
|
|
133
|
+
endpoint: "https://s3.example.com",
|
|
134
|
+
forcePathStyle: true,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const body = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
|
|
138
|
+
await client.upload(body, { key: "a/b.txt" });
|
|
139
|
+
|
|
140
|
+
expect(state.writes).toHaveLength(1);
|
|
141
|
+
expect(state.writes[0]!.key).toBe("a/b.txt");
|
|
142
|
+
expect(state.writes[0]!.body).toBe(body);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("infers Content-Type from key extension when not provided", async () => {
|
|
146
|
+
const { Ctor, state } = createFakeCtor();
|
|
147
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
148
|
+
|
|
149
|
+
await client.upload(new Uint8Array(), { key: "photos/hero.jpg" });
|
|
150
|
+
await client.upload(new Uint8Array(), { key: "data/report.json" });
|
|
151
|
+
|
|
152
|
+
expect(state.writes[0]!.options?.type).toBe("image/jpeg");
|
|
153
|
+
expect(state.writes[1]!.options?.type).toBe("application/json");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("passes through explicit contentType when provided", async () => {
|
|
157
|
+
const { Ctor, state } = createFakeCtor();
|
|
158
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
159
|
+
|
|
160
|
+
await client.upload(new Uint8Array(), {
|
|
161
|
+
key: "binary-with-jpg-extension.jpg",
|
|
162
|
+
contentType: "application/octet-stream",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Explicit contentType wins over extension-based inference.
|
|
166
|
+
expect(state.writes[0]!.options?.type).toBe("application/octet-stream");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("passes through acl when provided and omits when not", async () => {
|
|
170
|
+
const { Ctor, state } = createFakeCtor();
|
|
171
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
172
|
+
|
|
173
|
+
await client.upload(new Uint8Array(), { key: "public.png", acl: "public-read" });
|
|
174
|
+
await client.upload(new Uint8Array(), { key: "private.png" });
|
|
175
|
+
|
|
176
|
+
expect(state.writes[0]!.options?.acl).toBe("public-read");
|
|
177
|
+
// When acl is omitted from our call, it must NOT appear in the forwarded options.
|
|
178
|
+
expect(state.writes[1]!.options?.acl).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("accepts Blob, ArrayBuffer, and Uint8Array bodies uniformly", async () => {
|
|
182
|
+
const { Ctor, state } = createFakeCtor();
|
|
183
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
184
|
+
|
|
185
|
+
const blob = new Blob(["hi"], { type: "text/plain" });
|
|
186
|
+
const ab = new ArrayBuffer(4);
|
|
187
|
+
const u8 = new Uint8Array([1, 2, 3]);
|
|
188
|
+
|
|
189
|
+
await client.upload(blob, { key: "a.txt" });
|
|
190
|
+
await client.upload(ab, { key: "b.bin" });
|
|
191
|
+
await client.upload(u8, { key: "c.bin" });
|
|
192
|
+
|
|
193
|
+
expect(state.writes[0]!.body).toBe(blob);
|
|
194
|
+
expect(state.writes[1]!.body).toBe(ab);
|
|
195
|
+
expect(state.writes[2]!.body).toBe(u8);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("throws when metadata is passed (not yet supported by Bun.S3Client)", async () => {
|
|
199
|
+
const { Ctor } = createFakeCtor();
|
|
200
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
201
|
+
|
|
202
|
+
await expect(
|
|
203
|
+
client.upload(new Uint8Array(), {
|
|
204
|
+
key: "x.txt",
|
|
205
|
+
metadata: { owner: "alice" },
|
|
206
|
+
}),
|
|
207
|
+
).rejects.toThrow(/metadata.*not yet supported/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns a path-style URL when forcePathStyle=true", async () => {
|
|
211
|
+
const { Ctor } = createFakeCtor();
|
|
212
|
+
const client = _createS3ClientWith(Ctor, {
|
|
213
|
+
bucket: "uploads",
|
|
214
|
+
endpoint: "https://minio.local:9000",
|
|
215
|
+
forcePathStyle: true,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const url = await client.upload(new Uint8Array(), { key: "u/1.png" });
|
|
219
|
+
expect(url).toBe("https://minio.local:9000/uploads/u/1.png");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns a virtual-hosted URL by default", async () => {
|
|
223
|
+
const { Ctor } = createFakeCtor();
|
|
224
|
+
const client = _createS3ClientWith(Ctor, {
|
|
225
|
+
bucket: "uploads",
|
|
226
|
+
endpoint: "https://s3.example.com",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const url = await client.upload(new Uint8Array(), { key: "u/1.png" });
|
|
230
|
+
expect(url).toBe("https://uploads.s3.example.com/u/1.png");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("returns an s3:// URI when no endpoint is configured (AWS default)", async () => {
|
|
234
|
+
const { Ctor } = createFakeCtor();
|
|
235
|
+
const client = _createS3ClientWith(Ctor, { bucket: "my-bucket" });
|
|
236
|
+
|
|
237
|
+
const url = await client.upload(new Uint8Array(), { key: "a/b.txt" });
|
|
238
|
+
expect(url).toBe("s3://my-bucket/a/b.txt");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("URL-encodes path segments but preserves '/' separators", async () => {
|
|
242
|
+
const { Ctor } = createFakeCtor();
|
|
243
|
+
const client = _createS3ClientWith(Ctor, {
|
|
244
|
+
bucket: "b",
|
|
245
|
+
endpoint: "https://s3.example.com",
|
|
246
|
+
forcePathStyle: true,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const url = await client.upload(new Uint8Array(), {
|
|
250
|
+
key: "user uploads/hello world.txt",
|
|
251
|
+
});
|
|
252
|
+
expect(url).toBe("https://s3.example.com/b/user%20uploads/hello%20world.txt");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("throws a TypeError when key is missing", async () => {
|
|
256
|
+
const { Ctor } = createFakeCtor();
|
|
257
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
258
|
+
|
|
259
|
+
await expect(
|
|
260
|
+
client.upload(new Uint8Array(), { key: "" }),
|
|
261
|
+
).rejects.toThrow(TypeError);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("@mandujs/core/storage/s3 — presign", () => {
|
|
266
|
+
it("returns a string URL for method=PUT", async () => {
|
|
267
|
+
const { Ctor, state } = createFakeCtor();
|
|
268
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
269
|
+
|
|
270
|
+
const url = await client.presign({ key: "upload.png", method: "PUT" });
|
|
271
|
+
|
|
272
|
+
expect(typeof url).toBe("string");
|
|
273
|
+
expect(url.length).toBeGreaterThan(0);
|
|
274
|
+
expect(state.presigns[0]!.options?.method).toBe("PUT");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("honors expiresIn=60 for method=GET", async () => {
|
|
278
|
+
const { Ctor, state } = createFakeCtor();
|
|
279
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
280
|
+
|
|
281
|
+
await client.presign({ key: "download.txt", method: "GET", expiresIn: 60 });
|
|
282
|
+
|
|
283
|
+
expect(state.presigns[0]!.options?.method).toBe("GET");
|
|
284
|
+
expect(state.presigns[0]!.options?.expiresIn).toBe(60);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("defaults method to PUT and expiresIn to 900 seconds", async () => {
|
|
288
|
+
const { Ctor, state } = createFakeCtor();
|
|
289
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
290
|
+
|
|
291
|
+
await client.presign({ key: "thing" });
|
|
292
|
+
|
|
293
|
+
expect(state.presigns[0]!.options?.method).toBe("PUT");
|
|
294
|
+
expect(state.presigns[0]!.options?.expiresIn).toBe(900);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("forwards contentType as Bun's `type` option", async () => {
|
|
298
|
+
const { Ctor, state } = createFakeCtor();
|
|
299
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
300
|
+
|
|
301
|
+
await client.presign({ key: "x.png", method: "PUT", contentType: "image/png" });
|
|
302
|
+
|
|
303
|
+
expect(state.presigns[0]!.options?.type).toBe("image/png");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("throws a TypeError when key is missing", async () => {
|
|
307
|
+
const { Ctor } = createFakeCtor();
|
|
308
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
309
|
+
|
|
310
|
+
await expect(client.presign({ key: "" })).rejects.toThrow(TypeError);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("@mandujs/core/storage/s3 — delete", () => {
|
|
315
|
+
it("calls bunClient.file(key).delete() and resolves", async () => {
|
|
316
|
+
const { Ctor, state } = createFakeCtor();
|
|
317
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
318
|
+
|
|
319
|
+
await client.delete("to-remove.txt");
|
|
320
|
+
|
|
321
|
+
expect(state.deletes).toContain("to-remove.txt");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("resolves silently on NoSuchKey errors (idempotent delete)", async () => {
|
|
325
|
+
const { Ctor, state } = createFakeCtor();
|
|
326
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
327
|
+
|
|
328
|
+
const notFound = Object.assign(new Error("Not found"), {
|
|
329
|
+
name: "S3Error",
|
|
330
|
+
code: "NoSuchKey",
|
|
331
|
+
});
|
|
332
|
+
state.deleteBehavior.set("ghost.txt", notFound);
|
|
333
|
+
|
|
334
|
+
await expect(client.delete("ghost.txt")).resolves.toBeUndefined();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("re-throws non-404 delete errors", async () => {
|
|
338
|
+
const { Ctor, state } = createFakeCtor();
|
|
339
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
340
|
+
|
|
341
|
+
const accessDenied = Object.assign(new Error("Access denied"), {
|
|
342
|
+
name: "S3Error",
|
|
343
|
+
code: "AccessDenied",
|
|
344
|
+
status: 403,
|
|
345
|
+
});
|
|
346
|
+
state.deleteBehavior.set("forbidden.txt", accessDenied);
|
|
347
|
+
|
|
348
|
+
await expect(client.delete("forbidden.txt")).rejects.toThrow(/Access denied/);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("@mandujs/core/storage/s3 — exists", () => {
|
|
353
|
+
it("returns true when the object exists", async () => {
|
|
354
|
+
const { Ctor, state } = createFakeCtor();
|
|
355
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
356
|
+
state.existsBehavior.set("here.txt", true);
|
|
357
|
+
|
|
358
|
+
expect(await client.exists("here.txt")).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("returns false when Bun.s3 reports the object as absent", async () => {
|
|
362
|
+
const { Ctor, state } = createFakeCtor();
|
|
363
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
364
|
+
state.existsBehavior.set("gone.txt", false);
|
|
365
|
+
|
|
366
|
+
expect(await client.exists("gone.txt")).toBe(false);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("returns false when Bun.s3 throws a NoSuchKey-shaped error", async () => {
|
|
370
|
+
const { Ctor, state } = createFakeCtor();
|
|
371
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
372
|
+
const nf = Object.assign(new Error("no key"), {
|
|
373
|
+
name: "S3Error",
|
|
374
|
+
code: "NoSuchKey",
|
|
375
|
+
});
|
|
376
|
+
state.existsBehavior.set("missing.txt", nf);
|
|
377
|
+
|
|
378
|
+
expect(await client.exists("missing.txt")).toBe(false);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("re-throws non-404 errors (e.g., AccessDenied/5xx)", async () => {
|
|
382
|
+
const { Ctor, state } = createFakeCtor();
|
|
383
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
384
|
+
const denied = Object.assign(new Error("403 denied"), {
|
|
385
|
+
name: "S3Error",
|
|
386
|
+
code: "AccessDenied",
|
|
387
|
+
status: 403,
|
|
388
|
+
});
|
|
389
|
+
state.existsBehavior.set("protected.txt", denied);
|
|
390
|
+
|
|
391
|
+
await expect(client.exists("protected.txt")).rejects.toThrow(/403 denied/);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe("@mandujs/core/storage/s3 — getReadable", () => {
|
|
396
|
+
it("returns a ReadableStream", async () => {
|
|
397
|
+
const { Ctor, state } = createFakeCtor();
|
|
398
|
+
const client = _createS3ClientWith(Ctor, { bucket: "b" });
|
|
399
|
+
|
|
400
|
+
const s = await client.getReadable("file.bin");
|
|
401
|
+
|
|
402
|
+
expect(s).toBeInstanceOf(ReadableStream);
|
|
403
|
+
expect(state.streamCalls).toContain("file.bin");
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe("@mandujs/core/storage/s3 — createS3Client", () => {
|
|
408
|
+
it("forwards bucket, endpoint, and region to the underlying ctor", () => {
|
|
409
|
+
const { Ctor, state } = createFakeCtor();
|
|
410
|
+
_createS3ClientWith(Ctor, {
|
|
411
|
+
bucket: "prod-uploads",
|
|
412
|
+
endpoint: "https://abc.r2.cloudflarestorage.com",
|
|
413
|
+
region: "auto",
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
expect(state.ctorCalls).toHaveLength(1);
|
|
417
|
+
expect(state.ctorCalls[0]!.bucket).toBe("prod-uploads");
|
|
418
|
+
expect(state.ctorCalls[0]!.endpoint).toBe("https://abc.r2.cloudflarestorage.com");
|
|
419
|
+
expect(state.ctorCalls[0]!.region).toBe("auto");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("maps forcePathStyle=true to virtualHostedStyle=false (Bun's flag name)", () => {
|
|
423
|
+
const { Ctor, state } = createFakeCtor();
|
|
424
|
+
_createS3ClientWith(Ctor, {
|
|
425
|
+
bucket: "b",
|
|
426
|
+
endpoint: "http://localhost:9000",
|
|
427
|
+
forcePathStyle: true,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
expect(state.ctorCalls[0]!.virtualHostedStyle).toBe(false);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("leaves virtualHostedStyle untouched when forcePathStyle is unset", () => {
|
|
434
|
+
const { Ctor, state } = createFakeCtor();
|
|
435
|
+
_createS3ClientWith(Ctor, { bucket: "b" });
|
|
436
|
+
|
|
437
|
+
expect(state.ctorCalls[0]!.virtualHostedStyle).toBeUndefined();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("throws a TypeError when bucket is missing", () => {
|
|
441
|
+
const { Ctor } = createFakeCtor();
|
|
442
|
+
expect(() =>
|
|
443
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
444
|
+
_createS3ClientWith(Ctor, { bucket: "" } as S3ConfigLike),
|
|
445
|
+
).toThrow(TypeError);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Helper alias for the "missing bucket" negative test — keeps us from having
|
|
450
|
+
// to stringify `any` in the test body.
|
|
451
|
+
type S3ConfigLike = Parameters<typeof _createS3ClientWith>[1];
|
|
452
|
+
|
|
453
|
+
describe("@mandujs/core/storage/s3 — getContentType", () => {
|
|
454
|
+
it.each([
|
|
455
|
+
["photo.jpg", "image/jpeg"],
|
|
456
|
+
["PHOTO.JPEG", "image/jpeg"], // case-insensitive
|
|
457
|
+
["a/b/c.png", "image/png"],
|
|
458
|
+
["logo.webp", "image/webp"],
|
|
459
|
+
["anim.gif", "image/gif"],
|
|
460
|
+
["doc.pdf", "application/pdf"],
|
|
461
|
+
["notes.txt", "text/plain"],
|
|
462
|
+
["data.json", "application/json"],
|
|
463
|
+
["report.csv", "text/csv"],
|
|
464
|
+
["archive.zip", "application/zip"],
|
|
465
|
+
["clip.mp4", "video/mp4"],
|
|
466
|
+
["clip.webm", "video/webm"],
|
|
467
|
+
])("maps %s to %s", (key, expected) => {
|
|
468
|
+
expect(getContentType(key)).toBe(expected);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it.each([
|
|
472
|
+
["README", "application/octet-stream"], // no extension
|
|
473
|
+
["archive.", "application/octet-stream"], // trailing dot
|
|
474
|
+
["file.unknown", "application/octet-stream"], // unrecognized extension
|
|
475
|
+
[".hidden", "application/octet-stream"], // dotfile without real extension
|
|
476
|
+
])("falls back to octet-stream for %s", (key, expected) => {
|
|
477
|
+
expect(getContentType(key)).toBe(expected);
|
|
478
|
+
});
|
|
479
|
+
});
|