@openclaw-china/shared 2026.3.22 → 2026.4.23

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": "2026.3.22",
3
+ "version": "2026.4.23",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -51,7 +51,14 @@ type ConfigRoot = {
51
51
  [key: string]: unknown;
52
52
  };
53
53
 
54
- export type ChannelId = "dingtalk" | "feishu-china" | "wecom" | "wecom-app" | "wecom-kf" | "qqbot" | "wechat-mp";
54
+ export type ChannelId =
55
+ | "dingtalk"
56
+ | "feishu-china"
57
+ | "wecom"
58
+ | "wecom-app"
59
+ | "wecom-kf"
60
+ | "qqbot-china"
61
+ | "wechat-mp";
55
62
 
56
63
  export type RegisterChinaSetupCliOptions = {
57
64
  channels?: readonly ChannelId[];
@@ -68,37 +75,38 @@ const GUIDES_BASE = "https://github.com/BytePioneer-AI/openclaw-china/tree/main/
68
75
  const OPENCLAW_HOME = join(homedir(), ".openclaw");
69
76
  const DEFAULT_PLUGIN_PATH = join(OPENCLAW_HOME, "extensions");
70
77
  const LEGACY_PLUGIN_PATH = join(OPENCLAW_HOME, "plugins");
71
- const CONFIG_FILE_PATH = join(OPENCLAW_HOME, "openclaw.json");
72
- const ANSI_RESET = "\u001b[0m";
73
- const ANSI_LINK = "\u001b[1;4;96m";
74
- const ANSI_BORDER = "\u001b[92m";
75
- const CHANNEL_ORDER: readonly ChannelId[] = [
76
- "dingtalk",
77
- "qqbot",
78
- "wecom",
79
- "wecom-app",
80
- "wecom-kf",
78
+ const CONFIG_FILE_PATH = join(OPENCLAW_HOME, "openclaw.json");
79
+ const ANSI_RESET = "\u001b[0m";
80
+ const ANSI_LINK = "\u001b[1;4;96m";
81
+ const ANSI_BORDER = "\u001b[92m";
82
+ const QQBOT_CHANNEL_ID = "qqbot-china" as const;
83
+ const CHANNEL_ORDER: readonly ChannelId[] = [
84
+ "dingtalk",
85
+ QQBOT_CHANNEL_ID,
86
+ "wecom",
87
+ "wecom-app",
88
+ "wecom-kf",
81
89
  "wechat-mp",
82
90
  "feishu-china",
83
91
  ];
84
92
  const CHANNEL_DISPLAY_LABELS: Record<ChannelId, string> = {
85
93
  dingtalk: "DingTalk(钉钉)",
86
- "feishu-china": "Feishu(飞书)",
87
- wecom: "WeCom(企业微信-智能机器人)",
88
- "wecom-app": "WeCom App(自建应用-可接入微信)",
89
- "wecom-kf": "WeCom KF(微信客服)",
90
- "wechat-mp": "WeChat MP(微信公众号)",
91
- qqbot: "QQBot(QQ 机器人)",
92
- };
93
- const CHANNEL_GUIDE_LINKS: Record<ChannelId, string> = {
94
+ "feishu-china": "Feishu(飞书)",
95
+ wecom: "WeCom(企业微信-智能机器人)",
96
+ "wecom-app": "WeCom App(自建应用-可接入微信)",
97
+ "wecom-kf": "WeCom KF(微信客服)",
98
+ "wechat-mp": "WeChat MP(微信公众号)",
99
+ "qqbot-china": "QQBot(QQ 机器人)",
100
+ };
101
+ const CHANNEL_GUIDE_LINKS: Record<ChannelId, string> = {
94
102
  dingtalk: `${GUIDES_BASE}/dingtalk/configuration.md`,
95
103
  "feishu-china": "https://github.com/BytePioneer-AI/openclaw-china/blob/main/README.md",
96
104
  wecom: `${GUIDES_BASE}/wecom/configuration.md`,
97
- "wecom-app": `${GUIDES_BASE}/wecom-app/configuration.md`,
98
- "wecom-kf": "https://github.com/BytePioneer-AI/openclaw-china/blob/main/extensions/wecom-kf/README.md",
99
- "wechat-mp": `${GUIDES_BASE}/wechat-mp/configuration.md`,
100
- qqbot: `${GUIDES_BASE}/qqbot/configuration.md`,
101
- };
105
+ "wecom-app": `${GUIDES_BASE}/wecom-app/configuration.md`,
106
+ "wecom-kf": "https://github.com/BytePioneer-AI/openclaw-china/blob/main/extensions/wecom-kf/README.md",
107
+ "wechat-mp": `${GUIDES_BASE}/wechat-mp/configuration.md`,
108
+ "qqbot-china": `${GUIDES_BASE}/qqbot/configuration.md`,
109
+ };
102
110
  const CHINA_CLI_STATE_KEY = Symbol.for("@openclaw-china/china-cli-state");
103
111
 
104
112
  type ChinaCliState = {
@@ -272,11 +280,11 @@ function cloneConfig(cfg: ConfigRoot): ConfigRoot {
272
280
  }
273
281
  }
274
282
 
275
- function getChannelConfig(cfg: ConfigRoot, channelId: ChannelId): ConfigRecord {
276
- const channels = isRecord(cfg.channels) ? cfg.channels : {};
277
- const existing = channels[channelId];
278
- return isRecord(existing) ? existing : {};
279
- }
283
+ function getChannelConfig(cfg: ConfigRoot, channelId: ChannelId): ConfigRecord {
284
+ const channels = isRecord(cfg.channels) ? cfg.channels : {};
285
+ const existing = channels[channelId];
286
+ return isRecord(existing) ? existing : {};
287
+ }
280
288
 
281
289
  function getGatewayAuthToken(cfg: ConfigRoot): string | undefined {
282
290
  if (!isRecord(cfg.gateway)) {
@@ -328,15 +336,15 @@ function hasWecomWsCredentialPair(channelCfg: ConfigRecord): boolean {
328
336
  return hasCredentialPair(channelCfg, "botId", "secret");
329
337
  }
330
338
 
331
- function isChannelConfigured(cfg: ConfigRoot, channelId: ChannelId): boolean {
332
- const channelCfg = getChannelConfig(cfg, channelId);
333
- switch (channelId) {
339
+ function isChannelConfigured(cfg: ConfigRoot, channelId: ChannelId): boolean {
340
+ const channelCfg = getChannelConfig(cfg, channelId);
341
+ switch (channelId) {
334
342
  case "dingtalk":
335
343
  return hasNonEmptyString(channelCfg.clientId) && hasNonEmptyString(channelCfg.clientSecret);
336
344
  case "feishu-china":
337
345
  return hasNonEmptyString(channelCfg.appId) && hasNonEmptyString(channelCfg.appSecret);
338
- case "qqbot":
339
- return hasNonEmptyString(channelCfg.appId) && hasNonEmptyString(channelCfg.clientSecret);
346
+ case "qqbot-china":
347
+ return hasNonEmptyString(channelCfg.appId) && hasNonEmptyString(channelCfg.clientSecret);
340
348
  case "wecom":
341
349
  return hasWecomWsCredentialPair(channelCfg);
342
350
  case "wecom-app":
@@ -359,21 +367,22 @@ function withConfiguredSuffix(cfg: ConfigRoot, channelId: ChannelId): string {
359
367
  return isChannelConfigured(cfg, channelId) ? `${base}(已配置)` : base;
360
368
  }
361
369
 
362
- function mergeChannelConfig(
363
- cfg: ConfigRoot,
364
- channelId: ChannelId,
365
- patch: ConfigRecord
366
- ): ConfigRoot {
367
- const channels = isRecord(cfg.channels) ? { ...cfg.channels } : {};
368
- const existing = getChannelConfig(cfg, channelId);
369
- channels[channelId] = {
370
- ...existing,
371
- ...patch,
372
- enabled: true,
373
- };
374
- return {
375
- ...cfg,
376
- channels,
370
+ function mergeChannelConfig(
371
+ cfg: ConfigRoot,
372
+ channelId: ChannelId,
373
+ patch: ConfigRecord
374
+ ): ConfigRoot {
375
+ const channels = isRecord(cfg.channels) ? { ...cfg.channels } : {};
376
+ const existing = getChannelConfig(cfg, channelId);
377
+ const nextChannelConfig = {
378
+ ...existing,
379
+ ...patch,
380
+ enabled: true,
381
+ };
382
+ channels[channelId] = nextChannelConfig;
383
+ return {
384
+ ...cfg,
385
+ channels,
377
386
  };
378
387
  }
379
388
 
@@ -795,10 +804,10 @@ async function configureWechatMp(prompter: SetupPrompter, cfg: ConfigRoot): Prom
795
804
  });
796
805
  }
797
806
 
798
- async function configureQQBot(prompter: SetupPrompter, cfg: ConfigRoot): Promise<ConfigRoot> {
799
- section("配置 QQBot(QQ 机器人)");
800
- showGuideLink("qqbot");
801
- const existing = getChannelConfig(cfg, "qqbot");
807
+ async function configureQQBot(prompter: SetupPrompter, cfg: ConfigRoot): Promise<ConfigRoot> {
808
+ section("配置 QQBot(QQ 机器人)");
809
+ showGuideLink(QQBOT_CHANNEL_ID);
810
+ const existing = getChannelConfig(cfg, QQBOT_CHANNEL_ID);
802
811
  const existingAsr = isRecord(existing.asr) ? existing.asr : {};
803
812
 
804
813
  const appId = await prompter.askText({
@@ -838,10 +847,10 @@ async function configureQQBot(prompter: SetupPrompter, cfg: ConfigRoot): Promise
838
847
  });
839
848
  }
840
849
 
841
- return mergeChannelConfig(cfg, "qqbot", {
842
- appId,
843
- clientSecret,
844
- asr,
850
+ return mergeChannelConfig(cfg, QQBOT_CHANNEL_ID, {
851
+ appId,
852
+ clientSecret,
853
+ asr,
845
854
  });
846
855
  }
847
856
 
@@ -863,8 +872,8 @@ async function configureSingleChannel(
863
872
  return configureWecomKf(prompter, cfg);
864
873
  case "wechat-mp":
865
874
  return configureWechatMp(prompter, cfg);
866
- case "qqbot":
867
- return configureQQBot(prompter, cfg);
875
+ case "qqbot-china":
876
+ return configureQQBot(prompter, cfg);
868
877
  default:
869
878
  return cfg;
870
879
  }
package/src/cli/index.ts CHANGED
@@ -1,2 +1,2 @@
1
- export * from "./china-setup.js";
2
- export * from "./install-hint.js";
1
+ export * from "./china-setup.js";
2
+ export * from "./install-hint.js";
@@ -18,16 +18,16 @@ const ANSI_RESET = "\u001b[0m";
18
18
  const ANSI_BOLD = "\u001b[1m";
19
19
  const ANSI_LINK = "\u001b[1;4;96m";
20
20
  const ANSI_BORDER = "\u001b[92m";
21
- const SUPPORTED_CHANNELS: readonly ChannelId[] = [
22
- "dingtalk",
23
- "feishu-china",
24
- "wecom",
25
- "wecom-app",
26
- "wecom-kf",
27
- "wechat-mp",
28
- "qqbot",
29
- ];
30
- const CHINA_INSTALL_HINT_SHOWN_KEY = Symbol.for("@openclaw-china/china-install-hint-shown");
21
+ const SUPPORTED_CHANNELS: readonly ChannelId[] = [
22
+ "dingtalk",
23
+ "feishu-china",
24
+ "wecom",
25
+ "wecom-app",
26
+ "wecom-kf",
27
+ "wechat-mp",
28
+ "qqbot-china",
29
+ ];
30
+ const CHINA_INSTALL_HINT_SHOWN_KEY = Symbol.for("@openclaw-china/china-install-hint-shown");
31
31
 
32
32
  function isRecord(value: unknown): value is Record<string, unknown> {
33
33
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -42,12 +42,12 @@ function hasAnyEnabledChinaChannel(config: unknown): boolean {
42
42
  if (!isRecord(channels)) {
43
43
  return false;
44
44
  }
45
-
46
- return SUPPORTED_CHANNELS.some((channelId) => {
47
- const channelConfig = channels[channelId];
48
- return isRecord(channelConfig) && channelConfig.enabled === true;
49
- });
50
- }
45
+
46
+ return SUPPORTED_CHANNELS.some((channelId) => {
47
+ const channelConfig = channels[channelId];
48
+ return isRecord(channelConfig) && channelConfig.enabled === true;
49
+ });
50
+ }
51
51
 
52
52
  function hasShownInstallHint(): boolean {
53
53
  const root = globalThis as Record<PropertyKey, unknown>;
package/src/cron/index.ts CHANGED
@@ -1,21 +1,21 @@
1
- const CRON_HIDDEN_PROMPT = `
2
- When creating or updating a cron task, always store a fixed delivery target in the job itself.
3
- - Use the built-in cron tool (action=add/update). Do not run shell commands.
4
- - Must use sessionTarget="isolated" for reminder jobs.
5
- - payload.kind="agentTurn"
6
- - payload.message must be plain user-visible reminder text only.
7
- - You must encode runtime guardrails directly into payload.message so the cron run can follow them without extra context.
8
- - Runtime guardrails to encode in payload.message:
9
- - return plain text only
10
- - never call any tool
11
- - never call the message tool
12
- - never send manually; delivery is handled by cron delivery settings
13
- - Do not include tool directives, "NO_REPLY", or heartbeat markers in payload.message.
14
- - Job name is never a message target.
15
- - During cron run, must return plain text only and never call the message tool.
1
+ const CRON_HIDDEN_PROMPT = `
2
+ When creating or updating a cron task, always store a fixed delivery target in the job itself.
3
+ - Use the built-in cron tool (action=add/update). Do not run shell commands.
4
+ - Must use sessionTarget="isolated" for reminder jobs.
5
+ - payload.kind="agentTurn"
6
+ - payload.message must be plain user-visible reminder text only.
7
+ - You must encode runtime guardrails directly into payload.message so the cron run can follow them without extra context.
8
+ - Runtime guardrails to encode in payload.message:
9
+ - return plain text only
10
+ - never call any tool
11
+ - never call the message tool
12
+ - never send manually; delivery is handled by cron delivery settings
13
+ - Do not include tool directives, "NO_REPLY", or heartbeat markers in payload.message.
14
+ - Job name is never a message target.
15
+ - During cron run, must return plain text only and never call the message tool.
16
16
  - Use top-level delivery with announce mode:
17
- delivery.mode="announce"
18
- delivery.channel=<OriginatingChannel> (example: "qqbot")
17
+ delivery.mode="announce"
18
+ delivery.channel=<OriginatingChannel> (example: "qqbot")
19
19
  delivery.to=<OriginatingTo> (examples: "user:<openid>" / "group:<group_openid>")
20
20
  delivery.accountId=<AccountId> when available
21
21
  - Never set delivery.channel="last" for multi-channel environments.
@@ -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
+ });