feishu-user-plugin 1.3.15 → 1.3.17

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.
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for the image/file block 3-step write flows (2026-06-07 systemic
3
+ // audit): create placeholder → upload media → PATCH replace. Before the fix a
4
+ // step-2/3 failure threw a raw error that did not name the placeholder block —
5
+ // the empty block stayed in the document as an orphan with no repair path, and
6
+ // transient Feishu flakes (rate limit / 5xx / code=2200) were never retried
7
+ // (unlike createDocTable's cell fill).
8
+ 'use strict';
9
+
10
+ const assert = require('assert');
11
+ const docs = require('./clients/official/docs');
12
+
13
+ let pass = 0, fail = 0;
14
+ async function ok(name, fn) {
15
+ try { await fn(); console.log(` OK ${name}`); pass++; }
16
+ catch (e) { console.log(` FAIL ${name}: ${e.message}`); fail++; }
17
+ }
18
+
19
+ const TRANSIENT = () => new Error('updateDocBlock failed (HTTP 503, code=99991400): rate limited');
20
+ const PERMANENT = () => new Error('updateDocBlock failed (HTTP 403, code=1770032): forBidden');
21
+
22
+ // Stub for createDocBlockWithImage. Dispatches _asUserOrApp on label:
23
+ // *.placeholder → returns the created placeholder block
24
+ // *.replaceImage / *.replaceFile → behavior driven by patchFails
25
+ function imageStub({ patchFails, uploadFails } = {}) {
26
+ const calls = { patches: 0, uploads: 0 };
27
+ const self = {
28
+ async _asUserOrApp({ label }) {
29
+ if (label.endsWith('.placeholder')) {
30
+ return { data: { children: [{ block_id: 'ph1', block_type: 27 }] }, _viaUser: true, _fallbackWarning: null };
31
+ }
32
+ calls.patches++;
33
+ if (patchFails) {
34
+ const err = patchFails(calls.patches);
35
+ if (err) throw err;
36
+ }
37
+ return { data: {}, _viaUser: true };
38
+ },
39
+ async uploadMedia() {
40
+ calls.uploads++;
41
+ if (uploadFails) {
42
+ const err = uploadFails(calls.uploads);
43
+ if (err) throw err;
44
+ }
45
+ return { fileToken: 'img_tok_1', viaUser: true };
46
+ },
47
+ };
48
+ return { self, calls };
49
+ }
50
+
51
+ // Stub for createDocBlockWithFile — the create response returns the inner FILE
52
+ // block directly (block_type 23) so the view-walk is skipped.
53
+ function fileStub({ patchFails } = {}) {
54
+ const calls = { patches: 0 };
55
+ const self = {
56
+ async _asUserOrApp({ label }) {
57
+ if (label.endsWith('.placeholder')) {
58
+ return { data: { children: [{ block_id: 'fb1', block_type: 23 }] }, _viaUser: true, _fallbackWarning: null };
59
+ }
60
+ calls.patches++;
61
+ if (patchFails) {
62
+ const err = patchFails(calls.patches);
63
+ if (err) throw err;
64
+ }
65
+ return { data: {}, _viaUser: true };
66
+ },
67
+ async uploadMedia() { return { fileToken: 'box_tok_1', viaUser: true }; },
68
+ };
69
+ return { self, calls };
70
+ }
71
+
72
+ async function run() {
73
+ console.log('=== test-doc-block-media ===');
74
+
75
+ await ok('image: retries a transient PATCH failure and succeeds', async () => {
76
+ const { self, calls } = imageStub({ patchFails: (n) => (n === 1 ? TRANSIENT() : null) });
77
+ const r = await docs.createDocBlockWithImage.call(self, 'd', 'd', { imagePath: '/tmp/x.png', retryDelaysMs: [1, 1] });
78
+ assert.strictEqual(r.blockId, 'ph1');
79
+ assert.strictEqual(calls.patches, 2, 'transient PATCH retried');
80
+ });
81
+
82
+ await ok('image: permanent PATCH failure throws a structured error naming the orphan placeholder', async () => {
83
+ const { self } = imageStub({ patchFails: () => PERMANENT() });
84
+ let err = null;
85
+ try {
86
+ await docs.createDocBlockWithImage.call(self, 'd', 'd', { imagePath: '/tmp/x.png', retryDelaysMs: [1, 1] });
87
+ } catch (e) { err = e; }
88
+ assert.ok(err, 'must throw on permanent failure');
89
+ assert.strictEqual(err.blockId, 'ph1', 'error must carry the placeholder blockId');
90
+ assert.ok(/ph1/.test(err.message), 'message must name the placeholder for cleanup/repair');
91
+ assert.ok(/img_tok_1/.test(err.message), 'message should carry the uploaded token so the caller can re-attach without re-uploading');
92
+ assert.ok(/image_token=/.test(err.message), 'repair hint must use the concrete token param name (not an ambiguous placeholder)');
93
+ assert.ok(/document_id=d\b/.test(err.message), 'repair hint must include the document_id required by manage_doc_block');
94
+ });
95
+
96
+ await ok('image: upload failure also names the placeholder (orphan exists before upload)', async () => {
97
+ const { self } = imageStub({ uploadFails: () => PERMANENT() });
98
+ let err = null;
99
+ try {
100
+ await docs.createDocBlockWithImage.call(self, 'd', 'd', { imagePath: '/tmp/x.png', retryDelaysMs: [1, 1] });
101
+ } catch (e) { err = e; }
102
+ assert.ok(err);
103
+ assert.strictEqual(err.blockId, 'ph1');
104
+ assert.ok(/ph1/.test(err.message));
105
+ });
106
+
107
+ await ok('image: transient upload failure is retried', async () => {
108
+ const { self, calls } = imageStub({ uploadFails: (n) => (n === 1 ? TRANSIENT() : null) });
109
+ const r = await docs.createDocBlockWithImage.call(self, 'd', 'd', { imagePath: '/tmp/x.png', retryDelaysMs: [1, 1] });
110
+ assert.strictEqual(r.blockId, 'ph1');
111
+ assert.strictEqual(calls.uploads, 2, 'transient upload retried');
112
+ });
113
+
114
+ await ok('file: permanent PATCH failure throws a structured error naming the orphan block', async () => {
115
+ const { self } = fileStub({ patchFails: () => PERMANENT() });
116
+ let err = null;
117
+ try {
118
+ await docs.createDocBlockWithFile.call(self, 'd', 'd', { fileToken: 'box_pre_uploaded', retryDelaysMs: [1, 1] });
119
+ } catch (e) { err = e; }
120
+ assert.ok(err);
121
+ assert.strictEqual(err.blockId, 'fb1', 'error must carry the file block id');
122
+ assert.ok(/fb1/.test(err.message));
123
+ });
124
+
125
+ await ok('file: transient PATCH failure is retried and succeeds', async () => {
126
+ const { self, calls } = fileStub({ patchFails: (n) => (n === 1 ? TRANSIENT() : null) });
127
+ const r = await docs.createDocBlockWithFile.call(self, 'd', 'd', { fileToken: 'box_pre_uploaded', retryDelaysMs: [1, 1] });
128
+ assert.strictEqual(r.blockId, 'fb1');
129
+ assert.strictEqual(calls.patches, 2);
130
+ });
131
+
132
+ console.log(`\n=== test-doc-block-media: ${pass} passed, ${fail} failed ===`);
133
+ if (fail > 0) process.exit(1);
134
+ }
135
+
136
+ if (require.main === module) {
137
+ run().catch((e) => { console.error('test-doc-block-media harness error:', e); process.exit(1); });
138
+ }
139
+
140
+ module.exports = { run };
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for getDocBlocks pagination (v1.3.17) + the tool-layer plumbing
3
+ // in src/tools/docs.js (get_doc_blocks page_token/max_blocks passthrough,
4
+ // manage_doc_block failedCells warning lift, read_doc_markdown truncation note).
5
+ //
6
+ // Field report (2026-06-07): a ~300KB doc synced via manage_doc_block exceeded
7
+ // 500 blocks; get_doc_blocks and read_doc_markdown both silently stopped at the
8
+ // same mid-document position — the client fetched ONE page of 500 and ignored
9
+ // has_more/page_token, with no truncation flag for the caller.
10
+ 'use strict';
11
+
12
+ const assert = require('assert');
13
+ const docs = require('./clients/official/docs');
14
+ const { handlers } = require('./tools/docs');
15
+
16
+ let pass = 0, fail = 0;
17
+ async function ok(name, fn) {
18
+ try { await fn(); console.log(` OK ${name}`); pass++; }
19
+ catch (e) { console.log(` FAIL ${name}: ${e.message}`); fail++; }
20
+ }
21
+
22
+ // pageMap: requested page_token ('' for first call) → { items, has_more, next }
23
+ function pagedStub(pageMap) {
24
+ const calls = [];
25
+ const self = {
26
+ async _asUserOrApp({ query }) {
27
+ const key = (query && query.page_token) || '';
28
+ calls.push(key);
29
+ const p = pageMap[key];
30
+ if (!p) throw new Error(`unexpected page_token: "${key}"`);
31
+ const data = { items: p.items, has_more: !!p.has_more };
32
+ if (p.next !== undefined) data.page_token = p.next;
33
+ return { data, _viaUser: true };
34
+ },
35
+ };
36
+ return { self, calls };
37
+ }
38
+
39
+ const blk = (id) => ({ block_id: id, block_type: 2 });
40
+
41
+ async function run() {
42
+ console.log('=== test-doc-blocks-pagination ===');
43
+
44
+ await ok('follows page_token to fetch ALL blocks past the 500/page cap', async () => {
45
+ const { self, calls } = pagedStub({
46
+ '': { items: [blk('b1'), blk('b2')], has_more: true, next: 'p2' },
47
+ 'p2': { items: [blk('b3')], has_more: false },
48
+ });
49
+ const r = await docs.getDocBlocks.call(self, 'docX');
50
+ assert.strictEqual(r.items.length, 3, 'all pages concatenated');
51
+ assert.strictEqual(r.total, 3);
52
+ assert.strictEqual(r.hasMore, false);
53
+ assert.strictEqual(r.nextPageToken, undefined);
54
+ assert.deepStrictEqual(calls, ['', 'p2'], 'second request carries the page_token');
55
+ assert.strictEqual(r.viaUser, true);
56
+ });
57
+
58
+ await ok('max_blocks stops early and reports hasMore + nextPageToken + truncated', async () => {
59
+ const { self, calls } = pagedStub({
60
+ '': { items: [blk('b1'), blk('b2')], has_more: true, next: 'p2' },
61
+ 'p2': { items: [blk('b3')], has_more: false },
62
+ });
63
+ const r = await docs.getDocBlocks.call(self, 'docX', { maxBlocks: 2 });
64
+ assert.strictEqual(r.items.length, 2);
65
+ assert.strictEqual(r.hasMore, true);
66
+ assert.strictEqual(r.truncated, true);
67
+ assert.strictEqual(r.nextPageToken, 'p2');
68
+ assert.strictEqual(calls.length, 1, 'no extra page fetched past max_blocks');
69
+ });
70
+
71
+ await ok('treats malformed max_blocks (0 / negative / string) as "no cap" — full fetch, no truncation', async () => {
72
+ for (const bad of [0, -5, 'abc', NaN]) {
73
+ const { self } = pagedStub({
74
+ '': { items: [blk('b1'), blk('b2')], has_more: true, next: 'p2' },
75
+ 'p2': { items: [blk('b3')], has_more: false },
76
+ });
77
+ const r = await docs.getDocBlocks.call(self, 'docX', { maxBlocks: bad });
78
+ assert.strictEqual(r.items.length, 3, `maxBlocks=${bad} must not cap the fetch`);
79
+ assert.strictEqual(r.hasMore, false, `maxBlocks=${bad} must not flag truncation`);
80
+ }
81
+ });
82
+
83
+ await ok('resumes from a caller-provided pageToken', async () => {
84
+ const { self, calls } = pagedStub({
85
+ 'p2': { items: [blk('b3')], has_more: false },
86
+ });
87
+ const r = await docs.getDocBlocks.call(self, 'docX', { pageToken: 'p2' });
88
+ assert.deepStrictEqual(calls, ['p2']);
89
+ assert.strictEqual(r.items.length, 1);
90
+ assert.strictEqual(r.hasMore, false);
91
+ });
92
+
93
+ await ok('continues past a permission-filtered empty page (empty + ADVANCING token is not a stall)', async () => {
94
+ // Feishu documents that paginated endpoints may return an empty page with
95
+ // has_more:true (permission filtering) and the caller should keep paging.
96
+ // An empty page must NOT end the loop when the cursor is still advancing.
97
+ const { self, calls } = pagedStub({
98
+ '': { items: [blk('b1')], has_more: true, next: 'p2' },
99
+ 'p2': { items: [], has_more: true, next: 'p3' }, // filtered-empty, real data behind it
100
+ 'p3': { items: [blk('b2')], has_more: false },
101
+ });
102
+ const r = await docs.getDocBlocks.call(self, 'docX');
103
+ assert.strictEqual(r.items.length, 2, 'must fetch the data behind the empty page');
104
+ assert.strictEqual(r.hasMore, false);
105
+ assert.deepStrictEqual(calls, ['', 'p2', 'p3']);
106
+ });
107
+
108
+ await ok('terminates on a stalled cursor (empty page, has_more, same token) and withholds it', async () => {
109
+ const { self, calls } = pagedStub({
110
+ '': { items: [blk('b1')], has_more: true, next: 'p1' },
111
+ 'p1': { items: [], has_more: true, next: 'p1' }, // server stall — would loop forever
112
+ });
113
+ const r = await docs.getDocBlocks.call(self, 'docX');
114
+ assert.ok(calls.length <= 3, `must terminate, made ${calls.length} calls`);
115
+ assert.strictEqual(r.items.length, 1);
116
+ assert.strictEqual(r.hasMore, true, 'incompleteness is reported, not hidden');
117
+ assert.strictEqual(r.nextPageToken, undefined, 'stalled cursor is withheld (PR #116 parity)');
118
+ });
119
+
120
+ await ok('get_doc_blocks handler passes page_token/max_blocks through to the client', async () => {
121
+ let got;
122
+ const ctx = {
123
+ resolveDocId: async (x) => x,
124
+ getOfficialClient: () => ({
125
+ getDocBlocks: async (id, opts) => { got = { id, opts }; return { items: [], total: 0, hasMore: false }; },
126
+ }),
127
+ };
128
+ await handlers.get_doc_blocks({ document_id: 'd', page_token: 'pX', max_blocks: 100 }, ctx);
129
+ assert.strictEqual(got.id, 'd');
130
+ assert.strictEqual(got.opts.pageToken, 'pX');
131
+ assert.strictEqual(got.opts.maxBlocks, 100);
132
+ });
133
+
134
+ await ok('manage_doc_block lifts a top warning when the table fill partially failed', async () => {
135
+ const ctx = {
136
+ resolveDocId: async (x) => x,
137
+ getOfficialClient: () => ({
138
+ createDocTable: async () => ({
139
+ tableBlockId: 'tbl1', cells: [['c0', 'c1']], rows: 1, columns: 2, filled: 1,
140
+ failedCells: [{ row: 0, col: 1, cellId: 'c1', textBlockId: 't-c1', reason: 'code=2200 …' }],
141
+ viaUser: true, fallbackWarning: null,
142
+ }),
143
+ }),
144
+ };
145
+ const res = await handlers.manage_doc_block({
146
+ action: 'create', document_id: 'd', parent_block_id: 'd',
147
+ table: { rows: 1, columns: 2, cells: [['A', 'B']] },
148
+ }, ctx);
149
+ const txt = res.content[0].text;
150
+ assert.ok(txt.startsWith('⚠'), `warning lifted to top: ${txt.slice(0, 80)}`);
151
+ assert.ok(/1\/2/.test(txt), 'warning names the failed/attempted ratio');
152
+ assert.ok(/failedCells/.test(txt), 'warning points at failedCells[]');
153
+ assert.ok(/"failedCells"/.test(txt), 'JSON body still carries failedCells');
154
+ });
155
+
156
+ await ok('read_doc_markdown appends a truncation note when blocks are incomplete', async () => {
157
+ try { require.resolve('feishu-docx'); } catch (_) {
158
+ console.log(' (feishu-docx not installed — skipping render assertion)');
159
+ return;
160
+ }
161
+ const fixturePath = require('path').join(__dirname, 'test-fixtures', 'doc-blocks', 'sample-1.json');
162
+ if (!require('fs').existsSync(fixturePath)) {
163
+ console.log(' (no fixture — skipping render assertion)');
164
+ return;
165
+ }
166
+ const blocks = JSON.parse(require('fs').readFileSync(fixturePath, 'utf8'));
167
+ const ctx = {
168
+ resolveDocId: async (x) => x,
169
+ getOfficialClient: () => ({
170
+ getDocBlocks: async () => ({ items: blocks, total: 2, hasMore: true }),
171
+ }),
172
+ };
173
+ const res = await handlers.read_doc_markdown({ document_id: 'd' }, ctx);
174
+ const md = res.content[0].text;
175
+ assert.ok(/truncated/i.test(md), `markdown output must flag incompleteness: ${md.slice(-120)}`);
176
+ });
177
+
178
+ console.log(`\n=== test-doc-blocks-pagination: ${pass} passed, ${fail} failed ===`);
179
+ if (fail > 0) process.exit(1);
180
+ }
181
+
182
+ if (require.main === module) {
183
+ run().catch((e) => { console.error('test-doc-blocks-pagination harness error:', e); process.exit(1); });
184
+ }
185
+
186
+ module.exports = { run };
@@ -27,8 +27,11 @@ async function ok(name, fn) {
27
27
  // false: empty cell (CREATE)
28
28
  // resolvableCells — cell ids that getBlockChildren(table) will return (defaults
29
29
  // to cellIds); set shorter to exercise fail-loud
30
- function stub({ cellIds, cellsInCreate = true, cellHasText = true, resolvableCells } = {}) {
31
- const calls = { createBody: null, updates: [], creates: [], childFetches: [] };
30
+ // updateFails — optional (blockId, attemptNo) => Error|null; attemptNo is
31
+ // 1-based per block. Lets tests inject transient/persistent
32
+ // cell-fill failures (the 2026-06-07 mode-F field report).
33
+ function stub({ cellIds, cellsInCreate = true, cellHasText = true, resolvableCells, updateFails } = {}) {
34
+ const calls = { createBody: null, updates: [], creates: [], childFetches: [], updateAttempts: {} };
32
35
  const self = {
33
36
  async _asUserOrApp({ body }) {
34
37
  calls.createBody = body;
@@ -45,12 +48,30 @@ function stub({ cellIds, cellsInCreate = true, cellHasText = true, resolvableCel
45
48
  // a cell → its auto text block (or none)
46
49
  return { items: cellHasText ? [{ block_id: 't-' + blockId, block_type: 2 }] : [] };
47
50
  },
48
- async updateDocBlock(documentId, blockId, body) { calls.updates.push({ blockId, body }); return { block: {} }; },
51
+ async updateDocBlock(documentId, blockId, body) {
52
+ calls.updateAttempts[blockId] = (calls.updateAttempts[blockId] || 0) + 1;
53
+ if (updateFails) {
54
+ const err = updateFails(blockId, calls.updateAttempts[blockId]);
55
+ if (err) throw err;
56
+ }
57
+ calls.updates.push({ blockId, body });
58
+ return { block: {} };
59
+ },
49
60
  async createDocBlock(documentId, parent, children) { calls.creates.push({ parent, children }); return { blocks: [] }; },
50
61
  };
51
62
  return { self, calls };
52
63
  }
53
64
 
65
+ // Production both-identities failure messages, verbatim shape from
66
+ // identity-state.js::withIdentityFallback — classifyError extracts the FIRST
67
+ // `code=` occurrence (the UAT-side code).
68
+ const TRANSIENT_BOTH = () => new Error(
69
+ 'updateDocBlock failed on both identities. as user: code=2200 msg=check incr user_access_token scope fail. as app: updateDocBlock failed (HTTP 403, code=1770032): forBidden',
70
+ );
71
+ const PERMANENT_BOTH = () => new Error(
72
+ 'updateDocBlock failed on both identities. as user: code=99991668 msg=access denied. as app: updateDocBlock failed (HTTP 403, code=1770032): forBidden',
73
+ );
74
+
54
75
  async function run() {
55
76
  console.log('=== test-doc-table ===');
56
77
 
@@ -103,6 +124,74 @@ async function run() {
103
124
  assert.strictEqual(calls.updates.length, 2);
104
125
  });
105
126
 
127
+ // --- v1.3.17: cell-fill resilience (mode-F partial failure field report) ---
128
+
129
+ await ok('retries transient cell-fill failures and still fills every cell', async () => {
130
+ // c01's update fails twice with the production code=2200 transient, then clears.
131
+ const { self, calls } = stub({
132
+ cellIds: ['c00', 'c01', 'c10', 'c11'],
133
+ updateFails: (blockId, attempt) => (blockId === 't-c01' && attempt <= 2 ? TRANSIENT_BOTH() : null),
134
+ });
135
+ const r = await docs.createDocTable.call(self, 'd', 'd', {
136
+ rows: 2, columns: 2, cells: [['A', 'B'], ['C', 'D']], retryDelaysMs: [1, 1],
137
+ });
138
+ assert.strictEqual(r.filled, 4, 'all 4 cells filled after retries');
139
+ assert.ok(!r.failedCells || r.failedCells.length === 0, 'no failedCells when retries succeed');
140
+ assert.strictEqual(calls.updateAttempts['t-c01'], 3, 'failing cell attempted 3 times (1 + 2 retries)');
141
+ });
142
+
143
+ await ok('reports failedCells {row,col,cellId,textBlockId,reason} and keeps filling on persistent failure', async () => {
144
+ const { self, calls } = stub({
145
+ cellIds: ['c00', 'c01', 'c10', 'c11'],
146
+ updateFails: (blockId) => (blockId === 't-c01' ? PERMANENT_BOTH() : null),
147
+ });
148
+ const r = await docs.createDocTable.call(self, 'd', 'd', {
149
+ rows: 2, columns: 2, cells: [['A', 'B'], ['C', 'D']], retryDelaysMs: [1, 1],
150
+ });
151
+ assert.strictEqual(r.tableBlockId, 'tbl1', 'partial result keeps tableBlockId');
152
+ assert.strictEqual(r.filled, 3, 'other cells still filled');
153
+ assert.strictEqual(r.failedCells.length, 1);
154
+ const f = r.failedCells[0];
155
+ assert.strictEqual(f.row, 0);
156
+ assert.strictEqual(f.col, 1);
157
+ assert.strictEqual(f.cellId, 'c01');
158
+ assert.strictEqual(f.textBlockId, 't-c01');
159
+ assert.ok(/code=99991668/.test(f.reason), `reason carries the underlying error: ${f.reason}`);
160
+ assert.strictEqual(calls.updateAttempts['t-c01'], 1, 'permanent errors are NOT retried');
161
+ });
162
+
163
+ await ok('records the failed cell after exhausting transient retries and continues', async () => {
164
+ const { self, calls } = stub({
165
+ cellIds: ['c0', 'c1'],
166
+ updateFails: (blockId) => (blockId === 't-c0' ? TRANSIENT_BOTH() : null),
167
+ });
168
+ const r = await docs.createDocTable.call(self, 'd', 'd', {
169
+ rows: 1, columns: 2, cells: [['X', 'Y']], retryDelaysMs: [1, 1],
170
+ });
171
+ assert.strictEqual(r.filled, 1);
172
+ assert.strictEqual(r.failedCells.length, 1);
173
+ assert.strictEqual(calls.updateAttempts['t-c0'], 3, 'transient retried to exhaustion (1 + 2 retries)');
174
+ assert.strictEqual(calls.updateAttempts['t-c1'], 1, 'later cell still attempted');
175
+ });
176
+
177
+ await ok('aborts after 3 consecutive cell failures and marks the remainder skipped', async () => {
178
+ const { self, calls } = stub({
179
+ cellIds: ['c0', 'c1', 'c2', 'c3', 'c4', 'c5'],
180
+ updateFails: () => PERMANENT_BOTH(),
181
+ });
182
+ const r = await docs.createDocTable.call(self, 'd', 'd', {
183
+ rows: 2, columns: 3, cells: [['a', 'b', 'c'], ['d', 'e', 'f']], retryDelaysMs: [1, 1],
184
+ });
185
+ assert.strictEqual(r.filled, 0);
186
+ assert.strictEqual(r.failedCells.length, 6, 'every provided cell accounted for');
187
+ const attempted = r.failedCells.filter(f => !f.skipped);
188
+ const skipped = r.failedCells.filter(f => f.skipped);
189
+ assert.strictEqual(attempted.length, 3, 'stops attempting after 3 consecutive failures');
190
+ assert.strictEqual(skipped.length, 3, 'remaining cells reported as skipped');
191
+ assert.ok(skipped.every(f => f.cellId), 'skipped entries still carry cellId for manual repair');
192
+ assert.strictEqual(Object.keys(calls.updateAttempts).length, 3, 'no API calls for skipped cells');
193
+ });
194
+
106
195
  await ok('rejects rows/columns < 1', async () => {
107
196
  const { self } = stub({ cellIds: [] });
108
197
  for (const bad of [{ rows: 0, columns: 2 }, { rows: 2, columns: 0 }, { rows: -1, columns: 1 }]) {
@@ -47,6 +47,16 @@ function run() {
47
47
  assert.equal(c.reason, 'upload_transient', `${code} reason`);
48
48
  }
49
49
 
50
+ // --- 2200 docx scope-check flake (v1.3.17) ---
51
+ // Field report 2026-06-07: a mode-F table fill saw 15 identical updateDocBlock
52
+ // calls succeed, then "code=2200 check incr user_access_token scope fail" —
53
+ // same token, so the scope was fine; the check itself is intermittently flaky
54
+ // under rapid-fire docx writes. Classified transient so cell-fill retries it.
55
+ const c2200 = classifyError(2200);
56
+ assert.equal(c2200.action, 'retry', '2200 should be transient (retry)');
57
+ const bothIdentities = new Error('updateDocBlock failed on both identities. as user: code=2200 msg=check incr user_access_token scope fail. as app: updateDocBlock failed (HTTP 403, code=1770032): forBidden');
58
+ assert.equal(classifyError(bothIdentities).action, 'retry', 'combined both-identities message extracts the UAT-side 2200');
59
+
50
60
  // --- res.json() parse failures should retry once ---
51
61
  // Real-world: feishu's gateway occasionally returns truncated bodies that
52
62
  // make response.json() throw SyntaxError; one retry usually clears it.
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for the cursor-chain fixes (2026-06-07 systemic audit, follow-up
3
+ // to PR #118): clients that return hasMore but drop the resume cursor, tool
4
+ // schemas/handlers that never expose page_token, and partial-failure arrays
5
+ // silently swallowed.
6
+ //
7
+ // Covers:
8
+ // 1. read_messages / read_p2p_messages — page_token passthrough (client ready)
9
+ // 2. list_wiki_nodes — client must return pageToken; handler must pass it
10
+ // 3. manage_bitable_record(search) — same chain as 2
11
+ // 4. listWikiSpaces — internal pagination to completion (was silent 50-cap)
12
+ // 5. manage_members(add) — surface not_existed_id_list / pending_approval_id_list
13
+ 'use strict';
14
+
15
+ const assert = require('assert');
16
+ const wikiClient = require('./clients/official/wiki');
17
+ const bitableClient = require('./clients/official/bitable');
18
+ const groupsClient = require('./clients/official/groups');
19
+ const imReadHandlers = require('./tools/im-read').handlers;
20
+ const wikiHandlers = require('./tools/wiki').handlers;
21
+ const bitableHandlers = require('./tools/bitable').handlers;
22
+ const groupsHandlers = require('./tools/groups').handlers;
23
+
24
+ let pass = 0, fail = 0;
25
+ async function ok(name, fn) {
26
+ try { await fn(); console.log(` OK ${name}`); pass++; }
27
+ catch (e) { console.log(` FAIL ${name}: ${e.message}`); fail++; }
28
+ }
29
+
30
+ async function run() {
31
+ console.log('=== test-pagination-cursor-chain ===');
32
+
33
+ // --- 1. read_messages / read_p2p_messages ---
34
+
35
+ await ok('read_messages handler passes page_token through to readMessagesWithFallback', async () => {
36
+ let got;
37
+ const official = {
38
+ hasUAT: true,
39
+ readMessagesWithFallback: async (chatId, opts) => { got = { chatId, opts }; return { items: [] }; },
40
+ };
41
+ const ctx = { getOfficialClient: () => official, getUserClient: async () => { throw new Error('no cookie'); } };
42
+ await imReadHandlers.read_messages({ chat_id: 'oc_test', page_token: 'PT1' }, ctx);
43
+ assert.strictEqual(got.chatId, 'oc_test');
44
+ assert.strictEqual(got.opts.pageToken, 'PT1', 'page_token must reach the client as pageToken');
45
+ });
46
+
47
+ await ok('read_p2p_messages handler passes page_token through to readMessagesAsUser', async () => {
48
+ let got;
49
+ const official = {
50
+ readMessagesAsUser: async (chatId, opts) => { got = { chatId, opts }; return { items: [] }; },
51
+ };
52
+ const ctx = { getOfficialClient: () => official, getUserClient: async () => { throw new Error('no cookie'); } };
53
+ await imReadHandlers.read_p2p_messages({ chat_id: '7123456', page_token: 'PT2' }, ctx);
54
+ assert.strictEqual(got.opts.pageToken, 'PT2');
55
+ });
56
+
57
+ // --- 2. list_wiki_nodes ---
58
+
59
+ await ok('listWikiNodes client returns the resume pageToken alongside hasMore', async () => {
60
+ const self = {
61
+ async _asUserOrApp() {
62
+ return { data: { items: [{ node_token: 'n1' }], has_more: true, page_token: 'WNEXT' }, _viaUser: true };
63
+ },
64
+ };
65
+ const r = await wikiClient.listWikiNodes.call(self, 'sp1', {});
66
+ assert.strictEqual(r.hasMore, true);
67
+ assert.strictEqual(r.pageToken, 'WNEXT', 'hasMore without a cursor is a dead end');
68
+ });
69
+
70
+ await ok('list_wiki_nodes handler passes page_token through', async () => {
71
+ let got;
72
+ const ctx = {
73
+ getOfficialClient: () => ({
74
+ listWikiNodes: async (spaceId, opts) => { got = { spaceId, opts }; return { items: [], hasMore: false }; },
75
+ }),
76
+ };
77
+ await wikiHandlers.list_wiki_nodes({ space_id: 'sp1', page_token: 'PT3' }, ctx);
78
+ assert.strictEqual(got.opts.pageToken, 'PT3');
79
+ });
80
+
81
+ // --- 3. manage_bitable_record(search) ---
82
+
83
+ await ok('searchBitableRecords client returns the resume pageToken alongside hasMore', async () => {
84
+ const self = {
85
+ async _asUserOrApp() {
86
+ return { data: { items: [{ record_id: 'r1' }], total: 2000, has_more: true, page_token: 'BNEXT' }, _viaUser: true };
87
+ },
88
+ // legacy path uses _safeSDKCall? keep both shims so the test follows the impl
89
+ async _safeSDKCall(fn) { return fn(); },
90
+ };
91
+ const r = await bitableClient.searchBitableRecords.call(self, 'app1', 'tbl1', {});
92
+ assert.strictEqual(r.hasMore, true);
93
+ assert.strictEqual(r.pageToken, 'BNEXT', 'hasMore + total without a cursor strands the caller at page 1');
94
+ });
95
+
96
+ await ok('manage_bitable_record(search) handler passes page_token through', async () => {
97
+ let got;
98
+ const ctx = {
99
+ resolveDocId: async (x) => x,
100
+ getOfficialClient: () => ({
101
+ searchBitableRecords: async (appToken, tableId, opts) => { got = { appToken, tableId, opts }; return { items: [], hasMore: false }; },
102
+ }),
103
+ };
104
+ await bitableHandlers.manage_bitable_record({ action: 'search', app_token: 'app1', table_id: 'tbl1', page_token: 'PT4' }, ctx);
105
+ assert.strictEqual(got.opts.pageToken, 'PT4');
106
+ });
107
+
108
+ // --- 4. listWikiSpaces full pagination ---
109
+
110
+ await ok('listWikiSpaces follows page_token to fetch ALL spaces past the 50/page cap', async () => {
111
+ const calls = [];
112
+ const self = {
113
+ async _asUserOrApp({ query }) {
114
+ const key = (query && query.page_token) || '';
115
+ calls.push(key);
116
+ if (key === '') return { data: { items: Array.from({ length: 50 }, (_, i) => ({ space_id: 's' + i })), has_more: true, page_token: 'SP2' }, _viaUser: true };
117
+ if (key === 'SP2') return { data: { items: [{ space_id: 's50' }], has_more: false }, _viaUser: true };
118
+ throw new Error('unexpected page_token: ' + key);
119
+ },
120
+ };
121
+ const r = await wikiClient.listWikiSpaces.call(self);
122
+ assert.strictEqual(r.items.length, 51, 'all pages concatenated');
123
+ assert.deepStrictEqual(calls, ['', 'SP2']);
124
+ assert.ok(!r.hasMore, 'complete fetch must not flag hasMore');
125
+ });
126
+
127
+ await ok('listWikiSpaces continues past a permission-filtered empty page (empty + advancing token)', async () => {
128
+ const calls = [];
129
+ const self = {
130
+ async _asUserOrApp({ query }) {
131
+ const key = (query && query.page_token) || '';
132
+ calls.push(key);
133
+ if (key === '') return { data: { items: [{ space_id: 's1' }], has_more: true, page_token: 'SP2' }, _viaUser: true };
134
+ if (key === 'SP2') return { data: { items: [], has_more: true, page_token: 'SP3' }, _viaUser: true }; // filtered-empty
135
+ if (key === 'SP3') return { data: { items: [{ space_id: 's2' }], has_more: false }, _viaUser: true };
136
+ throw new Error('unexpected page_token: ' + key);
137
+ },
138
+ };
139
+ const r = await wikiClient.listWikiSpaces.call(self);
140
+ assert.strictEqual(r.items.length, 2, 'must page through the filtered-empty page to reach s2');
141
+ assert.deepStrictEqual(calls, ['', 'SP2', 'SP3']);
142
+ assert.ok(!r.hasMore, 'complete fetch must not flag hasMore');
143
+ });
144
+
145
+ await ok('listWikiSpaces terminates on a stalled cursor and reports hasMore', async () => {
146
+ let n = 0;
147
+ const self = {
148
+ async _asUserOrApp() {
149
+ n++;
150
+ return { data: { items: n === 1 ? [{ space_id: 's1' }] : [], has_more: true, page_token: 'LOOP' }, _viaUser: true };
151
+ },
152
+ };
153
+ const r = await wikiClient.listWikiSpaces.call(self);
154
+ assert.ok(n <= 3, `must terminate, made ${n} calls`);
155
+ assert.strictEqual(r.items.length, 1);
156
+ assert.strictEqual(r.hasMore, true, 'incompleteness must be visible');
157
+ });
158
+
159
+ // --- 5. manage_members(add) partial-failure arrays ---
160
+
161
+ await ok('addChatMembers surfaces not_existed_id_list and pending_approval_id_list', async () => {
162
+ const self = {
163
+ async _safeSDKCall(fn) {
164
+ return { data: { invalid_id_list: ['bad1'], not_existed_id_list: ['ghost1'], pending_approval_id_list: ['wait1', 'wait2'] } };
165
+ },
166
+ };
167
+ const r = await groupsClient.addChatMembers.call(self, 'oc_g', ['bad1', 'ghost1', 'wait1', 'wait2', 'ok1']);
168
+ assert.deepStrictEqual(r.invalidIds, ['bad1']);
169
+ assert.deepStrictEqual(r.notExistedIds, ['ghost1'], 'not_existed ids must not be swallowed');
170
+ assert.deepStrictEqual(r.pendingApprovalIds, ['wait1', 'wait2'], 'pending-approval ids must not be swallowed');
171
+ });
172
+
173
+ await ok('manage_members(add) response names the partial failures, not a silent success', async () => {
174
+ const ctx = {
175
+ getOfficialClient: () => ({
176
+ addChatMembers: async () => ({ invalidIds: [], notExistedIds: ['ghost1'], pendingApprovalIds: ['wait1'] }),
177
+ }),
178
+ };
179
+ const res = await groupsHandlers.manage_members({ chat_id: 'oc_g', member_ids: ['ghost1', 'wait1', 'ok1'], action: 'add' }, ctx);
180
+ const txt = res.content[0].text;
181
+ assert.ok(/ghost1/.test(txt), 'not-existed id visible in response');
182
+ assert.ok(/wait1/.test(txt), 'pending id visible in response');
183
+ assert.ok(txt.startsWith('⚠'), `partial failure must be lifted as a top warning, not buried in JSON: ${txt.slice(0, 120)}`);
184
+ });
185
+
186
+ console.log(`\n=== test-pagination-cursor-chain: ${pass} passed, ${fail} failed ===`);
187
+ if (fail > 0) process.exit(1);
188
+ }
189
+
190
+ if (require.main === module) {
191
+ run().catch((e) => { console.error('test-pagination-cursor-chain harness error:', e); process.exit(1); });
192
+ }
193
+
194
+ module.exports = { run };