@gravitykit/block-mcp 2.0.0-beta
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/.env.example +15 -0
- package/LICENSE +26 -0
- package/README.md +592 -0
- package/dist/index.cjs +52721 -0
- package/package.json +70 -0
- package/src/__tests__/fixtures/block-trees.ts +199 -0
- package/src/__tests__/fixtures/error-envelopes.ts +115 -0
- package/src/__tests__/fixtures/rest-responses.ts +280 -0
- package/src/__tests__/helpers/mock-client.ts +185 -0
- package/src/__tests__/helpers/request-matchers.ts +88 -0
- package/src/__tests__/helpers/schema-asserts.ts +132 -0
- package/src/__tests__/integration/concurrency.test.ts +129 -0
- package/src/__tests__/integration/dual-storage.test.ts +156 -0
- package/src/__tests__/integration/error-envelopes.test.ts +238 -0
- package/src/__tests__/integration/global-setup.ts +17 -0
- package/src/__tests__/integration/rate-limit.test.ts +88 -0
- package/src/__tests__/integration/read-edit-read.test.ts +141 -0
- package/src/__tests__/integration/ref-stability.test.ts +175 -0
- package/src/__tests__/integration/setup.ts +201 -0
- package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
- package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
- package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
- package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
- package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
- package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
- package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
- package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
- package/src/__tests__/tools/media/upload_media.test.ts +123 -0
- package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
- package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
- package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
- package/src/__tests__/tools/posts/create_post.test.ts +84 -0
- package/src/__tests__/tools/posts/update_post.test.ts +93 -0
- package/src/__tests__/tools/read/get_block.test.ts +96 -0
- package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
- package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
- package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
- package/src/__tests__/tools/write/delete_block.test.ts +91 -0
- package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
- package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
- package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
- package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
- package/src/__tests__/tools/write/update_block.test.ts +206 -0
- package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
- package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
- package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
- package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
- package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
- package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
- package/src/__tests__/unit/instructions.test.ts +374 -0
- package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
- package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
- package/src/client.ts +964 -0
- package/src/connect.ts +877 -0
- package/src/enrichers.ts +348 -0
- package/src/error-translator.ts +156 -0
- package/src/index.ts +450 -0
- package/src/instructions.ts +270 -0
- package/src/preferences.ts +273 -0
- package/src/tools/discovery.ts +251 -0
- package/src/tools/media.ts +75 -0
- package/src/tools/mutate.ts +243 -0
- package/src/tools/patterns.ts +94 -0
- package/src/tools/posts.ts +200 -0
- package/src/tools/read.ts +201 -0
- package/src/tools/terms.ts +44 -0
- package/src/tools/write.ts +542 -0
- package/src/tools/yoast.ts +224 -0
- package/src/types.ts +862 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration: dual-storage enforcement (yoast/faq-block)
|
|
3
|
+
*
|
|
4
|
+
* A "dual" block (e.g. yoast/faq-block, yoast/how-to-block) requires BOTH
|
|
5
|
+
* `attributes` AND `innerHTML` on every write. Sending only `attributes`
|
|
6
|
+
* must return HTTP 400 with code `dual_storage_requires_both`.
|
|
7
|
+
*
|
|
8
|
+
* This test:
|
|
9
|
+
* 1. Calls list_block_types to detect whether Yoast is installed.
|
|
10
|
+
* If the `yoast/faq-block` type is absent, the test is skipped.
|
|
11
|
+
* 2. Creates a throwaway post with a yoast/faq-block.
|
|
12
|
+
* 3. Sends an update with only `attributes` (no innerHTML).
|
|
13
|
+
* 4. Asserts the 400 / dual_storage_requires_both response.
|
|
14
|
+
*
|
|
15
|
+
* If dual-storage scanning has not yet been run on the site (the scan builds
|
|
16
|
+
* the classification map), the plugin may not know that yoast/faq-block is
|
|
17
|
+
* `dual` and the write could succeed with 200 instead of 400. In that case
|
|
18
|
+
* we emit a console.log and allow the test to pass — the enforcement only
|
|
19
|
+
* fires after a storage-mode scan has been run.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
23
|
+
import { makeLiveClient, skipUnlessLive, withTestPost, LIVE_ENV } from './setup.js';
|
|
24
|
+
import axios from 'axios';
|
|
25
|
+
|
|
26
|
+
const skip = skipUnlessLive();
|
|
27
|
+
|
|
28
|
+
describe.skipIf(skip)('dual-storage enforcement (integration)', () => {
|
|
29
|
+
let yoastInstalled = false;
|
|
30
|
+
let dualBlockName: string | null = null;
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
if (skip) return;
|
|
34
|
+
try {
|
|
35
|
+
const client = makeLiveClient();
|
|
36
|
+
const result = await client.getBlockTypes({ namespace: 'yoast' });
|
|
37
|
+
const candidates = result.block_types.filter(
|
|
38
|
+
(bt) =>
|
|
39
|
+
bt.name === 'yoast/faq-block' ||
|
|
40
|
+
bt.name === 'yoast/how-to-block' ||
|
|
41
|
+
bt.storage_mode === 'dual'
|
|
42
|
+
);
|
|
43
|
+
if (candidates.length > 0) {
|
|
44
|
+
yoastInstalled = true;
|
|
45
|
+
dualBlockName = candidates[0].name;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
yoastInstalled = false;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('update with only attributes on a dual-storage block returns 400 or 200 (requires scan)', async () => {
|
|
53
|
+
if (!yoastInstalled || !dualBlockName) {
|
|
54
|
+
console.log('[integration] No dual-storage block found (Yoast not installed or no dual blocks) — skipping');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const client = makeLiveClient();
|
|
59
|
+
|
|
60
|
+
const faqInnerHtml = '<div class="schema-faq"><div class="schema-faq-section"><strong class="schema-faq-question">Test Q</strong><p class="schema-faq-answer">Test A</p></div></div>';
|
|
61
|
+
const faqAttributes = {
|
|
62
|
+
questions: [{ id: 'faq-q1', question: 'Test Q', answer: 'Test A', jsonAnswer: 'Test A', jsonQuestion: 'Test Q' }],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await withTestPost(client, async (postId) => {
|
|
66
|
+
// Insert the dual-storage block.
|
|
67
|
+
const inserted = await client.insertBlocks(postId, {
|
|
68
|
+
after: 'start',
|
|
69
|
+
blocks: [
|
|
70
|
+
{
|
|
71
|
+
name: dualBlockName!,
|
|
72
|
+
attributes: faqAttributes,
|
|
73
|
+
innerHTML: faqInnerHtml,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
expect(inserted.success).toBe(true);
|
|
78
|
+
const blockIndex = inserted.inserted[0]?.index;
|
|
79
|
+
expect(blockIndex).toBeGreaterThanOrEqual(0);
|
|
80
|
+
|
|
81
|
+
// Try to update with ONLY attributes — dual enforcement should reject it.
|
|
82
|
+
const credentials = Buffer.from(
|
|
83
|
+
`${LIVE_ENV.user}:${LIVE_ENV.password}`
|
|
84
|
+
).toString('base64');
|
|
85
|
+
const url = `${LIVE_ENV.url.replace(/\/+$/, '')}/wp-json/gk-block-api/v1/posts/${postId}/blocks/${blockIndex}`;
|
|
86
|
+
|
|
87
|
+
const response = await axios.patch(
|
|
88
|
+
url,
|
|
89
|
+
{ attributes: faqAttributes },
|
|
90
|
+
{
|
|
91
|
+
headers: {
|
|
92
|
+
Authorization: `Basic ${credentials}`,
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
},
|
|
95
|
+
timeout: 15_000,
|
|
96
|
+
validateStatus: () => true,
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (response.status === 400) {
|
|
101
|
+
// Dual-storage enforcement is active — verify the error code.
|
|
102
|
+
const body = response.data as Record<string, unknown>;
|
|
103
|
+
expect(body.code).toBe('dual_storage_requires_both');
|
|
104
|
+
expect(body.message).toBeTruthy();
|
|
105
|
+
} else if (response.status === 200) {
|
|
106
|
+
// The plugin returned a 200 because the storage-mode scan classifying
|
|
107
|
+
// this block as `dual` had not been run on this site. Silently
|
|
108
|
+
// logging-and-continuing turns the test green even though the
|
|
109
|
+
// enforcement path was never exercised — the same outcome a real
|
|
110
|
+
// regression that broke enforcement would produce. Fail loudly so
|
|
111
|
+
// CI surfaces the missing precondition instead of papering over it.
|
|
112
|
+
throw new Error(
|
|
113
|
+
`[integration] dual-storage not enforced for ${dualBlockName} ` +
|
|
114
|
+
'— call scan_storage_modes to classify the block as dual, then re-run this suite. ' +
|
|
115
|
+
'The 200 response means the precondition for this test is not met, not that the test passed.'
|
|
116
|
+
);
|
|
117
|
+
} else {
|
|
118
|
+
// Any other status is unexpected.
|
|
119
|
+
throw new Error(`Unexpected status ${response.status} from dual-storage update`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}, 45_000);
|
|
123
|
+
|
|
124
|
+
it('update with both attributes AND innerHTML on a dual block succeeds', async () => {
|
|
125
|
+
if (!yoastInstalled || !dualBlockName) {
|
|
126
|
+
console.log('[integration] No dual-storage block found — skipping');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const client = makeLiveClient();
|
|
131
|
+
|
|
132
|
+
const faqInnerHtml = '<div class="schema-faq"><div class="schema-faq-section"><strong class="schema-faq-question">Test Q</strong><p class="schema-faq-answer">Test A</p></div></div>';
|
|
133
|
+
const faqAttributes = {
|
|
134
|
+
questions: [{ id: 'faq-q1', question: 'Test Q', answer: 'Test A', jsonAnswer: 'Test A', jsonQuestion: 'Test Q' }],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
await withTestPost(client, async (postId) => {
|
|
138
|
+
const inserted = await client.insertBlocks(postId, {
|
|
139
|
+
after: 'start',
|
|
140
|
+
blocks: [{ name: dualBlockName!, attributes: faqAttributes, innerHTML: faqInnerHtml }],
|
|
141
|
+
});
|
|
142
|
+
const blockIndex = inserted.inserted[0]?.index;
|
|
143
|
+
expect(blockIndex).toBeGreaterThanOrEqual(0);
|
|
144
|
+
|
|
145
|
+
// Update with BOTH attributes AND innerHTML — must always succeed.
|
|
146
|
+
const updatedHtml = '<div class="schema-faq"><div class="schema-faq-section"><strong class="schema-faq-question">Updated Q</strong><p class="schema-faq-answer">Test A</p></div></div>';
|
|
147
|
+
const result = await client.updateBlock(postId, blockIndex, {
|
|
148
|
+
attributes: {
|
|
149
|
+
questions: [{ id: 'faq-q1', question: 'Updated Q', answer: 'Test A', jsonAnswer: 'Test A', jsonQuestion: 'Updated Q' }],
|
|
150
|
+
},
|
|
151
|
+
innerHTML: updatedHtml,
|
|
152
|
+
});
|
|
153
|
+
expect(result.success).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
}, 45_000);
|
|
156
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration: real REST error envelope shapes
|
|
3
|
+
*
|
|
4
|
+
* The unit tests in src/__tests__/error-translator.test.ts verify that our
|
|
5
|
+
* TypeScript translation layer maps codes correctly. THIS file verifies that
|
|
6
|
+
* the live PHP plugin actually returns the error codes and HTTP statuses we
|
|
7
|
+
* claim — i.e., our documented constants match what's on the wire.
|
|
8
|
+
*
|
|
9
|
+
* We sample 8 representative codes from the documented 49 and trigger each
|
|
10
|
+
* one deliberately against a live post:
|
|
11
|
+
*
|
|
12
|
+
* 1. rest_no_route — hit a non-existent route
|
|
13
|
+
* 2. post not found — reference a post that doesn't exist (403 or 404
|
|
14
|
+
* depending on whether the permission check or the
|
|
15
|
+
* lookup check fires first)
|
|
16
|
+
* 3. rest_forbidden — write with wrong credentials (401 or 403)
|
|
17
|
+
* 4. invalid_path — mutate with an out-of-range path
|
|
18
|
+
* 5. legacy_block — insert a ugb/text block (always hard-rejected)
|
|
19
|
+
* 6. invalid_ref — updateBlockByRef with a made-up ref
|
|
20
|
+
* (skipped when by-ref route is absent)
|
|
21
|
+
* 7. invalid_block — insert a non-registered block name
|
|
22
|
+
* 8. cleanup — verify the shared post is trashed correctly
|
|
23
|
+
*
|
|
24
|
+
* Each assertion checks:
|
|
25
|
+
* - HTTP status is in the expected range for the error class
|
|
26
|
+
* - Response body has a `code` field (machine-readable)
|
|
27
|
+
* - Response body has a `message` string (non-empty)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
31
|
+
import axios from 'axios';
|
|
32
|
+
import { makeLiveClient, skipUnlessLive, LIVE_ENV, hasRoute } from './setup.js';
|
|
33
|
+
|
|
34
|
+
const skip = skipUnlessLive();
|
|
35
|
+
|
|
36
|
+
/** Raw request against the REST API without the MCP client's retry logic. */
|
|
37
|
+
async function rawRequest(
|
|
38
|
+
method: 'get' | 'post' | 'patch' | 'delete',
|
|
39
|
+
path: string,
|
|
40
|
+
body?: unknown,
|
|
41
|
+
credentials?: string
|
|
42
|
+
): Promise<{ status: number; code?: string; message?: string; data?: unknown }> {
|
|
43
|
+
const creds =
|
|
44
|
+
credentials ??
|
|
45
|
+
Buffer.from(`${LIVE_ENV.user}:${LIVE_ENV.password}`).toString('base64');
|
|
46
|
+
const base = LIVE_ENV.url.replace(/\/+$/, '');
|
|
47
|
+
const url = `${base}/wp-json/gk-block-api/v1${path}`;
|
|
48
|
+
|
|
49
|
+
const response = await axios.request({
|
|
50
|
+
method,
|
|
51
|
+
url,
|
|
52
|
+
data: body,
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Basic ${creds}`,
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
},
|
|
57
|
+
timeout: 15_000,
|
|
58
|
+
validateStatus: () => true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const b = response.data as Record<string, unknown> | undefined;
|
|
62
|
+
return {
|
|
63
|
+
status: response.status,
|
|
64
|
+
code: typeof b?.code === 'string' ? b.code : undefined,
|
|
65
|
+
message: typeof b?.message === 'string' ? b.message : undefined,
|
|
66
|
+
data: b?.data,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe.skipIf(skip)('real REST error envelopes (integration)', () => {
|
|
71
|
+
// We need a live post ID for several tests.
|
|
72
|
+
let livePostId: number;
|
|
73
|
+
let liveHeadingIndex: number;
|
|
74
|
+
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
if (skip) return;
|
|
77
|
+
const client = makeLiveClient();
|
|
78
|
+
const created = await client.createPost({
|
|
79
|
+
title: `[integration-test] error-envelopes ${Date.now()}`,
|
|
80
|
+
status: 'draft',
|
|
81
|
+
blocks: [
|
|
82
|
+
{
|
|
83
|
+
name: 'core/heading',
|
|
84
|
+
attributes: { level: 2, content: 'Error envelope test post' },
|
|
85
|
+
innerHTML: '<h2 class="wp-block-heading">Error envelope test post</h2>',
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
livePostId = created.id;
|
|
90
|
+
|
|
91
|
+
const blocks = await client.getPageBlocks(livePostId);
|
|
92
|
+
const h = blocks.blocks.find((b) => b.name === 'core/heading');
|
|
93
|
+
liveHeadingIndex = h?.index ?? 0;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rest_no_route: GET to a non-existent route returns 404 + rest_no_route', async () => {
|
|
97
|
+
const result = await rawRequest('get', '/nonexistent-route-xyz');
|
|
98
|
+
expect(result.status).toBe(404);
|
|
99
|
+
expect(result.code).toBe('rest_no_route');
|
|
100
|
+
expect(result.message).toBeTruthy();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('auth gate fires before post lookup for non-existent post IDs (403 or 404)', async () => {
|
|
104
|
+
// Using a post ID that doesn't exist (999999999). Depending on whether the
|
|
105
|
+
// permission check or the post lookup runs first, we get 403 or 404.
|
|
106
|
+
// Both are valid — we just confirm a structured error envelope is returned.
|
|
107
|
+
const result = await rawRequest('get', '/posts/999999999/blocks');
|
|
108
|
+
expect([403, 404]).toContain(result.status);
|
|
109
|
+
expect(result.message).toBeTruthy();
|
|
110
|
+
// Some plugin versions return a code, others return a status-only envelope.
|
|
111
|
+
if (result.code) {
|
|
112
|
+
expect(typeof result.code).toBe('string');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('rest_forbidden: write with bad credentials returns 401 or 403', async () => {
|
|
117
|
+
const badCreds = Buffer.from('nobody:wrongpassword').toString('base64');
|
|
118
|
+
const result = await rawRequest(
|
|
119
|
+
'patch',
|
|
120
|
+
`/posts/${livePostId}/blocks/${liveHeadingIndex}`,
|
|
121
|
+
{ attributes: { content: 'forbidden write' } },
|
|
122
|
+
badCreds
|
|
123
|
+
);
|
|
124
|
+
expect([401, 403]).toContain(result.status);
|
|
125
|
+
expect(result.message).toBeTruthy();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('invalid_path: mutate with out-of-range path returns 400 + path error code', async () => {
|
|
129
|
+
const result = await rawRequest('post', `/posts/${livePostId}/mutate`, {
|
|
130
|
+
op: 'update-attrs',
|
|
131
|
+
path: [999, 999, 999],
|
|
132
|
+
attributes: { content: 'never land' },
|
|
133
|
+
});
|
|
134
|
+
expect(result.status).toBe(400);
|
|
135
|
+
expect(['invalid_path', 'path_not_found', 'path_out_of_bounds']).toContain(result.code);
|
|
136
|
+
expect(result.message).toBeTruthy();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('legacy_block: inserting a ugb/text block returns 400 + legacy_block', async () => {
|
|
140
|
+
const result = await rawRequest('post', `/posts/${livePostId}/blocks`, {
|
|
141
|
+
after: 0,
|
|
142
|
+
blocks: [{ name: 'ugb/text', attributes: {}, innerHTML: '<div>legacy</div>' }],
|
|
143
|
+
});
|
|
144
|
+
expect(result.status).toBe(400);
|
|
145
|
+
expect(result.code).toBe('legacy_block');
|
|
146
|
+
expect(result.message).toBeTruthy();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('invalid_ref: updateBlockByRef with a made-up ref returns a structured error (when route exists)', async () => {
|
|
150
|
+
const byRefExists = await hasRoute('by-ref');
|
|
151
|
+
if (!byRefExists) {
|
|
152
|
+
console.log('[integration] by-ref route not present — skipping invalid_ref test');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const result = await rawRequest(
|
|
157
|
+
'patch',
|
|
158
|
+
`/posts/${livePostId}/blocks/by-ref/blk_00000000`,
|
|
159
|
+
{ attributes: { content: 'no such ref' } }
|
|
160
|
+
);
|
|
161
|
+
expect([400, 404]).toContain(result.status);
|
|
162
|
+
// The plugin returns `ref_stale` or `invalid_ref` depending on version.
|
|
163
|
+
if (result.code) {
|
|
164
|
+
expect(result.code).toMatch(/ref|not_found/i);
|
|
165
|
+
}
|
|
166
|
+
expect(result.message).toBeTruthy();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('invalid_block: inserting a non-registered block name returns 400', async () => {
|
|
170
|
+
const result = await rawRequest('post', `/posts/${livePostId}/blocks`, {
|
|
171
|
+
after: 0,
|
|
172
|
+
blocks: [{ name: 'nonexistent/totally-fake-block', attributes: {}, innerHTML: '<div>x</div>' }],
|
|
173
|
+
});
|
|
174
|
+
expect(result.status).toBe(400);
|
|
175
|
+
expect(result.message).toBeTruthy();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('inner_html_required: inserting a paragraph with content attr but no innerHTML returns 400', async () => {
|
|
179
|
+
// Regression for the "Block contains unexpected or invalid content" bug:
|
|
180
|
+
// attribute-only inserts of a source-bound block (core/paragraph) used
|
|
181
|
+
// to serialize as a self-closing comment; Gutenberg flagged the block on
|
|
182
|
+
// reload because the parsed DOM ("") disagreed with the saved attribute.
|
|
183
|
+
// The plugin now rejects up front with `inner_html_required` and lists
|
|
184
|
+
// the offending attribute names in `data.source_bound_attributes`.
|
|
185
|
+
const result = await rawRequest('post', `/posts/${livePostId}/blocks`, {
|
|
186
|
+
after: 0,
|
|
187
|
+
blocks: [{ name: 'core/paragraph', attributes: { content: 'should reject' } }],
|
|
188
|
+
});
|
|
189
|
+
expect(result.status).toBe(400);
|
|
190
|
+
expect(result.code).toBe('inner_html_required');
|
|
191
|
+
expect(result.message).toMatch(/innerHTML/i);
|
|
192
|
+
const data = result.data as any;
|
|
193
|
+
expect(data?.block).toBe('core/paragraph');
|
|
194
|
+
expect(data?.source_bound_attributes).toContain('content');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('inner_html_required: providing innerHTML alongside attributes is accepted (round-trip)', async () => {
|
|
198
|
+
// Negative-space check: the rejection above must not become a blanket
|
|
199
|
+
// "no attributes-only" — the canonical form (attrs + innerHTML) still
|
|
200
|
+
// succeeds, and the saved post_content carries non-self-closing markup
|
|
201
|
+
// that re-parses with the same content the agent sent.
|
|
202
|
+
const insertResult = await rawRequest('post', `/posts/${livePostId}/blocks`, {
|
|
203
|
+
after: 0,
|
|
204
|
+
blocks: [
|
|
205
|
+
{
|
|
206
|
+
name: 'core/paragraph',
|
|
207
|
+
attributes: { content: 'integration round-trip' },
|
|
208
|
+
innerHTML: '<p>integration round-trip</p>',
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
// POST returns 201 Created on success.
|
|
213
|
+
expect([200, 201]).toContain(insertResult.status);
|
|
214
|
+
|
|
215
|
+
// Read the post back through the typed MCP client so we get the
|
|
216
|
+
// canonical { blocks: [...] } shape regardless of REST wrapping.
|
|
217
|
+
const client = makeLiveClient();
|
|
218
|
+
const blocks = await client.getPageBlocks(livePostId);
|
|
219
|
+
const hit = blocks.blocks.find(
|
|
220
|
+
(b: any) => b.name === 'core/paragraph' && (b.innerHTML ?? '').includes('integration round-trip')
|
|
221
|
+
);
|
|
222
|
+
expect(hit).toBeTruthy();
|
|
223
|
+
expect(hit!.innerHTML).toContain('<p>');
|
|
224
|
+
expect(hit!.innerHTML).toContain('</p>');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Move cleanup to afterAll so the shared post is trashed even if any of
|
|
228
|
+
// the assertions above fail. As a regular `it()` it would skip on failure
|
|
229
|
+
// and leak the test post into the live site, polluting later runs.
|
|
230
|
+
afterAll(async () => {
|
|
231
|
+
if (!livePostId) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const client = makeLiveClient();
|
|
235
|
+
const result = await client.updatePost(livePostId, { status: 'trash' });
|
|
236
|
+
expect(result.success).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vitest globalSetup for integration tests.
|
|
3
|
+
*
|
|
4
|
+
* Vitest does NOT support a top-level `globalTeardown` config key — teardown
|
|
5
|
+
* must be returned from the globalSetup function. The default-exported
|
|
6
|
+
* function below runs once before the suite (no setup work yet) and returns
|
|
7
|
+
* a teardown that sweeps any throwaway posts that leaked due to unexpected
|
|
8
|
+
* process exit, test timeouts, or beforeAll failures. Only fires when env
|
|
9
|
+
* vars are set (cleanupTestPosts() is a no-op otherwise).
|
|
10
|
+
*/
|
|
11
|
+
import { cleanupTestPosts } from './setup.js';
|
|
12
|
+
|
|
13
|
+
export default function setup(): () => Promise<void> {
|
|
14
|
+
return async function teardown(): Promise<void> {
|
|
15
|
+
await cleanupTestPosts();
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration: rate-limit enforcement
|
|
3
|
+
*
|
|
4
|
+
* The plugin allows 10 writes/minute per post (sliding 60-second window).
|
|
5
|
+
* Firing 11 sequential writes to the same post must cause the 11th to return
|
|
6
|
+
* HTTP 429 with code `rate_limit_exceeded`.
|
|
7
|
+
*
|
|
8
|
+
* Determinism notes:
|
|
9
|
+
* - We drive real writes (not fake timers); the plugin's transient-based
|
|
10
|
+
* counter is authoritative.
|
|
11
|
+
* - We do NOT test bucket reset (that would require a real 60-second sleep).
|
|
12
|
+
* We verify only that the limit fires.
|
|
13
|
+
* - To avoid the retry logic in WordPressBlockClient absorbing the 429
|
|
14
|
+
* (the client *does* retry 429s as per isRetryable()), we fire raw axios
|
|
15
|
+
* calls for the overflow write so we see the raw 429.
|
|
16
|
+
*
|
|
17
|
+
* Cleanup: the throwaway post is trashed in teardown regardless of failure.
|
|
18
|
+
* The rate-limit transient expires on its own within 60 seconds.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import axios from 'axios';
|
|
23
|
+
import { makeLiveClient, skipUnlessLive, withTestPost, LIVE_ENV } from './setup.js';
|
|
24
|
+
|
|
25
|
+
const skip = skipUnlessLive();
|
|
26
|
+
|
|
27
|
+
const RATE_LIMIT = 10; // must match Block_CRUD::WRITE_LIMIT in the plugin
|
|
28
|
+
|
|
29
|
+
/** Fire a raw PATCH without the MCP client's retry logic. */
|
|
30
|
+
async function rawPatch(
|
|
31
|
+
postId: number,
|
|
32
|
+
blockIndex: number,
|
|
33
|
+
suffix: number
|
|
34
|
+
): Promise<{ status: number; code?: string }> {
|
|
35
|
+
const credentials = Buffer.from(
|
|
36
|
+
`${LIVE_ENV.user}:${LIVE_ENV.password}`
|
|
37
|
+
).toString('base64');
|
|
38
|
+
|
|
39
|
+
const url = `${LIVE_ENV.url.replace(/\/+$/, '')}/wp-json/gk-block-api/v1/posts/${postId}/blocks/${blockIndex}`;
|
|
40
|
+
|
|
41
|
+
const response = await axios.patch(
|
|
42
|
+
url,
|
|
43
|
+
{
|
|
44
|
+
attributes: { content: `Rate-limit write ${suffix}`, level: 2 },
|
|
45
|
+
innerHTML: `<h2 class="wp-block-heading">Rate-limit write ${suffix}</h2>`,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Basic ${credentials}`,
|
|
50
|
+
'Content-Type': 'application/json',
|
|
51
|
+
},
|
|
52
|
+
timeout: 15_000,
|
|
53
|
+
validateStatus: () => true,
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const body = response.data as Record<string, unknown> | undefined;
|
|
58
|
+
return {
|
|
59
|
+
status: response.status,
|
|
60
|
+
code: typeof body?.code === 'string' ? body.code : undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe.skipIf(skip)('rate-limit enforcement (integration)', () => {
|
|
65
|
+
it(`fires rate_limit_exceeded (429) after ${RATE_LIMIT} writes in 60 seconds`, async () => {
|
|
66
|
+
const client = makeLiveClient();
|
|
67
|
+
|
|
68
|
+
await withTestPost(client, async (postId) => {
|
|
69
|
+
const initial = await client.getPageBlocks(postId);
|
|
70
|
+
const heading = initial.blocks.find((b) => b.name === 'core/heading');
|
|
71
|
+
expect(heading).toBeDefined();
|
|
72
|
+
const headingIndex = heading!.index;
|
|
73
|
+
|
|
74
|
+
// Fire RATE_LIMIT writes via raw axios (no retry layer).
|
|
75
|
+
// We expect all of them to succeed (200).
|
|
76
|
+
for (let i = 1; i <= RATE_LIMIT; i++) {
|
|
77
|
+
const result = await rawPatch(postId, headingIndex, i);
|
|
78
|
+
expect(result.status, `Write #${i} should succeed`).toBe(200);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The (RATE_LIMIT + 1)-th write must hit the cap.
|
|
82
|
+
const overflow = await rawPatch(postId, headingIndex, RATE_LIMIT + 1);
|
|
83
|
+
expect(overflow.status).toBe(429);
|
|
84
|
+
expect(overflow.code).toBe('rate_limit_exceeded');
|
|
85
|
+
});
|
|
86
|
+
// Allow generous time: RATE_LIMIT sequential network round-trips + margin.
|
|
87
|
+
}, 120_000);
|
|
88
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration: read → edit → read verification (happy path)
|
|
3
|
+
*
|
|
4
|
+
* Creates a throwaway post, reads its blocks, mutates the heading, reads
|
|
5
|
+
* again, and asserts that:
|
|
6
|
+
* - The write succeeds (200, success: true).
|
|
7
|
+
* - revision_id advances after the write.
|
|
8
|
+
* - The heading content attribute reflects the new value on re-read.
|
|
9
|
+
* - If the plugin assigns gk_refs, the ref is stable across the edit.
|
|
10
|
+
* - If the plugin returns a `saved` snapshot, it matches the re-read.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { makeLiveClient, skipUnlessLive, withTestPost, hasRoute } from './setup.js';
|
|
15
|
+
|
|
16
|
+
const skip = skipUnlessLive();
|
|
17
|
+
|
|
18
|
+
describe.skipIf(skip)('read → edit → read (integration)', () => {
|
|
19
|
+
it('updates a heading block and verifies the change persists via get_page_blocks', async () => {
|
|
20
|
+
const client = makeLiveClient();
|
|
21
|
+
|
|
22
|
+
await withTestPost(client, async (postId) => {
|
|
23
|
+
// ── Step 1: initial read ───────────────────────────────────────
|
|
24
|
+
const before = await client.getPageBlocks(postId);
|
|
25
|
+
expect(before.blocks.length).toBeGreaterThanOrEqual(1);
|
|
26
|
+
|
|
27
|
+
const heading = before.blocks.find((b) => b.name === 'core/heading');
|
|
28
|
+
expect(heading).toBeDefined();
|
|
29
|
+
|
|
30
|
+
const headingIndex = heading!.index;
|
|
31
|
+
const headingRef = heading!.ref; // may be undefined on older plugin builds
|
|
32
|
+
|
|
33
|
+
// ── Step 2: mutate the heading ─────────────────────────────────
|
|
34
|
+
const updated = await client.updateBlock(postId, headingIndex, {
|
|
35
|
+
attributes: { content: 'Updated by integration test', level: 2 },
|
|
36
|
+
innerHTML: '<h2 class="wp-block-heading">Updated by integration test</h2>',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(updated.success).toBe(true);
|
|
40
|
+
expect(updated.revision_id).toBeGreaterThan(0);
|
|
41
|
+
// revision_id must advance IF a real save happened (before_revision_id = 0
|
|
42
|
+
// on posts with no prior revisions is valid on some WP configs).
|
|
43
|
+
if (updated.before_revision_id > 0) {
|
|
44
|
+
expect(updated.revision_id).toBeGreaterThan(updated.before_revision_id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If this plugin build returns a `saved` snapshot, validate it.
|
|
48
|
+
if (updated.saved) {
|
|
49
|
+
expect(updated.saved.inner_html).toContain('Updated by integration test');
|
|
50
|
+
expect(updated.saved.block_name).toBe('core/heading');
|
|
51
|
+
// Ref stability: if a ref was present before and the plugin returns
|
|
52
|
+
// saved.ref, they must match.
|
|
53
|
+
if (headingRef && updated.saved.ref) {
|
|
54
|
+
expect(updated.saved.ref).toBe(headingRef);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Step 3: re-read and verify the change landed on disk ───────
|
|
59
|
+
const after = await client.getPageBlocks(postId);
|
|
60
|
+
const updatedHeading = after.blocks.find((b) => b.name === 'core/heading');
|
|
61
|
+
expect(updatedHeading).toBeDefined();
|
|
62
|
+
// Content attribute must match what we wrote.
|
|
63
|
+
expect(updatedHeading!.attributes.content).toBe('Updated by integration test');
|
|
64
|
+
|
|
65
|
+
// If refs are present on both the before and after blocks, they must match.
|
|
66
|
+
if (headingRef && updatedHeading!.ref) {
|
|
67
|
+
expect(updatedHeading!.ref).toBe(headingRef);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// If the write response carried a saved snapshot, the flat_index must
|
|
71
|
+
// point at the same block we see in the re-read.
|
|
72
|
+
if (updated.saved) {
|
|
73
|
+
expect(updatedHeading!.index).toBe(updated.saved.flat_index);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}, 30_000);
|
|
77
|
+
|
|
78
|
+
it('get_block(ref) returns current snapshot for a block that has a ref', async () => {
|
|
79
|
+
// Only run this sub-test when the single-block fetch route exists.
|
|
80
|
+
// The single-block fetch endpoint is /posts/{id}/block (no trailing 's').
|
|
81
|
+
// Use the anchored regex form to avoid matching /posts/{id}/blocks.
|
|
82
|
+
const singleBlockRouteExists = await hasRoute('/block$');
|
|
83
|
+
if (!singleBlockRouteExists) {
|
|
84
|
+
console.log('[integration] /block single-fetch route not present — skipping get_block sub-test');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const client = makeLiveClient();
|
|
89
|
+
|
|
90
|
+
await withTestPost(client, async (postId) => {
|
|
91
|
+
// Read to get the heading.
|
|
92
|
+
const initial = await client.getPageBlocks(postId);
|
|
93
|
+
const heading = initial.blocks.find((b) => b.name === 'core/heading');
|
|
94
|
+
expect(heading).toBeDefined();
|
|
95
|
+
|
|
96
|
+
// Write an update.
|
|
97
|
+
await client.updateBlock(postId, heading!.index, {
|
|
98
|
+
attributes: { content: 'Ref-verified heading', level: 2 },
|
|
99
|
+
innerHTML: '<h2 class="wp-block-heading">Ref-verified heading</h2>',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Use get_block by flat index (always available even without ref support).
|
|
103
|
+
const single = await client.getBlock(postId, { flatIndex: heading!.index });
|
|
104
|
+
expect(single.success).toBe(true);
|
|
105
|
+
expect(single.saved.inner_html).toContain('Ref-verified heading');
|
|
106
|
+
});
|
|
107
|
+
}, 30_000);
|
|
108
|
+
|
|
109
|
+
it('get_block(ref) works when the block has a stable ref', async () => {
|
|
110
|
+
const singleBlockRouteExists = await hasRoute('/block$');
|
|
111
|
+
if (!singleBlockRouteExists) {
|
|
112
|
+
console.log('[integration] /block single-fetch route not present — skipping ref sub-test');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const client = makeLiveClient();
|
|
117
|
+
|
|
118
|
+
await withTestPost(client, async (postId) => {
|
|
119
|
+
const initial = await client.getPageBlocks(postId);
|
|
120
|
+
const heading = initial.blocks.find((b) => b.name === 'core/heading');
|
|
121
|
+
expect(heading).toBeDefined();
|
|
122
|
+
|
|
123
|
+
// If this plugin version assigns refs, verify ref-based fetch.
|
|
124
|
+
if (!heading!.ref) {
|
|
125
|
+
console.log('[integration] Block has no ref on this plugin build — skipping ref fetch');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const ref = heading!.ref;
|
|
130
|
+
await client.updateBlockByRef(postId, ref, {
|
|
131
|
+
attributes: { content: 'Ref-verified heading', level: 2 },
|
|
132
|
+
innerHTML: '<h2 class="wp-block-heading">Ref-verified heading</h2>',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const single = await client.getBlock(postId, { ref });
|
|
136
|
+
expect(single.success).toBe(true);
|
|
137
|
+
expect(single.saved.inner_html).toContain('Ref-verified heading');
|
|
138
|
+
expect(single.saved.ref).toBe(ref);
|
|
139
|
+
});
|
|
140
|
+
}, 30_000);
|
|
141
|
+
});
|