|
| 1 | +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
| 2 | +import { S3Client, S3Error } from "./client"; |
| 3 | +import type { S3ClientConfig } from "./client"; |
| 4 | + |
| 5 | +// ---- S3Error ---- |
| 6 | +describe("S3Error", () => { |
| 7 | + it("应当正确设置 code、message、statusCode 属性", () => { |
| 8 | + const err = new S3Error("NoSuchKey", "The specified key does not exist", 404); |
| 9 | + |
| 10 | + expect(err).toBeInstanceOf(Error); |
| 11 | + expect(err).toBeInstanceOf(S3Error); |
| 12 | + expect(err.code).toBe("NoSuchKey"); |
| 13 | + expect(err.name).toBe("NoSuchKey"); // 兼容 SDK error.name 检查 |
| 14 | + expect(err.message).toBe("The specified key does not exist"); |
| 15 | + expect(err.statusCode).toBe(404); |
| 16 | + }); |
| 17 | + |
| 18 | + it("应当可被 try/catch 捕获并通过 instanceof 判断", () => { |
| 19 | + try { |
| 20 | + throw new S3Error("AccessDenied", "Access Denied", 403); |
| 21 | + } catch (e) { |
| 22 | + expect(e).toBeInstanceOf(S3Error); |
| 23 | + if (e instanceof S3Error) { |
| 24 | + expect(e.code).toBe("AccessDenied"); |
| 25 | + expect(e.statusCode).toBe(403); |
| 26 | + } |
| 27 | + } |
| 28 | + }); |
| 29 | +}); |
| 30 | + |
| 31 | +// ---- S3Client 构造函数与 getter 方法 ---- |
| 32 | +describe("S3Client", () => { |
| 33 | + const defaultConfig: S3ClientConfig = { |
| 34 | + region: "us-west-2", |
| 35 | + credentials: { |
| 36 | + accessKeyId: "AKIAIOSFODNN7EXAMPLE", |
| 37 | + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", |
| 38 | + }, |
| 39 | + }; |
| 40 | + |
| 41 | + describe("constructor", () => { |
| 42 | + it("应当使用默认 AWS endpoint 当未指定 endpoint 时", () => { |
| 43 | + const client = new S3Client(defaultConfig); |
| 44 | + |
| 45 | + expect(client.getEndpointUrl()).toBe("https://s3.us-west-2.amazonaws.com"); |
| 46 | + expect(client.hasCustomEndpoint()).toBe(false); |
| 47 | + }); |
| 48 | + |
| 49 | + it("应当使用自定义 endpoint", () => { |
| 50 | + const client = new S3Client({ |
| 51 | + ...defaultConfig, |
| 52 | + endpoint: "https://minio.example.com:9000", |
| 53 | + }); |
| 54 | + |
| 55 | + expect(client.getEndpointUrl()).toBe("https://minio.example.com:9000"); |
| 56 | + expect(client.hasCustomEndpoint()).toBe(true); |
| 57 | + }); |
| 58 | + |
| 59 | + it("应当为无协议前缀的 endpoint 自动添加 https://", () => { |
| 60 | + const client = new S3Client({ |
| 61 | + ...defaultConfig, |
| 62 | + endpoint: "s3.custom.com", |
| 63 | + }); |
| 64 | + |
| 65 | + expect(client.getEndpointUrl()).toBe("https://s3.custom.com"); |
| 66 | + }); |
| 67 | + |
| 68 | + it("应当去除 endpoint 末尾的斜杠", () => { |
| 69 | + const client = new S3Client({ |
| 70 | + ...defaultConfig, |
| 71 | + endpoint: "https://minio.example.com///", |
| 72 | + }); |
| 73 | + |
| 74 | + expect(client.getEndpointUrl()).toBe("https://minio.example.com"); |
| 75 | + }); |
| 76 | + |
| 77 | + it("应当支持 http:// 协议的 endpoint", () => { |
| 78 | + const client = new S3Client({ |
| 79 | + ...defaultConfig, |
| 80 | + endpoint: "http://localhost:9000", |
| 81 | + }); |
| 82 | + |
| 83 | + expect(client.getEndpointUrl()).toBe("http://localhost:9000"); |
| 84 | + }); |
| 85 | + |
| 86 | + it("应当默认 forcePathStyle 为 true", () => { |
| 87 | + const client = new S3Client(defaultConfig); |
| 88 | + |
| 89 | + expect(client.isForcePathStyle()).toBe(true); |
| 90 | + }); |
| 91 | + |
| 92 | + it("应当允许设置 forcePathStyle 为 false", () => { |
| 93 | + const client = new S3Client({ |
| 94 | + ...defaultConfig, |
| 95 | + forcePathStyle: false, |
| 96 | + }); |
| 97 | + |
| 98 | + expect(client.isForcePathStyle()).toBe(false); |
| 99 | + }); |
| 100 | + |
| 101 | + it("应当正确返回 region", () => { |
| 102 | + const client = new S3Client(defaultConfig); |
| 103 | + expect(client.getRegion()).toBe("us-west-2"); |
| 104 | + }); |
| 105 | + |
| 106 | + it("应当在 region 为空字符串时默认使用 us-east-1", () => { |
| 107 | + const client = new S3Client({ |
| 108 | + ...defaultConfig, |
| 109 | + region: "", |
| 110 | + }); |
| 111 | + |
| 112 | + expect(client.getRegion()).toBe("us-east-1"); |
| 113 | + }); |
| 114 | + }); |
| 115 | + |
| 116 | + // ---- request 方法 ---- |
| 117 | + describe("request", () => { |
| 118 | + let client: S3Client; |
| 119 | + let fetchSpy: ReturnType<typeof vi.fn>; |
| 120 | + |
| 121 | + beforeEach(() => { |
| 122 | + client = new S3Client({ |
| 123 | + ...defaultConfig, |
| 124 | + endpoint: "https://s3.us-west-2.amazonaws.com", |
| 125 | + }); |
| 126 | + fetchSpy = vi.fn(); |
| 127 | + vi.stubGlobal("fetch", fetchSpy); |
| 128 | + }); |
| 129 | + |
| 130 | + afterEach(() => { |
| 131 | + vi.unstubAllGlobals(); |
| 132 | + }); |
| 133 | + |
| 134 | + it("应当发送 GET 请求并返回 Response", async () => { |
| 135 | + const mockResponse = new Response("hello", { status: 200, statusText: "OK" }); |
| 136 | + fetchSpy.mockResolvedValue(mockResponse); |
| 137 | + |
| 138 | + const resp = await client.request("GET", "my-bucket", "test-key.txt"); |
| 139 | + |
| 140 | + expect(resp).toBe(mockResponse); |
| 141 | + expect(fetchSpy).toHaveBeenCalledTimes(1); |
| 142 | + |
| 143 | + // 验证 URL(path-style) |
| 144 | + const [url, options] = fetchSpy.mock.calls[0]; |
| 145 | + expect(url).toContain("/my-bucket/test-key.txt"); |
| 146 | + expect(options.method).toBe("GET"); |
| 147 | + }); |
| 148 | + |
| 149 | + it("应当在 path-style 模式下构建正确的 URL", async () => { |
| 150 | + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); |
| 151 | + |
| 152 | + await client.request("GET", "my-bucket", "folder/file.txt"); |
| 153 | + |
| 154 | + const [url] = fetchSpy.mock.calls[0]; |
| 155 | + expect(url).toBe("https://s3.us-west-2.amazonaws.com/my-bucket/folder/file.txt"); |
| 156 | + }); |
| 157 | + |
| 158 | + it("应当在 virtual-hosted 模式下构建正确的 URL", async () => { |
| 159 | + const vhClient = new S3Client({ |
| 160 | + ...defaultConfig, |
| 161 | + forcePathStyle: false, |
| 162 | + }); |
| 163 | + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); |
| 164 | + |
| 165 | + await vhClient.request("GET", "my-bucket", "file.txt"); |
| 166 | + |
| 167 | + const [url] = fetchSpy.mock.calls[0]; |
| 168 | + expect(url).toBe("https://my-bucket.s3.us-west-2.amazonaws.com/file.txt"); |
| 169 | + }); |
| 170 | + |
| 171 | + it("应当在请求头中包含 AWS Signature V4 签名", async () => { |
| 172 | + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); |
| 173 | + |
| 174 | + await client.request("GET", "my-bucket", "test.txt"); |
| 175 | + |
| 176 | + const [, options] = fetchSpy.mock.calls[0]; |
| 177 | + const headers = options.headers; |
| 178 | + |
| 179 | + // 验证签名头存在 |
| 180 | + expect(headers["authorization"]).toMatch(/^AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE\//); |
| 181 | + expect(headers["authorization"]).toContain("SignedHeaders="); |
| 182 | + expect(headers["authorization"]).toContain("Signature="); |
| 183 | + expect(headers["x-amz-date"]).toMatch(/^\d{8}T\d{6}Z$/); |
| 184 | + expect(headers["x-amz-content-sha256"]).toBeDefined(); |
| 185 | + }); |
| 186 | + |
| 187 | + it("应当正确传递 query parameters", async () => { |
| 188 | + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); |
| 189 | + |
| 190 | + await client.request("GET", "my-bucket", undefined, { |
| 191 | + queryParams: { "list-type": "2", prefix: "docs/" }, |
| 192 | + }); |
| 193 | + |
| 194 | + const [url] = fetchSpy.mock.calls[0]; |
| 195 | + expect(url).toContain("list-type=2"); |
| 196 | + expect(url).toContain("prefix=docs%2F"); |
| 197 | + }); |
| 198 | + |
| 199 | + it("应当正确传递 string body 的 PUT 请求", async () => { |
| 200 | + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); |
| 201 | + |
| 202 | + await client.request("PUT", "my-bucket", "file.txt", { |
| 203 | + body: "file content", |
| 204 | + headers: { "Content-Type": "text/plain" }, |
| 205 | + }); |
| 206 | + |
| 207 | + const [, options] = fetchSpy.mock.calls[0]; |
| 208 | + expect(options.method).toBe("PUT"); |
| 209 | + expect(options.body).toBe("file content"); |
| 210 | + }); |
| 211 | + |
| 212 | + it("应当正确传递 Uint8Array body", async () => { |
| 213 | + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); |
| 214 | + |
| 215 | + const body = new TextEncoder().encode("binary data"); |
| 216 | + await client.request("PUT", "my-bucket", "binary.bin", { body }); |
| 217 | + |
| 218 | + const [url, options] = fetchSpy.mock.calls[0]; |
| 219 | + expect(url).toContain("/my-bucket/binary.bin"); |
| 220 | + expect(options.method).toBe("PUT"); |
| 221 | + // body 应当是 Uint8Array.buffer(ArrayBuffer 或兼容类型) |
| 222 | + expect(options.body).toBeDefined(); |
| 223 | + expect(options.body).not.toBeTypeOf("string"); |
| 224 | + }); |
| 225 | + |
| 226 | + it("应当在非 2xx 响应时抛出 S3Error(XML 错误体)", async () => { |
| 227 | + const errorXml = `<?xml version="1.0" encoding="UTF-8"?> |
| 228 | + <Error> |
| 229 | + <Code>NoSuchKey</Code> |
| 230 | + <Message>The specified key does not exist.</Message> |
| 231 | + </Error>`; |
| 232 | + fetchSpy.mockResolvedValue(new Response(errorXml, { status: 404, statusText: "Not Found" })); |
| 233 | + |
| 234 | + try { |
| 235 | + await client.request("GET", "my-bucket", "nonexistent.txt"); |
| 236 | + expect.unreachable("should have thrown"); |
| 237 | + } catch (e) { |
| 238 | + expect(e).toBeInstanceOf(S3Error); |
| 239 | + if (e instanceof S3Error) { |
| 240 | + expect(e.code).toBe("NoSuchKey"); |
| 241 | + expect(e.statusCode).toBe(404); |
| 242 | + expect(e.message).toBe("The specified key does not exist."); |
| 243 | + } |
| 244 | + } |
| 245 | + }); |
| 246 | + |
| 247 | + it("应当在无 XML 体的错误响应时使用状态码映射", async () => { |
| 248 | + fetchSpy.mockResolvedValue(new Response("", { status: 403, statusText: "Forbidden" })); |
| 249 | + |
| 250 | + await expect(client.request("HEAD", "my-bucket")).rejects.toSatisfy((e: S3Error) => { |
| 251 | + return e instanceof S3Error && e.statusCode === 403; |
| 252 | + }); |
| 253 | + }); |
| 254 | + |
| 255 | + it("应当在 DELETE 请求成功时返回 Response", async () => { |
| 256 | + // jsdom 不支持 204 状态码构造 Response,使用 200 代替验证 DELETE 请求逻辑 |
| 257 | + fetchSpy.mockResolvedValue(new Response(null, { status: 200, statusText: "OK" })); |
| 258 | + |
| 259 | + const resp = await client.request("DELETE", "my-bucket", "file.txt"); |
| 260 | + expect(resp.status).toBe(200); |
| 261 | + }); |
| 262 | + |
| 263 | + it("应当不在 fetch headers 中包含 host 头", async () => { |
| 264 | + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); |
| 265 | + |
| 266 | + await client.request("GET", "my-bucket", "test.txt"); |
| 267 | + |
| 268 | + const [, options] = fetchSpy.mock.calls[0]; |
| 269 | + expect(options.headers["host"]).toBeUndefined(); |
| 270 | + }); |
| 271 | + |
| 272 | + it("应当将自定义 headers 的 key 转为小写", async () => { |
| 273 | + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); |
| 274 | + |
| 275 | + await client.request("PUT", "my-bucket", "test.txt", { |
| 276 | + body: "data", |
| 277 | + headers: { "Content-Type": "text/plain", "X-Custom-Header": "value" }, |
| 278 | + }); |
| 279 | + |
| 280 | + const [, options] = fetchSpy.mock.calls[0]; |
| 281 | + // authorization 中 SignedHeaders 应包含小写 key |
| 282 | + expect(options.headers["authorization"]).toContain("content-type"); |
| 283 | + expect(options.headers["authorization"]).toContain("x-custom-header"); |
| 284 | + }); |
| 285 | + |
| 286 | + it("应当在不传 key 时只使用 bucket 路径", async () => { |
| 287 | + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); |
| 288 | + |
| 289 | + await client.request("HEAD", "my-bucket"); |
| 290 | + |
| 291 | + const [url] = fetchSpy.mock.calls[0]; |
| 292 | + expect(url).toBe("https://s3.us-west-2.amazonaws.com/my-bucket"); |
| 293 | + }); |
| 294 | + |
| 295 | + it("应当正确处理包含特殊字符的 key", async () => { |
| 296 | + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); |
| 297 | + |
| 298 | + await client.request("GET", "my-bucket", "path/to/file with spaces.txt"); |
| 299 | + |
| 300 | + const [url] = fetchSpy.mock.calls[0]; |
| 301 | + expect(url).toContain("file%20with%20spaces.txt"); |
| 302 | + }); |
| 303 | + }); |
| 304 | +}); |
0 commit comments