@openclaw-china/shared 0.1.28 → 0.1.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw-china/shared",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -0,0 +1,100 @@
1
+ # ASR 接入说明(供各插件复用)
2
+
3
+ 本文说明如何在任意渠道插件中复用 `@openclaw-china/shared` 的语音转文本能力。
4
+
5
+ 当前已提供:
6
+ - 腾讯云录音文件识别极速版(`asr/flash/v1`)
7
+ - 统一错误类型(便于插件侧日志和降级)
8
+
9
+ ## 职责边界
10
+
11
+ `shared` 负责:
12
+ - 调用 ASR 服务(签名、请求、响应解析、超时)
13
+ - 抛出结构化错误(`ASRError` 及子类)
14
+
15
+ 单个插件负责:
16
+ - 平台事件解析(如何识别语音消息)
17
+ - 媒体下载(URL/鉴权/大小限制)
18
+ - 业务策略(失败是否降级、回复文案、是否中断本次处理)
19
+ - 密钥配置(建议保留在插件配置内)
20
+
21
+ ## 可直接使用的导出
22
+
23
+ ```ts
24
+ import {
25
+ transcribeTencentFlash,
26
+ ASRError,
27
+ ASRTimeoutError,
28
+ ASRAuthError,
29
+ ASRRequestError,
30
+ ASRResponseParseError,
31
+ ASRServiceError,
32
+ ASREmptyResultError,
33
+ } from "@openclaw-china/shared";
34
+ ```
35
+
36
+ ## 插件接入步骤
37
+
38
+ 1. 在插件配置中增加 ASR 字段(示例)
39
+ ```ts
40
+ asr: {
41
+ enabled?: boolean;
42
+ appId?: string;
43
+ secretId?: string;
44
+ secretKey?: string;
45
+ }
46
+ ```
47
+
48
+ 2. 在入站消息里识别语音附件,并下载到 `Buffer`
49
+ - 推荐复用 `shared/media` 的下载能力:
50
+ ```ts
51
+ import { fetchMediaFromUrl } from "@openclaw-china/shared";
52
+ ```
53
+
54
+ 3. 调用转写
55
+ ```ts
56
+ const transcript = await transcribeTencentFlash({
57
+ audio: media.buffer,
58
+ config: {
59
+ appId: asr.appId,
60
+ secretId: asr.secretId,
61
+ secretKey: asr.secretKey,
62
+ // 可选,默认如下:
63
+ // engineType: "16k_zh",
64
+ // voiceFormat: "silk",
65
+ // timeoutMs: 30000,
66
+ },
67
+ });
68
+ ```
69
+
70
+ 4. 按错误类型做日志与降级
71
+ ```ts
72
+ try {
73
+ // transcribeTencentFlash(...)
74
+ } catch (err) {
75
+ if (err instanceof ASRError) {
76
+ logger.warn(
77
+ `asr failed kind=${err.kind} provider=${err.provider} retryable=${err.retryable} msg=${err.message}`
78
+ );
79
+ } else {
80
+ logger.warn(`asr failed: ${String(err)}`);
81
+ }
82
+ // 在这里决定是否回退文本、提示用户、或继续其他链路
83
+ }
84
+ ```
85
+
86
+ ## 错误类型语义
87
+
88
+ - `ASRTimeoutError`:请求超时,通常可重试
89
+ - `ASRAuthError`:鉴权失败(密钥错误/权限问题),通常不可重试
90
+ - `ASRRequestError`:请求失败(网络或 HTTP 失败)
91
+ - `ASRResponseParseError`:服务返回非 JSON 或格式异常
92
+ - `ASRServiceError`:服务端返回业务错误码(`code != 0`)
93
+ - `ASREmptyResultError`:识别成功但无文本
94
+
95
+ ## 最佳实践
96
+
97
+ - 不要把密钥硬编码到仓库;仅通过配置注入。
98
+ - 给下载和 ASR 都设置超时与大小上限。
99
+ - 将“ASR 失败文案”放在插件侧,不放到 `shared`。
100
+ - 先记录结构化日志,再决定业务降级策略。
@@ -0,0 +1,61 @@
1
+ export type ASRErrorKind =
2
+ | "timeout"
3
+ | "auth"
4
+ | "request"
5
+ | "response_parse"
6
+ | "service"
7
+ | "empty_result";
8
+
9
+ export class ASRError extends Error {
10
+ constructor(
11
+ message: string,
12
+ public readonly kind: ASRErrorKind,
13
+ public readonly provider: string,
14
+ public readonly retryable: boolean = false
15
+ ) {
16
+ super(message);
17
+ this.name = "ASRError";
18
+ }
19
+ }
20
+
21
+ export class ASRTimeoutError extends ASRError {
22
+ constructor(provider: string, public readonly timeoutMs: number) {
23
+ super(`ASR request timeout after ${timeoutMs}ms`, "timeout", provider, true);
24
+ this.name = "ASRTimeoutError";
25
+ }
26
+ }
27
+
28
+ export class ASRAuthError extends ASRError {
29
+ constructor(provider: string, message: string, public readonly status?: number) {
30
+ super(message, "auth", provider, false);
31
+ this.name = "ASRAuthError";
32
+ }
33
+ }
34
+
35
+ export class ASRRequestError extends ASRError {
36
+ constructor(provider: string, message: string, public readonly status?: number) {
37
+ super(message, "request", provider, true);
38
+ this.name = "ASRRequestError";
39
+ }
40
+ }
41
+
42
+ export class ASRResponseParseError extends ASRError {
43
+ constructor(provider: string, public readonly bodySnippet: string) {
44
+ super("ASR response is not valid JSON", "response_parse", provider, false);
45
+ this.name = "ASRResponseParseError";
46
+ }
47
+ }
48
+
49
+ export class ASRServiceError extends ASRError {
50
+ constructor(provider: string, message: string, public readonly serviceCode?: number) {
51
+ super(message, "service", provider, false);
52
+ this.name = "ASRServiceError";
53
+ }
54
+ }
55
+
56
+ export class ASREmptyResultError extends ASRError {
57
+ constructor(provider: string) {
58
+ super("ASR returned empty transcript", "empty_result", provider, false);
59
+ this.name = "ASREmptyResultError";
60
+ }
61
+ }
@@ -0,0 +1,11 @@
1
+ export { transcribeTencentFlash, type TencentFlashASRConfig } from "./tencent-flash.js";
2
+ export {
3
+ ASRError,
4
+ ASRTimeoutError,
5
+ ASRAuthError,
6
+ ASRRequestError,
7
+ ASRResponseParseError,
8
+ ASRServiceError,
9
+ ASREmptyResultError,
10
+ type ASRErrorKind,
11
+ } from "./errors.js";
@@ -0,0 +1,165 @@
1
+ import { createHmac } from "node:crypto";
2
+ import {
3
+ ASRAuthError,
4
+ ASREmptyResultError,
5
+ ASRRequestError,
6
+ ASRResponseParseError,
7
+ ASRServiceError,
8
+ ASRTimeoutError,
9
+ } from "./errors.js";
10
+
11
+ const ASR_FLASH_HOST = "asr.cloud.tencent.com";
12
+ const ASR_FLASH_PATH_PREFIX = "/asr/flash/v1";
13
+ const ASR_FLASH_URL_PREFIX = `https://${ASR_FLASH_HOST}${ASR_FLASH_PATH_PREFIX}`;
14
+ const ASR_PROVIDER = "tencent-flash";
15
+
16
+ export interface TencentFlashASRConfig {
17
+ appId: string;
18
+ secretId: string;
19
+ secretKey: string;
20
+ engineType?: string;
21
+ voiceFormat?: string;
22
+ timeoutMs?: number;
23
+ }
24
+
25
+ interface TencentFlashResponseSentence {
26
+ text?: string;
27
+ }
28
+
29
+ interface TencentFlashResponseItem {
30
+ text?: string;
31
+ sentence_list?: TencentFlashResponseSentence[];
32
+ }
33
+
34
+ interface TencentFlashResponse {
35
+ code?: number;
36
+ message?: string;
37
+ flash_result?: TencentFlashResponseItem[];
38
+ }
39
+
40
+ function encodeQueryValue(value: string): string {
41
+ return encodeURIComponent(value)
42
+ .replace(/%20/g, "+")
43
+ .replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
44
+ }
45
+
46
+ function buildSignedQuery(params: Record<string, string>): string {
47
+ return Object.entries(params)
48
+ .sort(([a], [b]) => a.localeCompare(b))
49
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeQueryValue(value)}`)
50
+ .join("&");
51
+ }
52
+
53
+ function extractTranscript(payload: TencentFlashResponse): string {
54
+ const items = Array.isArray(payload.flash_result) ? payload.flash_result : [];
55
+ const lines: string[] = [];
56
+
57
+ for (const item of items) {
58
+ if (typeof item?.text === "string" && item.text.trim()) {
59
+ lines.push(item.text.trim());
60
+ continue;
61
+ }
62
+ const sentenceList = Array.isArray(item?.sentence_list) ? item.sentence_list : [];
63
+ for (const sentence of sentenceList) {
64
+ if (typeof sentence?.text === "string" && sentence.text.trim()) {
65
+ lines.push(sentence.text.trim());
66
+ }
67
+ }
68
+ }
69
+
70
+ return lines.join("\n").trim();
71
+ }
72
+
73
+ export async function transcribeTencentFlash(params: {
74
+ audio: Buffer;
75
+ config: TencentFlashASRConfig;
76
+ }): Promise<string> {
77
+ const { audio, config } = params;
78
+ const timestamp = Math.floor(Date.now() / 1000).toString();
79
+ const engineType = config.engineType ?? "16k_zh";
80
+ const voiceFormat = config.voiceFormat ?? "silk";
81
+ const query = buildSignedQuery({
82
+ engine_type: engineType,
83
+ secretid: config.secretId,
84
+ timestamp,
85
+ voice_format: voiceFormat,
86
+ });
87
+
88
+ const signText = `POST${ASR_FLASH_HOST}${ASR_FLASH_PATH_PREFIX}/${config.appId}?${query}`;
89
+ const authorization = createHmac("sha1", config.secretKey).update(signText).digest("base64");
90
+ const url = `${ASR_FLASH_URL_PREFIX}/${config.appId}?${query}`;
91
+ const timeoutMs = config.timeoutMs ?? 30000;
92
+ const controller = new AbortController();
93
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
94
+
95
+ try {
96
+ const response = await fetch(url, {
97
+ method: "POST",
98
+ headers: {
99
+ Authorization: authorization,
100
+ "Content-Type": "application/octet-stream",
101
+ },
102
+ body: audio,
103
+ signal: controller.signal,
104
+ });
105
+
106
+ const bodyText = await response.text();
107
+ let payload: TencentFlashResponse;
108
+ try {
109
+ payload = JSON.parse(bodyText) as TencentFlashResponse;
110
+ } catch {
111
+ throw new ASRResponseParseError(ASR_PROVIDER, bodyText.slice(0, 300));
112
+ }
113
+
114
+ if (!response.ok) {
115
+ const message = payload.message ?? `HTTP ${response.status}`;
116
+ if (response.status === 401 || response.status === 403) {
117
+ throw new ASRAuthError(
118
+ ASR_PROVIDER,
119
+ `Tencent Flash ASR authentication failed: ${message}`,
120
+ response.status
121
+ );
122
+ }
123
+ throw new ASRRequestError(
124
+ ASR_PROVIDER,
125
+ `Tencent Flash ASR request failed: ${message}`,
126
+ response.status
127
+ );
128
+ }
129
+
130
+ if (payload.code !== 0) {
131
+ throw new ASRServiceError(
132
+ ASR_PROVIDER,
133
+ `Tencent Flash ASR failed: ${payload.message ?? "unknown error"} (code=${payload.code})`
134
+ ,
135
+ payload.code
136
+ );
137
+ }
138
+
139
+ const transcript = extractTranscript(payload);
140
+ if (!transcript) {
141
+ throw new ASREmptyResultError(ASR_PROVIDER);
142
+ }
143
+ return transcript;
144
+ } catch (error) {
145
+ if (error instanceof Error && error.name === "AbortError") {
146
+ throw new ASRTimeoutError(ASR_PROVIDER, timeoutMs);
147
+ }
148
+ if (
149
+ error instanceof ASRResponseParseError ||
150
+ error instanceof ASRAuthError ||
151
+ error instanceof ASRRequestError ||
152
+ error instanceof ASRServiceError ||
153
+ error instanceof ASREmptyResultError ||
154
+ error instanceof ASRTimeoutError
155
+ ) {
156
+ throw error;
157
+ }
158
+ throw new ASRRequestError(
159
+ ASR_PROVIDER,
160
+ `Tencent Flash ASR request failed: ${error instanceof Error ? error.message : String(error)}`
161
+ );
162
+ } finally {
163
+ clearTimeout(timeoutId);
164
+ }
165
+ }
@@ -1,141 +1,141 @@
1
- /**
2
- * Unit Tests for File Utilities
3
- *
4
- * Feature: dingtalk-media-receive
5
- * Validates: Requirements 5.1-5.8, 6.1-6.6
6
- */
7
-
8
- import { describe, it, expect } from "vitest";
9
- import { resolveFileCategory, resolveExtension } from "./file-utils.js";
10
-
11
- describe("resolveFileCategory", () => {
12
- // Image categorization (Requirement 5.1)
13
- it("should categorize image MIME types", () => {
14
- expect(resolveFileCategory("image/jpeg")).toBe("image");
15
- expect(resolveFileCategory("image/png")).toBe("image");
16
- expect(resolveFileCategory("image/gif")).toBe("image");
17
- expect(resolveFileCategory("image/webp")).toBe("image");
18
- expect(resolveFileCategory("image/bmp")).toBe("image");
19
- });
20
-
21
- // Audio categorization (Requirement 5.2)
22
- it("should categorize audio MIME types", () => {
23
- expect(resolveFileCategory("audio/mpeg")).toBe("audio");
24
- expect(resolveFileCategory("audio/wav")).toBe("audio");
25
- expect(resolveFileCategory("audio/ogg")).toBe("audio");
26
- expect(resolveFileCategory("audio/amr")).toBe("audio");
27
- });
28
-
29
- // Video categorization (Requirement 5.3)
30
- it("should categorize video MIME types", () => {
31
- expect(resolveFileCategory("video/mp4")).toBe("video");
32
- expect(resolveFileCategory("video/quicktime")).toBe("video");
33
- expect(resolveFileCategory("video/webm")).toBe("video");
34
- });
35
-
36
- // Document categorization (Requirement 5.4)
37
- it("should categorize document MIME types", () => {
38
- expect(resolveFileCategory("application/pdf")).toBe("document");
39
- expect(resolveFileCategory("application/msword")).toBe("document");
40
- expect(resolveFileCategory("text/plain")).toBe("document");
41
- expect(resolveFileCategory("text/markdown")).toBe("document");
42
- });
43
-
44
- // Archive categorization (Requirement 5.5)
45
- it("should categorize archive MIME types", () => {
46
- expect(resolveFileCategory("application/zip")).toBe("archive");
47
- expect(resolveFileCategory("application/x-rar-compressed")).toBe("archive");
48
- expect(resolveFileCategory("application/x-7z-compressed")).toBe("archive");
49
- });
50
-
51
- // Code categorization (Requirement 5.6)
52
- it("should categorize code MIME types", () => {
53
- expect(resolveFileCategory("application/json")).toBe("code");
54
- expect(resolveFileCategory("text/html")).toBe("code");
55
- expect(resolveFileCategory("text/css")).toBe("code");
56
- expect(resolveFileCategory("text/javascript")).toBe("code");
57
- });
58
-
59
- // Extension fallback (Requirement 5.8)
60
- it("should use extension fallback when MIME type is unknown", () => {
61
- expect(resolveFileCategory("application/octet-stream", "photo.jpg")).toBe("image");
62
- expect(resolveFileCategory("application/octet-stream", "song.mp3")).toBe("audio");
63
- expect(resolveFileCategory("application/octet-stream", "movie.mp4")).toBe("video");
64
- expect(resolveFileCategory("application/octet-stream", "doc.pdf")).toBe("document");
65
- expect(resolveFileCategory("application/octet-stream", "archive.zip")).toBe("archive");
66
- expect(resolveFileCategory("application/octet-stream", "script.py")).toBe("code");
67
- });
68
-
69
- // Other category (Requirement 5.7)
70
- it("should return 'other' for unknown types", () => {
71
- expect(resolveFileCategory("application/octet-stream")).toBe("other");
72
- expect(resolveFileCategory("application/unknown")).toBe("other");
73
- expect(resolveFileCategory("application/octet-stream", "file.xyz")).toBe("other");
74
- });
75
-
76
- // MIME type normalization
77
- it("should handle MIME types with parameters", () => {
78
- expect(resolveFileCategory("image/jpeg; charset=utf-8")).toBe("image");
79
- expect(resolveFileCategory("text/plain; charset=utf-8")).toBe("document");
80
- });
81
- });
82
-
83
- describe("resolveExtension", () => {
84
- // Image extensions (Requirement 6.1)
85
- it("should resolve image MIME types to extensions", () => {
86
- expect(resolveExtension("image/jpeg")).toBe(".jpg");
87
- expect(resolveExtension("image/png")).toBe(".png");
88
- expect(resolveExtension("image/gif")).toBe(".gif");
89
- expect(resolveExtension("image/webp")).toBe(".webp");
90
- expect(resolveExtension("image/bmp")).toBe(".bmp");
91
- });
92
-
93
- // Audio extensions (Requirement 6.2)
94
- it("should resolve audio MIME types to extensions", () => {
95
- expect(resolveExtension("audio/mpeg")).toBe(".mp3");
96
- expect(resolveExtension("audio/wav")).toBe(".wav");
97
- expect(resolveExtension("audio/ogg")).toBe(".ogg");
98
- expect(resolveExtension("audio/amr")).toBe(".amr");
99
- expect(resolveExtension("audio/x-m4a")).toBe(".m4a");
100
- });
101
-
102
- // Video extensions (Requirement 6.3)
103
- it("should resolve video MIME types to extensions", () => {
104
- expect(resolveExtension("video/mp4")).toBe(".mp4");
105
- expect(resolveExtension("video/quicktime")).toBe(".mov");
106
- expect(resolveExtension("video/x-msvideo")).toBe(".avi");
107
- expect(resolveExtension("video/webm")).toBe(".webm");
108
- });
109
-
110
- // Document extensions (Requirement 6.4)
111
- it("should resolve document MIME types to extensions", () => {
112
- expect(resolveExtension("application/pdf")).toBe(".pdf");
113
- expect(resolveExtension("application/msword")).toBe(".doc");
114
- expect(resolveExtension("application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBe(".docx");
115
- });
116
-
117
- // Default extension (Requirement 6.5)
118
- it("should return .bin for unknown MIME types", () => {
119
- expect(resolveExtension("application/unknown")).toBe(".bin");
120
- expect(resolveExtension("application/octet-stream")).toBe(".bin");
121
- });
122
-
123
- // fileName precedence (Requirement 6.6)
124
- it("should use fileName extension when provided", () => {
125
- expect(resolveExtension("application/octet-stream", "photo.jpg")).toBe(".jpg");
126
- expect(resolveExtension("image/png", "custom.jpeg")).toBe(".jpeg");
127
- expect(resolveExtension("application/unknown", "document.pdf")).toBe(".pdf");
128
- });
129
-
130
- // MIME type normalization
131
- it("should handle MIME types with parameters", () => {
132
- expect(resolveExtension("image/jpeg; charset=utf-8")).toBe(".jpg");
133
- expect(resolveExtension("audio/mpeg; bitrate=320")).toBe(".mp3");
134
- });
135
-
136
- // Edge cases
137
- it("should handle fileName without extension", () => {
138
- expect(resolveExtension("image/jpeg", "photo")).toBe(".jpg");
139
- expect(resolveExtension("application/unknown", "noext")).toBe(".bin");
140
- });
141
- });
1
+ /**
2
+ * Unit Tests for File Utilities
3
+ *
4
+ * Feature: dingtalk-media-receive
5
+ * Validates: Requirements 5.1-5.8, 6.1-6.6
6
+ */
7
+
8
+ import { describe, it, expect } from "vitest";
9
+ import { resolveFileCategory, resolveExtension } from "./file-utils.js";
10
+
11
+ describe("resolveFileCategory", () => {
12
+ // Image categorization (Requirement 5.1)
13
+ it("should categorize image MIME types", () => {
14
+ expect(resolveFileCategory("image/jpeg")).toBe("image");
15
+ expect(resolveFileCategory("image/png")).toBe("image");
16
+ expect(resolveFileCategory("image/gif")).toBe("image");
17
+ expect(resolveFileCategory("image/webp")).toBe("image");
18
+ expect(resolveFileCategory("image/bmp")).toBe("image");
19
+ });
20
+
21
+ // Audio categorization (Requirement 5.2)
22
+ it("should categorize audio MIME types", () => {
23
+ expect(resolveFileCategory("audio/mpeg")).toBe("audio");
24
+ expect(resolveFileCategory("audio/wav")).toBe("audio");
25
+ expect(resolveFileCategory("audio/ogg")).toBe("audio");
26
+ expect(resolveFileCategory("audio/amr")).toBe("audio");
27
+ });
28
+
29
+ // Video categorization (Requirement 5.3)
30
+ it("should categorize video MIME types", () => {
31
+ expect(resolveFileCategory("video/mp4")).toBe("video");
32
+ expect(resolveFileCategory("video/quicktime")).toBe("video");
33
+ expect(resolveFileCategory("video/webm")).toBe("video");
34
+ });
35
+
36
+ // Document categorization (Requirement 5.4)
37
+ it("should categorize document MIME types", () => {
38
+ expect(resolveFileCategory("application/pdf")).toBe("document");
39
+ expect(resolveFileCategory("application/msword")).toBe("document");
40
+ expect(resolveFileCategory("text/plain")).toBe("document");
41
+ expect(resolveFileCategory("text/markdown")).toBe("document");
42
+ });
43
+
44
+ // Archive categorization (Requirement 5.5)
45
+ it("should categorize archive MIME types", () => {
46
+ expect(resolveFileCategory("application/zip")).toBe("archive");
47
+ expect(resolveFileCategory("application/x-rar-compressed")).toBe("archive");
48
+ expect(resolveFileCategory("application/x-7z-compressed")).toBe("archive");
49
+ });
50
+
51
+ // Code categorization (Requirement 5.6)
52
+ it("should categorize code MIME types", () => {
53
+ expect(resolveFileCategory("application/json")).toBe("code");
54
+ expect(resolveFileCategory("text/html")).toBe("code");
55
+ expect(resolveFileCategory("text/css")).toBe("code");
56
+ expect(resolveFileCategory("text/javascript")).toBe("code");
57
+ });
58
+
59
+ // Extension fallback (Requirement 5.8)
60
+ it("should use extension fallback when MIME type is unknown", () => {
61
+ expect(resolveFileCategory("application/octet-stream", "photo.jpg")).toBe("image");
62
+ expect(resolveFileCategory("application/octet-stream", "song.mp3")).toBe("audio");
63
+ expect(resolveFileCategory("application/octet-stream", "movie.mp4")).toBe("video");
64
+ expect(resolveFileCategory("application/octet-stream", "doc.pdf")).toBe("document");
65
+ expect(resolveFileCategory("application/octet-stream", "archive.zip")).toBe("archive");
66
+ expect(resolveFileCategory("application/octet-stream", "script.py")).toBe("code");
67
+ });
68
+
69
+ // Other category (Requirement 5.7)
70
+ it("should return 'other' for unknown types", () => {
71
+ expect(resolveFileCategory("application/octet-stream")).toBe("other");
72
+ expect(resolveFileCategory("application/unknown")).toBe("other");
73
+ expect(resolveFileCategory("application/octet-stream", "file.xyz")).toBe("other");
74
+ });
75
+
76
+ // MIME type normalization
77
+ it("should handle MIME types with parameters", () => {
78
+ expect(resolveFileCategory("image/jpeg; charset=utf-8")).toBe("image");
79
+ expect(resolveFileCategory("text/plain; charset=utf-8")).toBe("document");
80
+ });
81
+ });
82
+
83
+ describe("resolveExtension", () => {
84
+ // Image extensions (Requirement 6.1)
85
+ it("should resolve image MIME types to extensions", () => {
86
+ expect(resolveExtension("image/jpeg")).toBe(".jpg");
87
+ expect(resolveExtension("image/png")).toBe(".png");
88
+ expect(resolveExtension("image/gif")).toBe(".gif");
89
+ expect(resolveExtension("image/webp")).toBe(".webp");
90
+ expect(resolveExtension("image/bmp")).toBe(".bmp");
91
+ });
92
+
93
+ // Audio extensions (Requirement 6.2)
94
+ it("should resolve audio MIME types to extensions", () => {
95
+ expect(resolveExtension("audio/mpeg")).toBe(".mp3");
96
+ expect(resolveExtension("audio/wav")).toBe(".wav");
97
+ expect(resolveExtension("audio/ogg")).toBe(".ogg");
98
+ expect(resolveExtension("audio/amr")).toBe(".amr");
99
+ expect(resolveExtension("audio/x-m4a")).toBe(".m4a");
100
+ });
101
+
102
+ // Video extensions (Requirement 6.3)
103
+ it("should resolve video MIME types to extensions", () => {
104
+ expect(resolveExtension("video/mp4")).toBe(".mp4");
105
+ expect(resolveExtension("video/quicktime")).toBe(".mov");
106
+ expect(resolveExtension("video/x-msvideo")).toBe(".avi");
107
+ expect(resolveExtension("video/webm")).toBe(".webm");
108
+ });
109
+
110
+ // Document extensions (Requirement 6.4)
111
+ it("should resolve document MIME types to extensions", () => {
112
+ expect(resolveExtension("application/pdf")).toBe(".pdf");
113
+ expect(resolveExtension("application/msword")).toBe(".doc");
114
+ expect(resolveExtension("application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBe(".docx");
115
+ });
116
+
117
+ // Default extension (Requirement 6.5)
118
+ it("should return .bin for unknown MIME types", () => {
119
+ expect(resolveExtension("application/unknown")).toBe(".bin");
120
+ expect(resolveExtension("application/octet-stream")).toBe(".bin");
121
+ });
122
+
123
+ // fileName precedence (Requirement 6.6)
124
+ it("should use fileName extension when provided", () => {
125
+ expect(resolveExtension("application/octet-stream", "photo.jpg")).toBe(".jpg");
126
+ expect(resolveExtension("image/png", "custom.jpeg")).toBe(".jpeg");
127
+ expect(resolveExtension("application/unknown", "document.pdf")).toBe(".pdf");
128
+ });
129
+
130
+ // MIME type normalization
131
+ it("should handle MIME types with parameters", () => {
132
+ expect(resolveExtension("image/jpeg; charset=utf-8")).toBe(".jpg");
133
+ expect(resolveExtension("audio/mpeg; bitrate=320")).toBe(".mp3");
134
+ });
135
+
136
+ // Edge cases
137
+ it("should handle fileName without extension", () => {
138
+ expect(resolveExtension("image/jpeg", "photo")).toBe(".jpg");
139
+ expect(resolveExtension("application/unknown", "noext")).toBe(".bin");
140
+ });
141
+ });