@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.
Files changed (70) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +26 -0
  3. package/README.md +592 -0
  4. package/dist/index.cjs +52721 -0
  5. package/package.json +70 -0
  6. package/src/__tests__/fixtures/block-trees.ts +199 -0
  7. package/src/__tests__/fixtures/error-envelopes.ts +115 -0
  8. package/src/__tests__/fixtures/rest-responses.ts +280 -0
  9. package/src/__tests__/helpers/mock-client.ts +185 -0
  10. package/src/__tests__/helpers/request-matchers.ts +88 -0
  11. package/src/__tests__/helpers/schema-asserts.ts +132 -0
  12. package/src/__tests__/integration/concurrency.test.ts +129 -0
  13. package/src/__tests__/integration/dual-storage.test.ts +156 -0
  14. package/src/__tests__/integration/error-envelopes.test.ts +238 -0
  15. package/src/__tests__/integration/global-setup.ts +17 -0
  16. package/src/__tests__/integration/rate-limit.test.ts +88 -0
  17. package/src/__tests__/integration/read-edit-read.test.ts +141 -0
  18. package/src/__tests__/integration/ref-stability.test.ts +175 -0
  19. package/src/__tests__/integration/setup.ts +201 -0
  20. package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
  21. package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
  22. package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
  23. package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
  24. package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
  25. package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
  26. package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
  27. package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
  28. package/src/__tests__/tools/media/upload_media.test.ts +123 -0
  29. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
  30. package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
  31. package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
  32. package/src/__tests__/tools/posts/create_post.test.ts +84 -0
  33. package/src/__tests__/tools/posts/update_post.test.ts +93 -0
  34. package/src/__tests__/tools/read/get_block.test.ts +96 -0
  35. package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
  36. package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
  37. package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
  38. package/src/__tests__/tools/write/delete_block.test.ts +91 -0
  39. package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
  40. package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
  41. package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
  42. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
  43. package/src/__tests__/tools/write/update_block.test.ts +206 -0
  44. package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
  45. package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
  46. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
  47. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
  48. package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
  49. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
  50. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
  51. package/src/__tests__/unit/instructions.test.ts +374 -0
  52. package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
  53. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
  54. package/src/client.ts +964 -0
  55. package/src/connect.ts +877 -0
  56. package/src/enrichers.ts +348 -0
  57. package/src/error-translator.ts +156 -0
  58. package/src/index.ts +450 -0
  59. package/src/instructions.ts +270 -0
  60. package/src/preferences.ts +273 -0
  61. package/src/tools/discovery.ts +251 -0
  62. package/src/tools/media.ts +75 -0
  63. package/src/tools/mutate.ts +243 -0
  64. package/src/tools/patterns.ts +94 -0
  65. package/src/tools/posts.ts +200 -0
  66. package/src/tools/read.ts +201 -0
  67. package/src/tools/terms.ts +44 -0
  68. package/src/tools/write.ts +542 -0
  69. package/src/tools/yoast.ts +224 -0
  70. package/src/types.ts +862 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Typed mock for WordPressBlockClient.
3
+ *
4
+ * Returns a vi.fn() stub for every public client method. Tests override
5
+ * individual methods with .mockResolvedValueOnce() as needed.
6
+ *
7
+ * Usage:
8
+ * import { makeMockClient } from '../helpers/mock-client.js';
9
+ * const client = makeMockClient();
10
+ * client.getPageBlocks.mockResolvedValueOnce(pageBlocksResponse);
11
+ */
12
+ import { vi } from 'vitest';
13
+
14
+ /** The full shape of the mock client returned by makeMockClient(). */
15
+ export type MockClient = ReturnType<typeof makeMockClient>;
16
+
17
+ /**
18
+ * Build a fresh mock client with all methods stubbed as vi.fn().
19
+ * Each method returns a resolved promise with a sensible empty/success value.
20
+ * Override per-test with .mockResolvedValueOnce().
21
+ */
22
+ export function makeMockClient() {
23
+ return {
24
+ // ── Discovery ────────────────────────────────────────────────────────
25
+ getBlockTypes: vi.fn().mockResolvedValue(
26
+ { block_types: [] }
27
+ ),
28
+ getPatterns: vi.fn().mockResolvedValue(
29
+ { patterns: [] }
30
+ ),
31
+ getPattern: vi.fn().mockResolvedValue({ id: 1, name: 'Test Pattern' }),
32
+ searchPatterns: vi.fn().mockResolvedValue(
33
+ { patterns: [] }
34
+ ),
35
+ getSiteUsage: vi.fn().mockResolvedValue(
36
+ { block_usage: {}, namespace_totals: {}, pattern_references: {}, legacy_patterns: [] }
37
+ ),
38
+ resolveUrl: vi.fn().mockResolvedValue({
39
+ post_id: 1,
40
+ post_type: 'page',
41
+ title: 'Test Page',
42
+ status: 'publish',
43
+ slug: 'test-page',
44
+ edit_url: 'https://example.test/wp-admin/post.php?post=1&action=edit',
45
+ }),
46
+ findPosts: vi.fn().mockResolvedValue(
47
+ { posts: [], count: 0, total: 0, total_pages: 0, page: 1, per_page: 20 }
48
+ ),
49
+ getPostInfo: vi.fn().mockResolvedValue({
50
+ post_id: 1, title: 'Test', slug: 'test', post_type: 'page',
51
+ post_status: 'publish', post_url: '', edit_url: '',
52
+ modified: '2026-01-01', created: '2026-01-01',
53
+ parent_id: 0, author: { id: 1, display_name: 'Admin' },
54
+ mime_type: '', comment_count: 0,
55
+ }),
56
+ scanStorageModes: vi.fn().mockResolvedValue({
57
+ scanned_posts: 0, unique_blocks: 0, classification: {},
58
+ dual_count: 0, dynamic_count: 0, static_count: 0,
59
+ }),
60
+
61
+ // ── Read ─────────────────────────────────────────────────────────────
62
+ getPageBlocks: vi.fn().mockResolvedValue(
63
+ { blocks: [], summary: undefined }
64
+ ),
65
+ getBlock: vi.fn().mockResolvedValue({
66
+ success: true,
67
+ saved: {
68
+ flat_index: 0, block_name: 'core/paragraph',
69
+ attributes: {}, inner_html: '<p></p>', is_dynamic: false,
70
+ },
71
+ }),
72
+
73
+ // ── Write ────────────────────────────────────────────────────────────
74
+ updateBlock: vi.fn().mockResolvedValue({
75
+ success: true,
76
+ block: { index: 0, name: 'core/paragraph', attributes: {} },
77
+ saved: {
78
+ flat_index: 0, block_name: 'core/paragraph',
79
+ attributes: {}, inner_html: '<p></p>', is_dynamic: false,
80
+ },
81
+ before_revision_id: 1,
82
+ revision_id: 2,
83
+ }),
84
+ updateBlockByRef: vi.fn().mockResolvedValue({
85
+ success: true,
86
+ block: { index: 0, name: 'core/paragraph', attributes: {}, ref: 'blk_test0001' },
87
+ saved: {
88
+ flat_index: 0, block_name: 'core/paragraph',
89
+ attributes: {}, inner_html: '<p></p>', is_dynamic: false, ref: 'blk_test0001',
90
+ },
91
+ before_revision_id: 1,
92
+ revision_id: 2,
93
+ }),
94
+ updateBlocksBatch: vi.fn().mockResolvedValue({
95
+ success: true, count: 0, results: [], before_revision_id: 1, revision_id: 2,
96
+ }),
97
+ insertBlocks: vi.fn().mockResolvedValue({
98
+ success: true,
99
+ inserted: [{ index: 0, name: 'core/paragraph' }],
100
+ warnings: [],
101
+ before_revision_id: 1,
102
+ revision_id: 2,
103
+ }),
104
+ deleteBlock: vi.fn().mockResolvedValue(
105
+ { success: true, removed: 1, before_revision_id: 1, revision_id: 2 }
106
+ ),
107
+ deleteBlockByRef: vi.fn().mockResolvedValue(
108
+ { success: true, removed: 1, before_revision_id: 1, revision_id: 2 }
109
+ ),
110
+ replaceBlocksRange: vi.fn().mockResolvedValue({
111
+ success: true, removed: 0,
112
+ inserted: [{ index: 0, name: 'core/paragraph' }],
113
+ warnings: [],
114
+ before_revision_id: 1, revision_id: 2,
115
+ }),
116
+ replaceAllBlocks: vi.fn().mockResolvedValue({
117
+ success: true,
118
+ inserted: [],
119
+ warnings: [],
120
+ before_revision_id: 1,
121
+ revision_id: 2,
122
+ }),
123
+ revertToRevision: vi.fn().mockResolvedValue({ success: true, revision_id: 1 }),
124
+
125
+ // ── Mutation ─────────────────────────────────────────────────────────
126
+ mutateBlockTree: vi.fn().mockResolvedValue({
127
+ success: true,
128
+ op: 'update-attrs' as const,
129
+ path: [0],
130
+ block: { name: 'core/paragraph', attributes: {} },
131
+ warnings: [],
132
+ before_revision_id: 1,
133
+ revision_id: 2,
134
+ }),
135
+
136
+ // ── Pattern ──────────────────────────────────────────────────────────
137
+ insertPattern: vi.fn().mockResolvedValue({
138
+ success: true,
139
+ inserted: { index: 0, name: 'core/block', attributes: { ref: 1 }, synced: true },
140
+ pattern_name: 'Test Pattern',
141
+ synced: true,
142
+ before_revision_id: 1,
143
+ revision_id: 2,
144
+ }),
145
+
146
+ // ── Post lifecycle ────────────────────────────────────────────────────
147
+ createPost: vi.fn().mockResolvedValue({
148
+ success: true, id: 1, post_type: 'post', status: 'draft',
149
+ title: 'Test Post', slug: 'test-post',
150
+ permalink: 'https://example.test/test-post/',
151
+ edit_link: '', before_revision_id: null, revision_id: null, warnings: [],
152
+ }),
153
+ updatePost: vi.fn().mockResolvedValue({
154
+ success: true, id: 1, post_type: 'post', status: 'publish',
155
+ title: 'Test Post', slug: 'test-post',
156
+ permalink: 'https://example.test/test-post/',
157
+ edit_link: '', before_revision_id: null, revision_id: null, warnings: [],
158
+ transitioned_to_publish: true,
159
+ }),
160
+
161
+ // ── Terms ─────────────────────────────────────────────────────────────
162
+ listTerms: vi.fn().mockResolvedValue(
163
+ { taxonomy: 'category', total: 0, page: 1, per_page: 100, terms: [] }
164
+ ),
165
+
166
+ // ── Media ─────────────────────────────────────────────────────────────
167
+ uploadMedia: vi.fn().mockResolvedValue({
168
+ success: true, id: 1, title: 'test.png', filename: 'test.png',
169
+ url: 'https://example.test/test.png',
170
+ source_url: 'https://example.test/test.png',
171
+ mime_type: 'image/png', alt_text: '', post_parent: 0,
172
+ }),
173
+
174
+ // ── Yoast ─────────────────────────────────────────────────────────────
175
+ getYoastSEO: vi.fn().mockResolvedValue({
176
+ post_id: 1, title: '', description: '', noindex: null,
177
+ seo_score: null, readability_score: null, inclusive_language_score: null,
178
+ }),
179
+ updateYoastSEO: vi.fn().mockResolvedValue({
180
+ post_id: 1, title: 'Updated', description: '', noindex: null,
181
+ seo_score: 80, readability_score: 70, inclusive_language_score: null,
182
+ }),
183
+ bulkUpdateYoastSEO: vi.fn().mockResolvedValue([]),
184
+ };
185
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Assertion helpers for tool-layer test assertions.
3
+ *
4
+ * Provide lean wrappers that express domain intent rather than raw vitest
5
+ * matcher chains, making test bodies more readable.
6
+ */
7
+ import { expect } from 'vitest';
8
+ import type { MockClient } from './mock-client.js';
9
+
10
+ // ── Tool result helpers ──────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Assert a tool result signals success and has the expected top-level keys.
14
+ */
15
+ export function assertToolSuccess(
16
+ result: unknown,
17
+ requiredKeys: string[] = ['success']
18
+ ): void {
19
+ expect(result).toBeDefined();
20
+ expect(typeof result).toBe('object');
21
+ const r = result as Record<string, unknown>;
22
+ for (const key of requiredKeys) {
23
+ expect(r, `result must have key "${key}"`).toHaveProperty(key);
24
+ }
25
+ if ('success' in r) {
26
+ expect(r.success).toBe(true);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Assert a tool result contains formatted_warnings with at least one entry
32
+ * that matches the expected substring.
33
+ */
34
+ export function assertHasFormattedWarning(result: unknown, containing: string): void {
35
+ const r = result as Record<string, unknown>;
36
+ expect(r.formatted_warnings, 'formatted_warnings must be defined').toBeDefined();
37
+ const warnings = r.formatted_warnings as string[];
38
+ expect(Array.isArray(warnings)).toBe(true);
39
+ expect(warnings.length).toBeGreaterThan(0);
40
+ const found = warnings.some((w) => w.includes(containing));
41
+ expect(found, `No warning contains "${containing}". Got: ${JSON.stringify(warnings)}`).toBe(true);
42
+ }
43
+
44
+ /**
45
+ * Assert a tool result has NO formatted_warnings (clean path).
46
+ */
47
+ export function assertNoFormattedWarnings(result: unknown): void {
48
+ const r = result as Record<string, unknown>;
49
+ expect(r.formatted_warnings).toBeUndefined();
50
+ }
51
+
52
+ // ── Client call helpers ──────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Assert a named client method was called exactly once with the given args.
56
+ * Passes args to toHaveBeenCalledWith — supports asymmetric matchers.
57
+ */
58
+ export function assertClientCalled(
59
+ client: MockClient,
60
+ method: keyof MockClient,
61
+ ...expectedArgs: unknown[]
62
+ ): void {
63
+ const fn = client[method] as ReturnType<typeof import('vitest').vi.fn>;
64
+ expect(fn).toHaveBeenCalledTimes(1);
65
+ if (expectedArgs.length > 0) {
66
+ expect(fn).toHaveBeenCalledWith(...expectedArgs);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Assert a named client method was NOT called.
72
+ */
73
+ export function assertClientNotCalled(client: MockClient, method: keyof MockClient): void {
74
+ const fn = client[method] as ReturnType<typeof import('vitest').vi.fn>;
75
+ expect(fn).not.toHaveBeenCalled();
76
+ }
77
+
78
+ // ── Revision pair helper ──────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Assert result has both revision IDs with before < after (sanity check).
82
+ */
83
+ export function assertRevisionPair(result: unknown): void {
84
+ const r = result as Record<string, unknown>;
85
+ expect(typeof r.before_revision_id, 'before_revision_id must be number').toBe('number');
86
+ expect(typeof r.revision_id, 'revision_id must be number').toBe('number');
87
+ expect(r.revision_id as number).toBeGreaterThan(r.before_revision_id as number);
88
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Runtime shape-checking helpers.
3
+ *
4
+ * Zero new dependencies — uses typeof + key-presence to validate objects
5
+ * match the core interfaces from src/types.ts.
6
+ *
7
+ * These are assertion helpers for tests: they throw (via Vitest's expect)
8
+ * when an object is missing required keys or has a key of the wrong primitive
9
+ * type.
10
+ *
11
+ * Usage:
12
+ * assertSavedBlock(result.saved);
13
+ * assertBlock(blocks[0]);
14
+ */
15
+ import { expect } from 'vitest';
16
+
17
+ // ── Generic key-presence check ───────────────────────────────────────────────
18
+
19
+ /**
20
+ * Assert that `value` is a non-null object containing all listed keys.
21
+ * Fails the test with a descriptive message when any key is absent.
22
+ */
23
+ export function assertHasKeys<T extends object>(
24
+ value: unknown,
25
+ keys: (keyof T)[],
26
+ label = 'object'
27
+ ): asserts value is T {
28
+ // toBeDefined() only fails on undefined; null passes silently AND
29
+ // `typeof null === 'object'` so the type check below would also pass,
30
+ // letting downstream code read keys off null and crash with a
31
+ // less-informative message. Reject null explicitly first.
32
+ expect(value, `${label} must not be null/undefined`).not.toBeNull();
33
+ expect(value, `${label} must not be null/undefined`).toBeDefined();
34
+ expect(typeof value, `${label} must be an object`).toBe('object');
35
+ const obj = value as unknown as Record<string, unknown>;
36
+ for (const key of keys as string[]) {
37
+ expect(obj, `${label} must have key "${key}"`).toHaveProperty(key);
38
+ }
39
+ }
40
+
41
+ // ── Interface-specific assertions ────────────────────────────────────────────
42
+
43
+ /**
44
+ * Assert `value` conforms to the SavedBlock interface.
45
+ * Checks: flat_index (number), block_name (string), attributes (object),
46
+ * inner_html (string), is_dynamic (boolean).
47
+ */
48
+ export function assertSavedBlock(value: unknown): void {
49
+ assertHasKeys(value, ['flat_index', 'block_name', 'attributes', 'inner_html', 'is_dynamic'], 'SavedBlock');
50
+ const b = value as unknown as Record<string, unknown>;
51
+ expect(typeof b['flat_index'], 'SavedBlock.flat_index must be number').toBe('number');
52
+ expect(typeof b['block_name'], 'SavedBlock.block_name must be string').toBe('string');
53
+ expect(typeof b['attributes'], 'SavedBlock.attributes must be object').toBe('object');
54
+ expect(typeof b['inner_html'], 'SavedBlock.inner_html must be string').toBe('string');
55
+ expect(typeof b['is_dynamic'], 'SavedBlock.is_dynamic must be boolean').toBe('boolean');
56
+ }
57
+
58
+ /**
59
+ * Assert `value` conforms to the Block interface.
60
+ * Checks: index (number), name (string), attributes (object).
61
+ */
62
+ export function assertBlock(value: unknown): void {
63
+ assertHasKeys(value, ['index', 'name', 'attributes'], 'Block');
64
+ const b = value as unknown as Record<string, unknown>;
65
+ expect(typeof b['index'], 'Block.index must be number').toBe('number');
66
+ expect(typeof b['name'], 'Block.name must be string').toBe('string');
67
+ expect(b['name'] as string).toMatch(/^\w[\w-]*\/[\w-]+$/);
68
+ expect(typeof b['attributes'], 'Block.attributes must be object').toBe('object');
69
+ }
70
+
71
+ /**
72
+ * Assert `value` conforms to BlockUpdateResponse.
73
+ */
74
+ export function assertBlockUpdateResponse(value: unknown): void {
75
+ assertHasKeys(value, ['success', 'block', 'saved', 'before_revision_id', 'revision_id'], 'BlockUpdateResponse');
76
+ const r = value as unknown as Record<string, unknown>;
77
+ expect(r['success']).toBe(true);
78
+ assertSavedBlock(r['saved']);
79
+ expect(typeof r['before_revision_id'], 'before_revision_id must be number').toBe('number');
80
+ expect(typeof r['revision_id'], 'revision_id must be number').toBe('number');
81
+ }
82
+
83
+ /**
84
+ * Assert `value` conforms to BlockWriteResponse (insert / replace-all).
85
+ */
86
+ export function assertBlockWriteResponse(value: unknown): void {
87
+ assertHasKeys(value, ['success', 'inserted', 'warnings', 'before_revision_id', 'revision_id'], 'BlockWriteResponse');
88
+ const r = value as unknown as Record<string, unknown>;
89
+ expect(r['success']).toBe(true);
90
+ expect(Array.isArray(r['inserted']), 'inserted must be an array').toBe(true);
91
+ expect(Array.isArray(r['warnings']), 'warnings must be an array').toBe(true);
92
+ }
93
+
94
+ /**
95
+ * Assert `value` conforms to MutationResponse.
96
+ */
97
+ export function assertMutationResponse(value: unknown): void {
98
+ assertHasKeys(value, ['success', 'op', 'path', 'warnings', 'before_revision_id', 'revision_id'], 'MutationResponse');
99
+ const r = value as unknown as Record<string, unknown>;
100
+ expect(r['success']).toBe(true);
101
+ expect(typeof r['op']).toBe('string');
102
+ expect(Array.isArray(r['path'])).toBe(true);
103
+ }
104
+
105
+ /**
106
+ * Assert `value` is a valid PreferenceWarning.
107
+ */
108
+ export function assertPreferenceWarning(value: unknown): void {
109
+ assertHasKeys(value, ['block', 'message'], 'PreferenceWarning');
110
+ const w = value as unknown as Record<string, unknown>;
111
+ expect(typeof w['block']).toBe('string');
112
+ expect(typeof w['message']).toBe('string');
113
+ }
114
+
115
+ /**
116
+ * Assert `value` conforms to PostMutationResponse.
117
+ */
118
+ export function assertPostMutationResponse(value: unknown): void {
119
+ assertHasKeys(value, ['success', 'id', 'post_type', 'status', 'title', 'slug', 'permalink'], 'PostMutationResponse');
120
+ const r = value as unknown as Record<string, unknown>;
121
+ expect(r['success']).toBe(true);
122
+ expect(typeof r['id']).toBe('number');
123
+ }
124
+
125
+ /**
126
+ * Assert `value` conforms to YoastSEOMeta.
127
+ */
128
+ export function assertYoastSEOMeta(value: unknown): void {
129
+ assertHasKeys(value, ['post_id'], 'YoastSEOMeta');
130
+ const r = value as unknown as Record<string, unknown>;
131
+ expect(typeof r['post_id']).toBe('number');
132
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Integration: ETag / If-Match stale-revision rejection
3
+ *
4
+ * The plugin's PATCH endpoint accepts an `if_match` body field carrying a
5
+ * revision_id. Sending two writes with the SAME revision_id as the pre-edit
6
+ * baseline should cause the second write to be rejected with HTTP 412
7
+ * (stale_revision) because the first write already bumped the revision.
8
+ *
9
+ * Capability detection: if the live plugin does not enforce `if_match` (i.e.
10
+ * the second write with a stale revision succeeds with 200), we emit a
11
+ * console.log and the test passes — the plugin just doesn't implement this
12
+ * guard yet. We don't assert a hard failure because the gkclone site may be
13
+ * running an older plugin version that pre-dates this feature.
14
+ *
15
+ * The "correct If-Match succeeds" test is always valid regardless of version.
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import axios from 'axios';
20
+ import { makeLiveClient, skipUnlessLive, withTestPost, LIVE_ENV } from './setup.js';
21
+
22
+ const skip = skipUnlessLive();
23
+
24
+ /** Fire a raw PATCH to /posts/{id}/blocks/{index} with an explicit if_match field. */
25
+ async function patchBlockWithIfMatch(
26
+ postId: number,
27
+ blockIndex: number,
28
+ attributes: Record<string, unknown>,
29
+ innerHTML: string,
30
+ ifMatch: number
31
+ ): Promise<{ status: number; data: unknown }> {
32
+ const credentials = Buffer.from(
33
+ `${LIVE_ENV.user}:${LIVE_ENV.password}`
34
+ ).toString('base64');
35
+
36
+ const url = `${LIVE_ENV.url.replace(/\/+$/, '')}/wp-json/gk-block-api/v1/posts/${postId}/blocks/${blockIndex}`;
37
+
38
+ const response = await axios.patch(
39
+ url,
40
+ { attributes, innerHTML, if_match: ifMatch },
41
+ {
42
+ headers: {
43
+ Authorization: `Basic ${credentials}`,
44
+ 'Content-Type': 'application/json',
45
+ },
46
+ timeout: 15_000,
47
+ // Don't throw on 4xx so we can inspect the status.
48
+ validateStatus: () => true,
49
+ }
50
+ );
51
+ return { status: response.status, data: response.data };
52
+ }
53
+
54
+ describe.skipIf(skip)('ETag / If-Match concurrency (integration)', () => {
55
+ it('second write with stale revision_id is rejected with 412 (or plugin does not enforce)', async () => {
56
+ const client = makeLiveClient();
57
+
58
+ await withTestPost(client, async (postId) => {
59
+ // Step 1: read to get the current revision baseline.
60
+ const initial = await client.getPageBlocks(postId);
61
+ const heading = initial.blocks.find((b) => b.name === 'core/heading');
62
+ expect(heading).toBeDefined();
63
+ const headingIndex = heading!.index;
64
+
65
+ // Step 2: first write — succeeds and bumps the revision.
66
+ const firstWrite = await client.updateBlock(postId, headingIndex, {
67
+ attributes: { content: 'First write — takes the slot', level: 2 },
68
+ innerHTML: '<h2 class="wp-block-heading">First write — takes the slot</h2>',
69
+ });
70
+ expect(firstWrite.success).toBe(true);
71
+ const baseRevision = firstWrite.before_revision_id;
72
+
73
+ // Step 3: second write with the ORIGINAL (now-stale) revision_id.
74
+ const secondWrite = await patchBlockWithIfMatch(
75
+ postId,
76
+ headingIndex,
77
+ { content: 'Second write — should be rejected', level: 2 },
78
+ '<h2 class="wp-block-heading">Second write — should be rejected</h2>',
79
+ baseRevision // stale: the first write already consumed this revision
80
+ );
81
+
82
+ if (secondWrite.status === 412) {
83
+ // Plugin enforces If-Match — verify the error code.
84
+ const body = secondWrite.data as Record<string, unknown>;
85
+ expect(body.code).toBe('stale_revision');
86
+ } else {
87
+ // Plugin version doesn't enforce if_match yet — acceptable.
88
+ // We still assert the write didn't crash unexpectedly.
89
+ expect([200, 201]).toContain(secondWrite.status);
90
+ console.log(
91
+ '[integration] if_match not enforced on this plugin build ' +
92
+ `(got ${secondWrite.status}, expected 412) — stale_revision guard not present`
93
+ );
94
+ }
95
+ });
96
+ }, 30_000);
97
+
98
+ it('write with correct current revision_id succeeds', async () => {
99
+ const client = makeLiveClient();
100
+
101
+ await withTestPost(client, async (postId) => {
102
+ const initial = await client.getPageBlocks(postId);
103
+ const heading = initial.blocks.find((b) => b.name === 'core/heading');
104
+ expect(heading).toBeDefined();
105
+ const headingIndex = heading!.index;
106
+
107
+ // First write — capture its revision_id for use as If-Match.
108
+ const firstWrite = await client.updateBlock(postId, headingIndex, {
109
+ attributes: { content: 'Write A', level: 2 },
110
+ innerHTML: '<h2 class="wp-block-heading">Write A</h2>',
111
+ });
112
+ expect(firstWrite.success).toBe(true);
113
+ const currentRevision = firstWrite.revision_id;
114
+
115
+ // Second write passing the CURRENT revision_id — must succeed.
116
+ const secondWrite = await patchBlockWithIfMatch(
117
+ postId,
118
+ headingIndex,
119
+ { content: 'Write B — with correct If-Match', level: 2 },
120
+ '<h2 class="wp-block-heading">Write B — with correct If-Match</h2>',
121
+ currentRevision
122
+ );
123
+
124
+ expect(secondWrite.status).toBe(200);
125
+ const body = secondWrite.data as Record<string, unknown>;
126
+ expect(body.success).toBe(true);
127
+ });
128
+ }, 30_000);
129
+ });