@zzclub/pipeline 0.9.0 → 0.10.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.
@@ -21,7 +21,14 @@
21
21
 
22
22
  import { parseArgs, requireArg } from "../args";
23
23
  import { printResult, renderReset } from "../output";
24
- import { readState, writeState, defaultContentReview } from "../state";
24
+ import {
25
+ defaultContentReview,
26
+ readResolvedState,
27
+ reenterPrepare,
28
+ reenterPublish,
29
+ reenterRender,
30
+ writeState,
31
+ } from "../state";
25
32
 
26
33
  type ResetMode =
27
34
  | "content"
@@ -68,7 +75,7 @@ Modes:
68
75
  return;
69
76
  }
70
77
 
71
- const statePath = requireArg(parsed, "state", "state JSON path");
78
+ const requestedStatePath = requireArg(parsed, "state", "state JSON path");
72
79
  const mode = requireArg(parsed, "mode", "reset mode") as ResetMode;
73
80
 
74
81
  if (!VALID_MODES.includes(mode)) {
@@ -77,7 +84,9 @@ Modes:
77
84
  );
78
85
  }
79
86
 
80
- const state = await readState(statePath);
87
+ const resolved = await readResolvedState(requestedStatePath);
88
+ const statePath = resolved.path;
89
+ const state = resolved.state;
81
90
 
82
91
  switch (mode) {
83
92
  case "content":
@@ -85,14 +94,10 @@ Modes:
85
94
  case "redo.format":
86
95
  case "redo.metadata":
87
96
  case "redo.route":
88
- // Reset prepare + render + publish
89
- state.phase.prepare = { status: "pending", error: null };
90
- state.phase.render = { status: "pending", error: null };
91
- state.phase.publish = { status: "pending", error: null };
92
- state.phase.current = "prepare";
93
- state.mode = "active";
94
- // Persist redo hint so orchestrator can recover start step even after context loss
95
- state.redo_hint =
97
+ reenterPrepare(state, {
98
+ clearFormattedBody: mode === "content",
99
+ resetReview: mode === "content" || mode === "redo.style",
100
+ redoHint:
96
101
  mode === "redo.style"
97
102
  ? "style"
98
103
  : mode === "redo.format"
@@ -101,33 +106,21 @@ Modes:
101
106
  ? "asset-meta"
102
107
  : mode === "redo.route"
103
108
  ? "channel-route"
104
- : "writer"; // content
105
- // Reset content_review for content rewrite (writer will produce new body)
106
- if (mode === "content") {
107
- state.content_review = defaultContentReview();
108
- }
109
+ : "writer",
110
+ });
109
111
  break;
110
112
 
111
113
  case "render":
112
- // Reset render + publish, keep prepare done
113
- state.phase.render = { status: "pending", error: null };
114
- state.phase.publish = { status: "pending", error: null };
115
- state.phase.current = "render";
116
- state.mode = "active";
117
- state.redo_hint = null;
118
- // Reset image plan status
119
- if (state.images.plan.needed) {
120
- state.images.plan.status = "planned";
121
- }
114
+ reenterRender(state);
122
115
  break;
123
116
 
124
117
  case "publish":
125
- // Reset publish only
126
- state.phase.publish = { status: "pending", error: null };
127
- state.phase.current = "publish";
128
- state.mode = "active";
129
- state.redo_hint = null;
130
- // Don't change content_version or render_version
118
+ reenterPublish(state);
119
+ state.publish.results = state.publish.results.filter(
120
+ (result) =>
121
+ result.content_version !== state.artifacts.content_version ||
122
+ result.render_version !== state.artifacts.render_version,
123
+ );
131
124
  break;
132
125
 
133
126
  case "full":
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { parseArgs, requireArg, optionalArg } from "../args";
19
19
  import { printResult, renderReview } from "../output";
20
- import { readState, writeState, type ContentReviewStatus } from "../state";
20
+ import { readResolvedState, writeState, type ContentReviewStatus } from "../state";
21
21
 
22
22
  const VALID_STATUSES: ContentReviewStatus[] = [
23
23
  "passed",
@@ -43,7 +43,7 @@ Statuses:
43
43
  return;
44
44
  }
45
45
 
46
- const statePath = requireArg(parsed, "state", "state JSON path");
46
+ const requestedStatePath = requireArg(parsed, "state", "state JSON path");
47
47
  const status = requireArg(parsed, "status", "review status") as ContentReviewStatus;
48
48
  const feedback = optionalArg(parsed, "feedback") ?? null;
49
49
 
@@ -59,7 +59,9 @@ Statuses:
59
59
  );
60
60
  }
61
61
 
62
- const state = await readState(statePath);
62
+ const resolved = await readResolvedState(requestedStatePath);
63
+ const statePath = resolved.path;
64
+ const state = resolved.state;
63
65
 
64
66
  state.content_review = {
65
67
  status,
@@ -1,7 +1,11 @@
1
1
  import { parseArgs, requireArg, flagArg } from "../args";
2
2
  import { printResult, renderSyncBlog } from "../output";
3
3
  import { loadConfig, resolveWorkspacePaths } from "../config";
4
- import { readState, updateState } from "../state";
4
+ import {
5
+ acquireStateOperationLock,
6
+ readResolvedState,
7
+ updateState,
8
+ } from "../state";
5
9
  import { publishBlogRoute } from "../providers/blog";
6
10
 
7
11
  export async function syncBlog(args: string[]): Promise<void> {
@@ -18,13 +22,18 @@ Options:
18
22
  return;
19
23
  }
20
24
 
21
- const statePath = requireArg(parsed, "state", "state JSON path");
25
+ const requestedStatePath = requireArg(parsed, "state", "state JSON path");
22
26
  const dryRun = flagArg(parsed, "dry-run");
23
27
 
24
28
  const config = loadConfig();
25
29
 
26
30
  // Run the actual publish (may have network side effects)
27
- const state = await readState(statePath);
31
+ const initialResolved = await readResolvedState(requestedStatePath);
32
+ const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
33
+ try {
34
+ const resolved = await readResolvedState(initialResolved.path);
35
+ const statePath = resolved.path;
36
+ const state = resolved.state;
28
37
 
29
38
  if (!state.asset_path) {
30
39
  throw new Error("asset_path not set. Run prepare-finalize first.");
@@ -38,6 +47,11 @@ Options:
38
47
  workspacePaths,
39
48
  });
40
49
 
50
+ if (dryRun) {
51
+ printResult({ ...result, mode: state.mode }, renderSyncBlog);
52
+ return;
53
+ }
54
+
41
55
  // Write result back to state
42
56
  const finalState = await updateState(statePath, (s) => {
43
57
  const idx = s.publish.results.findIndex((r) => r.route === "blog");
@@ -52,4 +66,7 @@ Options:
52
66
  ...result,
53
67
  mode: finalState.mode,
54
68
  }, renderSyncBlog);
69
+ } finally {
70
+ await releaseOperationLock();
71
+ }
55
72
  }
package/src/config.ts CHANGED
Binary file
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { join } from "path";
3
- import { extractLocalImagePaths, replaceLocalImagePaths } from "./blog";
3
+ import {
4
+ extractLocalImagePaths,
5
+ replaceLocalImagePaths,
6
+ resolveBlogPostPath,
7
+ } from "./blog";
4
8
 
5
9
  describe("extractLocalImagePaths", () => {
6
10
  test("extracts local image paths from markdown", () => {
@@ -17,6 +21,22 @@ describe("extractLocalImagePaths", () => {
17
21
  expect(pathMap.has("./local.png")).toBe(true);
18
22
  });
19
23
 
24
+ test("skips non-file URI schemes", () => {
25
+ const content = "![inline](data:image/png;base64,abc)\n![blob](blob:https://example.com/id)";
26
+ expect(extractLocalImagePaths(content, "/workspace").size).toBe(0);
27
+ });
28
+
29
+ test("rejects paths that escape the canonical asset directory", () => {
30
+ expect(() => extractLocalImagePaths("![secret](../secret.png)", "/workspace/post"))
31
+ .toThrow("escapes asset directory");
32
+ });
33
+
34
+ test("handles angle-bracket paths and markdown titles", () => {
35
+ const content = '![cover](<images/my cover.png> "Cover")';
36
+ const pathMap = extractLocalImagePaths(content, "/workspace");
37
+ expect(pathMap.get("images/my cover.png")).toBe("/workspace/images/my cover.png");
38
+ });
39
+
20
40
  test("returns empty map for no images", () => {
21
41
  expect(extractLocalImagePaths("just text", "/workspace").size).toBe(0);
22
42
  });
@@ -56,3 +76,19 @@ describe("replaceLocalImagePaths", () => {
56
76
  expect(result).toBe("![my alt text](https://cdn.com/img.png)");
57
77
  });
58
78
  });
79
+
80
+ describe("resolveBlogPostPath", () => {
81
+ test("builds the canonical dated blog path", () => {
82
+ expect(resolveBlogPostPath("/blog", "2026-07-12", "safe-slug")).toEqual({
83
+ directory: join("/blog", "content", "nezus", "2026", "07"),
84
+ path: join("/blog", "content", "nezus", "2026", "07", "safe-slug.md"),
85
+ });
86
+ });
87
+
88
+ test("rejects traversal in date or slug fields", () => {
89
+ expect(() => resolveBlogPostPath("/blog", "../../tmp", "safe"))
90
+ .toThrow("Invalid blog publish date");
91
+ expect(() => resolveBlogPostPath("/blog", "2026-07-12", "../../secret"))
92
+ .toThrow("Invalid blog publish slug");
93
+ });
94
+ });
@@ -1,5 +1,5 @@
1
1
  import { mkdir, readFile, writeFile } from "fs/promises";
2
- import { join, resolve } from "path";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "path";
3
3
  import { PipelineConfig, ResolvedWorkspacePaths } from "../config";
4
4
  import { spawnSync } from "../spawn";
5
5
  import { PublishResult, WorkflowState } from "../state";
@@ -13,25 +13,63 @@ interface BlogPublishContext {
13
13
  workspacePaths: ResolvedWorkspacePaths;
14
14
  }
15
15
 
16
- // ![alt](path) — skips http(s):// URLs, captures [1]=alt [2]=rawPath
17
- const LOCAL_IMAGE_RE = /!\[([^\]]*)\]\((?!https?:\/\/)([^)]+)\)/g;
16
+ const LOCAL_IMAGE_RE = /!\[([^\]]*)\]\((?![a-zA-Z][a-zA-Z0-9+.-]*:|\/\/)(?:<([^>]+)>|([^\s)]+))(?:\s+"[^"]*")?\)/g;
18
17
 
19
18
  export function extractLocalImagePaths(content: string, assetPath: string): Map<string, string> {
20
19
  const pathMap = new Map<string, string>();
21
20
  for (const match of content.matchAll(LOCAL_IMAGE_RE)) {
22
- const rawPath = match[2].trim();
23
- pathMap.set(rawPath, resolve(assetPath, rawPath));
21
+ const rawPath = (match[2] ?? match[3] ?? "").trim();
22
+ const assetRoot = resolve(assetPath);
23
+ const absolutePath = resolve(assetRoot, rawPath);
24
+ const relativePath = relative(assetRoot, absolutePath);
25
+ if (
26
+ relativePath === ".." ||
27
+ relativePath.startsWith(`..${/\\/.test(relativePath) ? "\\" : "/"}`) ||
28
+ isAbsolute(relativePath)
29
+ ) {
30
+ throw new Error(`Local blog image escapes asset directory: ${rawPath}`);
31
+ }
32
+ pathMap.set(rawPath, absolutePath);
24
33
  }
25
34
  return pathMap;
26
35
  }
27
36
 
28
37
  export function replaceLocalImagePaths(content: string, urlMap: Map<string, string>): string {
29
- return content.replace(LOCAL_IMAGE_RE, (original, alt: string, rawPath: string) => {
30
- const url = urlMap.get(rawPath.trim());
38
+ return content.replace(LOCAL_IMAGE_RE, (original, alt: string, anglePath: string, barePath: string) => {
39
+ const rawPath = (anglePath ?? barePath ?? "").trim();
40
+ const url = urlMap.get(rawPath);
31
41
  return url ? `![${alt}](${url})` : original;
32
42
  });
33
43
  }
34
44
 
45
+ export function resolveBlogPostPath(
46
+ blogRoot: string,
47
+ date: string,
48
+ slug: string,
49
+ ): { directory: string; path: string } {
50
+ const dateMatch = date.match(/^(\d{4})-(\d{2})-\d{2}$/);
51
+ if (!dateMatch) {
52
+ throw new Error(`Invalid blog publish date: ${date}`);
53
+ }
54
+ if (!slug || /[\\/]/.test(slug) || slug === "." || slug === "..") {
55
+ throw new Error(`Invalid blog publish slug: ${slug}`);
56
+ }
57
+
58
+ const contentRoot = resolve(blogRoot, "content", "nezus");
59
+ const directory = resolve(contentRoot, dateMatch[1], dateMatch[2]);
60
+ const path = resolve(directory, `${slug}.md`);
61
+ const relativePath = relative(contentRoot, path);
62
+ if (
63
+ dirname(path) !== directory ||
64
+ relativePath === ".." ||
65
+ relativePath.startsWith(`..${/\\/.test(relativePath) ? "\\" : "/"}`) ||
66
+ isAbsolute(relativePath)
67
+ ) {
68
+ throw new Error(`Blog post path escapes content directory: ${date}/${slug}`);
69
+ }
70
+ return { directory, path };
71
+ }
72
+
35
73
  async function uploadBlogImages(
36
74
  content: string,
37
75
  assetPath: string,
@@ -45,7 +83,7 @@ async function uploadBlogImages(
45
83
  }
46
84
 
47
85
  const cosPat = config.cos.pat;
48
- if (!cosPat) {
86
+ if (!cosPat && !dryRun) {
49
87
  throw new Error("No COS PAT configured. Set cos.pat in config.");
50
88
  }
51
89
 
@@ -84,12 +122,12 @@ export async function publishBlogRoute({
84
122
  }: BlogPublishContext): Promise<PublishResult> {
85
123
  const postPath = join(state.asset_path, "post.md");
86
124
  const postContent = await readFile(postPath, "utf-8");
87
- const category = "nezus";
88
125
  const blogRoot = workspacePaths.blogRoot;
89
-
90
- const [year, month] = state.metadata.date.split("-");
91
- const blogPostDir = join(blogRoot, "content", category, year, month);
92
- const blogPostPath = join(blogPostDir, `${state.metadata.slug}.md`);
126
+ const { directory: blogPostDir, path: blogPostPath } = resolveBlogPostPath(
127
+ blogRoot,
128
+ state.metadata.date,
129
+ state.metadata.slug,
130
+ );
93
131
 
94
132
  const lines = ["---"];
95
133
  lines.push(`title: "${state.metadata.title.replace(/"/g, '\\"')}"`);
@@ -90,28 +90,32 @@ async function requestSts(
90
90
  )) as StsApiResponse;
91
91
 
92
92
  const d = response?.data;
93
- if (
94
- !d?.TmpSecretId ||
95
- !d?.TmpSecretKey ||
96
- !d?.SessionToken ||
97
- !d?.Bucket ||
98
- !d?.Region ||
99
- !d?.Key ||
100
- d.StartTime === undefined ||
101
- d.ExpiredTime === undefined
102
- ) {
103
- throw new Error(`COS STS response incomplete: ${JSON.stringify(response)}`);
93
+ const missing = [
94
+ ["TmpSecretId", d?.TmpSecretId],
95
+ ["TmpSecretKey", d?.TmpSecretKey],
96
+ ["SessionToken", d?.SessionToken],
97
+ ["Bucket", d?.Bucket],
98
+ ["Region", d?.Region],
99
+ ["Key", d?.Key],
100
+ ["StartTime", d?.StartTime],
101
+ ["ExpiredTime", d?.ExpiredTime],
102
+ ].filter(([, value]) => value === undefined || value === null || value === "");
103
+ if (missing.length > 0) {
104
+ throw new Error(
105
+ `COS STS response incomplete; missing: ${missing.map(([field]) => field).join(", ")}`,
106
+ );
104
107
  }
108
+ const data = d as Required<NonNullable<StsApiResponse["data"]>>;
105
109
 
106
110
  return {
107
- TmpSecretId: d.TmpSecretId,
108
- TmpSecretKey: d.TmpSecretKey,
109
- SessionToken: d.SessionToken,
110
- StartTime: d.StartTime,
111
- ExpiredTime: d.ExpiredTime,
112
- Bucket: d.Bucket,
113
- Region: d.Region,
114
- Key: d.Key,
111
+ TmpSecretId: data.TmpSecretId,
112
+ TmpSecretKey: data.TmpSecretKey,
113
+ SessionToken: data.SessionToken,
114
+ StartTime: data.StartTime,
115
+ ExpiredTime: data.ExpiredTime,
116
+ Bucket: data.Bucket,
117
+ Region: data.Region,
118
+ Key: data.Key,
115
119
  };
116
120
  }
117
121
 
@@ -47,7 +47,9 @@ async function publishWechatArticleRoute({
47
47
  state.images.body_inputs.received,
48
48
  );
49
49
  exportPostPath = join(state.asset_path, "post-wechat-draft.md");
50
- await writeFile(exportPostPath, draftContent, "utf-8");
50
+ if (!dryRun) {
51
+ await writeFile(exportPostPath, draftContent, "utf-8");
52
+ }
51
53
 
52
54
  for (const img of state.images.body_inputs.received) {
53
55
  photos.push(img.path);
@@ -81,7 +83,7 @@ async function publishWechatArticleRoute({
81
83
  customCss: wxAccount?.customCss ?? null,
82
84
  themeOverrides: wxAccount?.theme,
83
85
  });
84
- await createWechatDraft({
86
+ const response = await createWechatDraft({
85
87
  account,
86
88
  title: state.metadata.title,
87
89
  html,
@@ -92,6 +94,16 @@ async function publishWechatArticleRoute({
92
94
  nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
93
95
  nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
94
96
  });
97
+ return {
98
+ route: "wechat-article",
99
+ account,
100
+ status: "success",
101
+ detail: null,
102
+ external_id: typeof response.draftMediaId === "string" ? response.draftMediaId : null,
103
+ published_at: new Date().toISOString(),
104
+ content_version: state.artifacts.content_version,
105
+ render_version: state.artifacts.render_version,
106
+ };
95
107
  } catch (error) {
96
108
  return {
97
109
  route: "wechat-article",
@@ -104,15 +116,6 @@ async function publishWechatArticleRoute({
104
116
  };
105
117
  }
106
118
 
107
- return {
108
- route: "wechat-article",
109
- account,
110
- status: "success",
111
- detail: null,
112
- published_at: new Date().toISOString(),
113
- content_version: state.artifacts.content_version,
114
- render_version: state.artifacts.render_version,
115
- };
116
119
  }
117
120
 
118
121
  async function publishWechatNewspicRoute({
@@ -126,7 +129,9 @@ async function publishWechatNewspicRoute({
126
129
  const bodyImageUrls = extractImageUrls(postContent);
127
130
  const cleanContent = prepareBodyForNewspic(postContent);
128
131
  const cleanPath = join(state.asset_path, "post-clean.md");
129
- await writeFile(cleanPath, cleanContent, "utf-8");
132
+ if (!dryRun) {
133
+ await writeFile(cleanPath, cleanContent, "utf-8");
134
+ }
130
135
 
131
136
  const assets = (state.images.render_assets as RenderAsset[])
132
137
  .filter((asset) => asset.route === "wechat-newspic")
@@ -156,7 +161,7 @@ async function publishWechatNewspicRoute({
156
161
  }
157
162
 
158
163
  try {
159
- await createWechatNewspic({
164
+ const response = await createWechatNewspic({
160
165
  account,
161
166
  title: state.metadata.title,
162
167
  content: cleanContent,
@@ -167,6 +172,16 @@ async function publishWechatNewspicRoute({
167
172
  nezusBaseUrl: process.env.ZZHUB_WX_BASE_URL || config.wx.baseUrl,
168
173
  nezusPat: process.env.ZZCLUB_PAT || config.wx.accounts[account]?.pat || config.wx.accounts[config.wx.defaultAccount]?.pat,
169
174
  });
175
+ return {
176
+ route: "wechat-newspic",
177
+ account,
178
+ status: "success",
179
+ detail: null,
180
+ external_id: typeof response.draftMediaId === "string" ? response.draftMediaId : null,
181
+ published_at: new Date().toISOString(),
182
+ content_version: state.artifacts.content_version,
183
+ render_version: state.artifacts.render_version,
184
+ };
170
185
  } catch (error) {
171
186
  return {
172
187
  route: "wechat-newspic",
@@ -179,15 +194,6 @@ async function publishWechatNewspicRoute({
179
194
  };
180
195
  }
181
196
 
182
- return {
183
- route: "wechat-newspic",
184
- account,
185
- status: "success",
186
- detail: null,
187
- published_at: new Date().toISOString(),
188
- content_version: state.artifacts.content_version,
189
- render_version: state.artifacts.render_version,
190
- };
191
197
  }
192
198
 
193
199
  const PUBLISH_PROVIDERS: Record<RoutePrimary, PublishProvider> = {
@@ -20,6 +20,7 @@ export interface ExecutePublishTargetsParams {
20
20
  dryRun: boolean;
21
21
  config: PipelineConfig;
22
22
  workspacePaths: ResolvedWorkspacePaths;
23
+ onResult?: (result: PublishResult) => Promise<void>;
23
24
  }
24
25
 
25
26
  export interface ExecutePublishTargetsResult {
@@ -66,13 +67,28 @@ export function filterIdempotent(
66
67
  });
67
68
  }
68
69
 
70
+ export function upsertPublishResult(
71
+ state: WorkflowState,
72
+ result: PublishResult,
73
+ ): void {
74
+ const index = state.publish.results.findIndex(
75
+ (item) => item.route === result.route && item.account === result.account,
76
+ );
77
+ if (index >= 0) {
78
+ state.publish.results[index] = result;
79
+ } else {
80
+ state.publish.results.push(result);
81
+ }
82
+ }
83
+
69
84
  /**
70
- * Execute publish for a list of targets in parallel.
85
+ * Execute publish targets sequentially so callers can persist each outcome
86
+ * before the next external side effect begins.
71
87
  */
72
88
  export async function executePublishTargets(
73
89
  params: ExecutePublishTargetsParams,
74
90
  ): Promise<ExecutePublishTargetsResult> {
75
- const { state, targets, dryRun, config, workspacePaths } = params;
91
+ const { state, targets, dryRun, config, workspacePaths, onResult } = params;
76
92
 
77
93
  const deduped = dedupeTargets(targets);
78
94
  const filtered = filterIdempotent(
@@ -85,39 +101,44 @@ export async function executePublishTargets(
85
101
  const results: PublishResult[] = [];
86
102
  const errors: PublishTargetError[] = [];
87
103
 
88
- // Execute in parallel
89
- const promises = filtered.map(async (target) => {
90
- const provider = getPublishProvider(target.route);
91
- const ctx: PublishRouteContext = {
92
- state,
93
- dryRun,
94
- config,
95
- workspacePaths,
96
- accountOverride: target.account,
97
- };
98
-
104
+ for (const target of filtered) {
105
+ let result: PublishResult;
99
106
  try {
100
- const result = await provider(ctx);
101
- return { result, error: null };
107
+ const provider = getPublishProvider(target.route);
108
+ const ctx: PublishRouteContext = {
109
+ state,
110
+ dryRun,
111
+ config,
112
+ workspacePaths,
113
+ accountOverride: target.account,
114
+ };
115
+ const providerResult = await provider(ctx);
116
+ result = {
117
+ ...providerResult,
118
+ route: target.route,
119
+ account: target.account,
120
+ content_version: state.artifacts.content_version,
121
+ render_version: state.artifacts.render_version,
122
+ };
102
123
  } catch (err) {
103
124
  const error: PublishTargetError = {
104
125
  route: target.route,
105
126
  account: target.account,
106
127
  error: err instanceof Error ? err.message : String(err),
107
128
  };
108
- return { result: null, error };
109
- }
110
- });
111
-
112
- const outcomes = await Promise.all(promises);
113
-
114
- for (const { result, error } of outcomes) {
115
- if (result) {
116
- results.push(result);
117
- }
118
- if (error) {
129
+ result = {
130
+ route: target.route,
131
+ account: target.account,
132
+ status: "failed",
133
+ detail: error.error,
134
+ published_at: null,
135
+ content_version: state.artifacts.content_version,
136
+ render_version: state.artifacts.render_version,
137
+ };
119
138
  errors.push(error);
120
139
  }
140
+ results.push(result);
141
+ await onResult?.(result);
121
142
  }
122
143
 
123
144
  return { results, errors };
@@ -8,8 +8,63 @@ import {
8
8
  replaceImageUrls,
9
9
  isRetryableTokenFetchError,
10
10
  resolveFilenameFromUrl,
11
+ resolveDraftMediaId,
12
+ readResponseBodyWithLimit,
11
13
  } from "./wechat";
12
14
 
15
+ describe("resolveDraftMediaId", () => {
16
+ test("extracts media_id from a successful draft response", () => {
17
+ expect(resolveDraftMediaId({ code: 0, data: { media_id: "media-1" } }))
18
+ .toBe("media-1");
19
+ });
20
+
21
+ test("rejects business errors returned with HTTP 200", () => {
22
+ expect(() => resolveDraftMediaId({ errcode: 40001, errmsg: "invalid credential" }))
23
+ .toThrow("WeChat draft API error: 40001");
24
+ });
25
+
26
+ test("rejects string business error codes for draft updates", () => {
27
+ expect(() => resolveDraftMediaId(
28
+ { errcode: "40001", errmsg: "invalid credential" },
29
+ "existing-media",
30
+ )).toThrow("WeChat draft API error: 40001");
31
+ });
32
+
33
+ test("requires media_id for a newly created draft", () => {
34
+ expect(() => resolveDraftMediaId({ code: 0, data: {} }))
35
+ .toThrow("did not include media_id");
36
+ });
37
+
38
+ test("uses the existing media id for a successful update", () => {
39
+ expect(resolveDraftMediaId({ errcode: 0 }, "existing-media"))
40
+ .toBe("existing-media");
41
+ });
42
+ });
43
+
44
+ describe("readResponseBodyWithLimit", () => {
45
+ test("accepts a streamed response at the byte limit", async () => {
46
+ const response = new Response(new Uint8Array([1, 2, 3]));
47
+ const body = await readResponseBodyWithLimit(response, 3);
48
+ expect(new Uint8Array(body)).toEqual(new Uint8Array([1, 2, 3]));
49
+ });
50
+
51
+ test("rejects a streamed response as soon as it exceeds the byte limit", async () => {
52
+ const response = new Response(new Uint8Array([1, 2, 3, 4]));
53
+ await expect(readResponseBodyWithLimit(response, 3)).rejects.toThrow(
54
+ "Image exceeds 3 byte limit",
55
+ );
56
+ });
57
+
58
+ test("rejects an oversized content-length before reading the body", async () => {
59
+ const response = new Response(new Uint8Array([1]), {
60
+ headers: { "content-length": "4" },
61
+ });
62
+ await expect(readResponseBodyWithLimit(response, 3)).rejects.toThrow(
63
+ "Image exceeds 3 byte limit",
64
+ );
65
+ });
66
+ });
67
+
13
68
  describe("normalizeAccountName", () => {
14
69
  test("accepts valid account names", () => {
15
70
  expect(normalizeAccountName("default")).toBe("default");