@pixlcore/xycli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +543 -0
- package/docs/help.md +2587 -0
- package/docs/screenshots.md +219 -0
- package/docs/sync.md +920 -0
- package/docs/tutorial.md +204 -0
- package/index.js +444 -0
- package/lib/alerts.js +735 -0
- package/lib/apikey.js +425 -0
- package/lib/buckets.js +607 -0
- package/lib/categories.js +323 -0
- package/lib/channels.js +290 -0
- package/lib/config.js +86 -0
- package/lib/dashboard.js +237 -0
- package/lib/doc.js +131 -0
- package/lib/events.js +1314 -0
- package/lib/groups.js +1038 -0
- package/lib/jobs.js +1254 -0
- package/lib/log.js +125 -0
- package/lib/marketplace.js +386 -0
- package/lib/monitors.js +412 -0
- package/lib/plugins.js +491 -0
- package/lib/secrets.js +441 -0
- package/lib/servers.js +1502 -0
- package/lib/snapshots.js +498 -0
- package/lib/sync.js +963 -0
- package/lib/system.js +612 -0
- package/lib/tags.js +250 -0
- package/lib/tickets.js +681 -0
- package/lib/transfer.js +447 -0
- package/lib/utils.js +2745 -0
- package/lib/webhook.js +414 -0
- package/logo.webp +0 -0
- package/package.json +38 -0
- package/test/README.md +97 -0
- package/test/api-validation.test.js +170 -0
- package/test/categories.test.js +246 -0
- package/test/channels.test.js +209 -0
- package/test/config.test.js +96 -0
- package/test/doc.test.js +84 -0
- package/test/groups.test.js +211 -0
- package/test/help.test.js +26 -0
- package/test/helpers/common.js +127 -0
- package/test/helpers/pagination-preload.cjs +18 -0
- package/test/log.test.js +103 -0
- package/test/markdown.test.js +61 -0
- package/test/marketplace.test.js +229 -0
- package/test/monitors.test.js +227 -0
- package/test/pagination.test.js +138 -0
- package/test/plugins.test.js +351 -0
- package/test/secrets.test.js +271 -0
- package/test/servers.test.js +303 -0
- package/test/snapshots.test.js +167 -0
- package/test/sync-unit.test.js +446 -0
- package/test/sync.test.js +542 -0
- package/test/system.test.js +94 -0
- package/test/tags.test.js +230 -0
- package/test/tickets.test.js +261 -0
- package/test/toast.test.js +73 -0
- package/test/transfer-apikey.test.js +96 -0
- package/test/transfer-workflow.test.js +122 -0
- package/test/transfer.test.js +314 -0
- package/test/webhook.test.js +332 -0
- package/test.js +49 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const Path = require('node:path');
|
|
5
|
+
const utils = require('../lib/utils.js');
|
|
6
|
+
const { loadTestConfig, createCheck, createTempDir, xy, json, call, cleanupFixtures } = require('./helpers/common.js');
|
|
7
|
+
|
|
8
|
+
function createValidationContext() {
|
|
9
|
+
// The production app mixes every utility into one object. Reproduce only the
|
|
10
|
+
// fatal-error behavior needed to exercise the validator without starting CLI.
|
|
11
|
+
return {
|
|
12
|
+
...utils,
|
|
13
|
+
die(message) { throw new Error(message); }
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('Levenshtein distance handles edits and empty strings', () => {
|
|
18
|
+
assert.equal(utils.getLevenshteinDistance('notes', 'notess'), 1);
|
|
19
|
+
assert.equal(utils.getLevenshteinDistance('kitten', 'sitting'), 3);
|
|
20
|
+
assert.equal(utils.getLevenshteinDistance('title', 'titel'), 2);
|
|
21
|
+
assert.equal(utils.getLevenshteinDistance('', 'event'), 5);
|
|
22
|
+
assert.equal(utils.getLevenshteinDistance('plugin', 'plugin'), 0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('closest string suggestions require one unambiguous nearby match', () => {
|
|
26
|
+
assert.equal(utils.findClosestString('notess', ['id', 'title', 'notes']), 'notes');
|
|
27
|
+
assert.equal(utils.findClosestString('titel', ['id', 'title', 'notes']), 'title');
|
|
28
|
+
assert.equal(utils.findClosestString('completely_unknown', ['id', 'title', 'notes']), '');
|
|
29
|
+
assert.equal(utils.findClosestString('a', ['b', 'c']), '');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('body arguments always remain opaque strings', t => {
|
|
33
|
+
loadTestConfig();
|
|
34
|
+
const temp = createTempDir(t, 'xycli-body-arg-test-');
|
|
35
|
+
const body = '{\n\t"message": "opaque JSON text"\n}\n';
|
|
36
|
+
const file = Path.join(temp, 'body.json');
|
|
37
|
+
fs.writeFileSync(file, body);
|
|
38
|
+
|
|
39
|
+
assert.equal(json(['api', 'unused', '--body', body, '--dry']).body, body);
|
|
40
|
+
assert.equal(json(['api', 'unused', '--body', '@-', '--dry'], { input: body }).body, body);
|
|
41
|
+
assert.equal(json(['api', 'unused', '--body', '@' + file, '--dry']).body, body);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('standard API validation accepts documented and request-only properties', () => {
|
|
45
|
+
const context = createValidationContext();
|
|
46
|
+
const requests = {
|
|
47
|
+
createAlert: { title: 'Alert', expression: '1', message: 'Yes', exclusive_actions: true },
|
|
48
|
+
updateApiKey: { id: 'key', max_per_sec: 5, description: 'Build key' },
|
|
49
|
+
createBucket: { title: 'Bucket', data: { arbitrary_nested_key: true } },
|
|
50
|
+
updateCategory: { id: 'category', actions: [{ arbitrary_nested_key: true }] },
|
|
51
|
+
createChannel: { title: 'Channel', max_per_day: 10 },
|
|
52
|
+
updateEvent: { id: 'event', params: { notess: 'Nested keys are allowed' }, update_state: { cursor: 1234 } },
|
|
53
|
+
createMonitor: { title: 'Monitor', source: 'cpu.currentLoad', divide_by_delta: true },
|
|
54
|
+
updatePlugin: { id: 'plugin', marketplace: { id: 'author/repo', version: 'v1.0.0' } },
|
|
55
|
+
createSecret: { title: 'Vault', fields: [{ name: 'TOKEN', value: 'Opaque nested value' }], web_hooks: ['hook'] },
|
|
56
|
+
updateTag: { id: 'tag', icon: 'tag-outline', notes: 'Tag notes' },
|
|
57
|
+
createTicket: { subject: 'Ticket', status: 'draft', body: '{ "opaque": true }', assignees: ['admin'] },
|
|
58
|
+
createWebHook: { title: 'Hook', url: 'https://example.com', method: 'POST', headers: [{ name: 'X-Test', value: 'yes' }] }
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
Object.entries(requests).forEach( ([method, request]) => {
|
|
62
|
+
assert.doesNotThrow(() => context.validateStandardAPIRequest(method, request), method);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// APIs outside the create/update CRUD rules retain normal pass-through behavior.
|
|
66
|
+
assert.doesNotThrow(() => context.validateStandardAPIRequest('runEvent', { arbitrary: true }));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('standard API validation rejects unknown properties with careful suggestions', () => {
|
|
70
|
+
const context = createValidationContext();
|
|
71
|
+
assert.throws(
|
|
72
|
+
() => context.validateStandardAPIRequest('updateEvent', { id: 'event', notess: 'Typo' }),
|
|
73
|
+
/Unsupported property for update_event: "notess"\. Did you mean "notes"\?/
|
|
74
|
+
);
|
|
75
|
+
assert.throws(
|
|
76
|
+
() => context.validateStandardAPIRequest('createPlugin', { title: 'Plugin', completely_unknown: true }),
|
|
77
|
+
/^Error: Unsupported property for create_plugin: "completely_unknown"\.$/
|
|
78
|
+
);
|
|
79
|
+
assert.throws(
|
|
80
|
+
() => context.validateStandardAPIRequest('updateEvent', { id: 'event', sync: true }),
|
|
81
|
+
/Unsupported property for update_event: "sync"\./
|
|
82
|
+
);
|
|
83
|
+
assert.throws(
|
|
84
|
+
() => context.validateStandardAPIRequest('updateSecret', { id: 'vault', notess: 'Typo' }),
|
|
85
|
+
/Unsupported property for update_secret: "notess"\. Did you mean "notes"\?/
|
|
86
|
+
);
|
|
87
|
+
assert.throws(
|
|
88
|
+
() => context.validateStandardAPIRequest('createTag', { title: 'Tag', notess: 'Typo' }),
|
|
89
|
+
/Unsupported property for create_tag: "notess"\. Did you mean "notes"\?/
|
|
90
|
+
);
|
|
91
|
+
assert.throws(
|
|
92
|
+
() => context.validateStandardAPIRequest('updateTicket', { id: 'ticket', subjec: 'Typo' }),
|
|
93
|
+
/Unsupported property for update_ticket: "subjec"\. Did you mean "subject"\?/
|
|
94
|
+
);
|
|
95
|
+
assert.throws(
|
|
96
|
+
() => context.validateStandardAPIRequest('updateWebHook', { id: 'hook', notess: 'Typo' }),
|
|
97
|
+
/Unsupported property for update_web_hook: "notess"\. Did you mean "notes"\?/
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('event CLI rejects phantom top-level properties before persistence', async t => {
|
|
102
|
+
loadTestConfig();
|
|
103
|
+
const check = createCheck(t);
|
|
104
|
+
const stamp = Date.now();
|
|
105
|
+
const prefix = 'cli_api_validation_' + stamp;
|
|
106
|
+
const invalidID = prefix + '_invalid';
|
|
107
|
+
const eventID = prefix + '_event';
|
|
108
|
+
const title = 'CLI API Validation ' + stamp;
|
|
109
|
+
const eventIDs = new Set([invalidID, eventID]);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const multiple = await call('getMultiple', { lists: 'all' });
|
|
113
|
+
const category = multiple.categories[0];
|
|
114
|
+
const plugin = multiple.plugins.find( plugin => plugin.type == 'event' );
|
|
115
|
+
const group = multiple.groups[0];
|
|
116
|
+
assert.ok(category && plugin && group, 'Category, Event Plugin and server group required');
|
|
117
|
+
|
|
118
|
+
const baseArgs = [
|
|
119
|
+
'--title', title, '--enabled', 'false', '--category', category.id,
|
|
120
|
+
'--plugin', plugin.id, '--targets', JSON.stringify([group.id]), '--triggers', '[]',
|
|
121
|
+
'--params.seed', 'Original value'
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
await check('create typo is rejected with a suggestion', () => {
|
|
125
|
+
const output = xy(['event', 'create', '--id', invalidID, ...baseArgs, '--notess', 'Typo'], { fail: true });
|
|
126
|
+
assert.match(output, /Unsupported property for create_event: "notess"\. Did you mean "notes"\?/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await check('rejected create persists nothing', async () => {
|
|
130
|
+
const rows = (await call('getEvents', {})).rows;
|
|
131
|
+
assert.ok(!rows.some(event => event.id == invalidID));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
xy(['event', 'create', '--id', eventID, ...baseArgs, '--notes', 'Original notes']);
|
|
135
|
+
await call('updateEvent', { id: eventID, future_property: { enabled: true } });
|
|
136
|
+
|
|
137
|
+
await check('update typo is rejected with a suggestion', () => {
|
|
138
|
+
const output = xy(['event', 'update', eventID, '--notess', 'Replacement notes'], { fail: true });
|
|
139
|
+
assert.match(output, /Unsupported property for update_event: "notess"\. Did you mean "notes"\?/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await check('rejected update leaves the stored event clean', () => {
|
|
143
|
+
const event = json(['event', eventID]);
|
|
144
|
+
assert.equal(event.notes, 'Original notes');
|
|
145
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(event, 'notess'));
|
|
146
|
+
assert.deepEqual(event.future_property, { enabled: true });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await check('sparse update ignores and preserves future server properties', () => {
|
|
150
|
+
xy(['event', 'update', eventID, '--notes', 'Replacement notes']);
|
|
151
|
+
const event = json(['event', eventID]);
|
|
152
|
+
assert.equal(event.notes, 'Replacement notes');
|
|
153
|
+
assert.deepEqual(event.future_property, { enabled: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await check('nested arbitrary properties preserve their saved siblings', () => {
|
|
157
|
+
const request = json(['event', 'update', eventID, '--params.notess', 'Nested value', '--dry']);
|
|
158
|
+
assert.deepEqual(request, {
|
|
159
|
+
params: { seed: 'Original value', notess: 'Nested value' },
|
|
160
|
+
id: eventID
|
|
161
|
+
});
|
|
162
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(request, 'future_property'));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await cleanupFixtures([
|
|
167
|
+
{ list: 'events', method: 'deleteEvent', match: event => eventIDs.has(event.id) }
|
|
168
|
+
]);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const Path = require('node:path');
|
|
5
|
+
const { loadTestConfig, createCheck, createTempDir, xy, json, call, cleanupFixtures } = require('./helpers/common.js');
|
|
6
|
+
|
|
7
|
+
test('categories', async t => {
|
|
8
|
+
loadTestConfig();
|
|
9
|
+
const check = createCheck(t);
|
|
10
|
+
|
|
11
|
+
// Disposable CLI integration checks against the configured local xyOps server.
|
|
12
|
+
const id = 'cli_cat_test_' + Date.now();
|
|
13
|
+
const title = 'CLI Category Test ' + id;
|
|
14
|
+
const temp = createTempDir(t, 'xycli-category-test-');
|
|
15
|
+
const categories = new Set([id]);
|
|
16
|
+
const events = new Set();
|
|
17
|
+
|
|
18
|
+
async function get() {
|
|
19
|
+
return (await call('getCategory', { id })).category;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const action = { type: 'email', enabled: true, condition: 'success', users: ['admin'] };
|
|
23
|
+
const limit = { type: 'time', enabled: true, duration: 300, abort: true };
|
|
24
|
+
let preview = json(['category', 'create', '--id', id, '--title', title, '--color', 'blue', '--limit', JSON.stringify(limit), '--dry']);
|
|
25
|
+
|
|
26
|
+
await check('dry create retains named color and resource limit', () => {
|
|
27
|
+
assert.equal(preview.color, 'blue');
|
|
28
|
+
assert.deepEqual(preview.limits, [limit]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await check('dry create makes no category', () => assert.equal(json(['categories', '--id', id]).length, 0));
|
|
32
|
+
|
|
33
|
+
let created = json(['category', 'create', '--id', id, '--title', title, '--notes', 'Hello', '--color', 'blue', '--icon', 'folder-outline', '--action', JSON.stringify(action), '--limit', JSON.stringify(limit)]);
|
|
34
|
+
|
|
35
|
+
await check('create persists defaults and complete configuration', () => {
|
|
36
|
+
assert.equal(created.id, id);
|
|
37
|
+
assert.equal(created.enabled, true);
|
|
38
|
+
assert.equal(created.color, 'blue');
|
|
39
|
+
assert.equal(created.notes, 'Hello');
|
|
40
|
+
assert.equal(created.revision, 1);
|
|
41
|
+
assert.deepEqual(created.actions, [action]);
|
|
42
|
+
assert.deepEqual(created.limits, [limit]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await check('get by exact ID', () => assert.deepEqual(json(['category', id]), created));
|
|
46
|
+
|
|
47
|
+
await check('get by fuzzy title', () => assert.equal(json(['category', 'get', '--title', title.toLowerCase()]).id, id));
|
|
48
|
+
|
|
49
|
+
await check('list search and combined filters', () => assert.equal(json(['categories', title, '--enabled', 'true', '--color', 'blue']).length, 1));
|
|
50
|
+
|
|
51
|
+
await check('list no matches', () => assert.deepEqual(json(['categories', id, '--enabled', 'false']), []));
|
|
52
|
+
|
|
53
|
+
await check('list sort order', () => {
|
|
54
|
+
const rows = json(['categories']);
|
|
55
|
+
assert.ok(rows.every((row, idx) => !idx || (row.sort_order || 0) >= (rows[idx - 1].sort_order || 0)));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await check('human pagination and suggestions', () => {
|
|
59
|
+
const output = xy(['category', 'list', '--limit', '1', '--page', '2']);
|
|
60
|
+
assert.match(output, /page 2 of/);
|
|
61
|
+
assert.match(output, /Other Commands:/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let detail = xy(['category', id]);
|
|
65
|
+
fs.writeFileSync(Path.join(temp, 'category-detail.txt'), detail);
|
|
66
|
+
|
|
67
|
+
await check('human detail renders indexed actions and limits', () => {
|
|
68
|
+
assert.match(detail, /CATEGORY NOTES[\s\S]*Hello/);
|
|
69
|
+
assert.match(detail, /CATEGORY ACTIONS/);
|
|
70
|
+
assert.match(detail, /CATEGORY LIMITS/);
|
|
71
|
+
assert.match(detail, /│ # │/);
|
|
72
|
+
assert.match(detail, /Send Email/);
|
|
73
|
+
assert.match(detail, /Max Run Time/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await check('human update shows target and parsed data', () => {
|
|
77
|
+
const output = xy(['category', 'update', id, '--notes', 'Preview', '--dry']);
|
|
78
|
+
assert.match(output, /UPDATE CATEGORY/);
|
|
79
|
+
assert.match(output, new RegExp('Category ID:\\s+' + id));
|
|
80
|
+
assert.match(output, /UPDATE DATA[\s\S]*"notes": "Preview"/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
preview = json(['category', 'update', id, '--notes', 'Preview', '--dry']);
|
|
84
|
+
|
|
85
|
+
await check('sparse update request excludes unrelated metadata', () => assert.deepEqual(preview, { id, notes: 'Preview' }));
|
|
86
|
+
|
|
87
|
+
await check('dry update leaves revision unchanged', () => assert.equal(json(['category', id]).revision, 1));
|
|
88
|
+
|
|
89
|
+
json(['category', 'update', id, '--enabled', 'false', '--actions.0.condition', 'complete', '--actions.0.enabled', 'true', '--limits.0.duration', '600']);
|
|
90
|
+
let updated = await get();
|
|
91
|
+
|
|
92
|
+
await check('dotted update preserves other fields and increments revision', () => {
|
|
93
|
+
assert.equal(updated.enabled, false);
|
|
94
|
+
assert.equal(updated.revision, 2);
|
|
95
|
+
assert.equal(updated.created, created.created);
|
|
96
|
+
assert.deepEqual(updated.actions, [{ ...action, condition: 'complete' }]);
|
|
97
|
+
assert.deepEqual(updated.limits, [{ ...limit, duration: 600 }]);
|
|
98
|
+
assert.equal(updated.notes, 'Hello');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
json(['category', 'update', id, '--action', JSON.stringify(action), '--limit', JSON.stringify(limit)]);
|
|
102
|
+
updated = await get();
|
|
103
|
+
|
|
104
|
+
await check('singular append preserves existing entries', () => {
|
|
105
|
+
assert.equal(updated.actions.length, 2);
|
|
106
|
+
assert.equal(updated.limits.length, 2);
|
|
107
|
+
assert.equal(updated.limits[0].duration, 600);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
json(['category', 'update', '--id', id, '--action', JSON.stringify(action), '--action', JSON.stringify({ ...action, condition: 'error' }), '--limit', JSON.stringify(limit), '--limit', JSON.stringify({ ...limit, duration: 900 })]);
|
|
111
|
+
updated = await get();
|
|
112
|
+
|
|
113
|
+
await check('repeated singular appends', () => {
|
|
114
|
+
assert.equal(updated.actions.length, 4);
|
|
115
|
+
assert.equal(updated.limits.length, 4);
|
|
116
|
+
assert.equal(updated.limits[3].duration, 900);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
json(['category', 'update', id, '--actions.0.condition', 'warning', '--actions', JSON.stringify([action]), '--limits', '[]']);
|
|
120
|
+
updated = await get();
|
|
121
|
+
|
|
122
|
+
await check('array replacement precedes dotted edits regardless of order', () => {
|
|
123
|
+
assert.deepEqual(updated.actions, [{ ...action, condition: 'warning' }]);
|
|
124
|
+
assert.deepEqual(updated.limits, []);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
json(['category', 'update', id, '--actions', '[]', '--limits', '[]']);
|
|
128
|
+
|
|
129
|
+
await check('arrays can be cleared', () => assert.deepEqual(json(['category', id]).actions, []));
|
|
130
|
+
|
|
131
|
+
const actionFile = Path.join(temp, 'action.json');
|
|
132
|
+
fs.writeFileSync(actionFile, JSON.stringify(action));
|
|
133
|
+
json(['category', 'update', id, '--action', '@' + actionFile, '--limit', '@-'], { input: JSON.stringify(limit) });
|
|
134
|
+
|
|
135
|
+
await check('file action and stdin limit', () => {
|
|
136
|
+
const row = json(['category', id]);
|
|
137
|
+
assert.deepEqual(row.actions, [action]);
|
|
138
|
+
assert.deepEqual(row.limits, [limit]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
json(['category', 'update', id, '--json', '@-'], { input: JSON.stringify({ notes: 'From stdin', color: 'green', sort_order: 10000 }) });
|
|
142
|
+
|
|
143
|
+
await check('complete stdin request and sort order', () => {
|
|
144
|
+
const row = json(['category', id]);
|
|
145
|
+
assert.equal(row.notes, 'From stdin');
|
|
146
|
+
assert.equal(row.color, 'green');
|
|
147
|
+
assert.equal(row.sort_order, 10000);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
json(['category', 'update', id, '--color', 'purple']);
|
|
151
|
+
|
|
152
|
+
await check('named color survives direct update', () => assert.equal(json(['category', id]).color, 'purple'));
|
|
153
|
+
|
|
154
|
+
const revision = (await get()).revision;
|
|
155
|
+
await check('unconfirmed delete shows target and warning toast', () => {
|
|
156
|
+
for (const args of [['delete', id], ['delete', id, '--confirm', 'false']]) {
|
|
157
|
+
const output = xy(['category', ...args]);
|
|
158
|
+
assert.match(output, /DELETE CATEGORY/);
|
|
159
|
+
assert.match(output, /⚠️[\s\S]*Please confirm the category delete/);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
for (const args of [
|
|
164
|
+
['update', id, '--actions.99.enabled', 'true'],
|
|
165
|
+
['update', id, '--actions.0.__proto__.polluted', 'true'],
|
|
166
|
+
['update', id, '--enabled', 'perhaps'],
|
|
167
|
+
['update', id, '--title', ' '],
|
|
168
|
+
['update', id, '--actions', '{}'],
|
|
169
|
+
['update', id, '--limit', '3'],
|
|
170
|
+
['update', id, '--action', 'garbage'],
|
|
171
|
+
['update', id, '--revision', '900'],
|
|
172
|
+
['update', id, '--sort_order', '-1'],
|
|
173
|
+
['update', id, '--unknown', 'value'],
|
|
174
|
+
['update', id, '--id', 'general', '--notes', 'Wrong target'],
|
|
175
|
+
['update', title, '--enabled', 'true'],
|
|
176
|
+
['update', id],
|
|
177
|
+
['delete', title, '--confirm']
|
|
178
|
+
]) {
|
|
179
|
+
await check('reject ' + args.slice(0, 3).join(' '), () => xy(['category', ...args], { fail: true }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await check('invalid commands make no changes', () => assert.equal(json(['category', id]).revision, revision));
|
|
183
|
+
|
|
184
|
+
await check('server action validation is surfaced', () => assert.match(xy(['category', 'update', id, '--action', JSON.stringify({ ...action, condition: 'invalid' })], { fail: true }), /Invalid condition/));
|
|
185
|
+
|
|
186
|
+
// Keep test events disabled with no triggers so category email actions never fire.
|
|
187
|
+
const eventTitle = 'Category CLI Test Event ' + id;
|
|
188
|
+
const eventID = 'cli_cat_event_' + Date.now();
|
|
189
|
+
events.add(eventID);
|
|
190
|
+
xy(['event', 'create', '--id', eventID, '--title', eventTitle, '--category', id, '--enabled', 'false', '--triggers', '[]', '--params.script', 'echo category-test', '--limit', JSON.stringify(limit)]);
|
|
191
|
+
|
|
192
|
+
await check('event create --limit regression', () => assert.ok(json(['event', eventID]).limits.some(item => item.duration === 300)));
|
|
193
|
+
|
|
194
|
+
xy(['event', 'update', eventID, '--limit', JSON.stringify({ ...limit, duration: 1200 })]);
|
|
195
|
+
|
|
196
|
+
await check('event update --limit regression', () => assert.ok(json(['event', eventID]).limits.some(item => item.duration === 1200)));
|
|
197
|
+
|
|
198
|
+
await check('category deletion refuses assigned events', () => assert.match(xy(['category', 'delete', id, '--confirm'], { fail: true }), /Still in use/));
|
|
199
|
+
|
|
200
|
+
await call('deleteEvent', { id: eventID });
|
|
201
|
+
events.delete(eventID);
|
|
202
|
+
json(['category', 'delete', id, '--confirm', '--dry']);
|
|
203
|
+
|
|
204
|
+
await check('dry deletion preserves category', () => assert.equal(json(['category', id]).id, id));
|
|
205
|
+
|
|
206
|
+
json(['category', 'delete', id, '--confirm']);
|
|
207
|
+
categories.delete(id);
|
|
208
|
+
|
|
209
|
+
await check('deleted category disappears', () => assert.deepEqual(json(['categories', '--id', id]), []));
|
|
210
|
+
|
|
211
|
+
await check('get deleted category fails', () => xy(['category', 'get', id], { fail: true }));
|
|
212
|
+
|
|
213
|
+
const auto = json(['category', 'create', '--title', title]);
|
|
214
|
+
categories.add(auto.id);
|
|
215
|
+
|
|
216
|
+
await check('server-generated ID', () => assert.match(auto.id, /^ca[a-z0-9_]+$/));
|
|
217
|
+
|
|
218
|
+
const requestFile = Path.join(temp, 'category.json');
|
|
219
|
+
fs.writeFileSync(requestFile, JSON.stringify({ title: title + ' imported', enabled: false, actions: [action], limits: [limit] }));
|
|
220
|
+
const imported = json(['category', 'create', '--json', '@' + requestFile]);
|
|
221
|
+
categories.add(imported.id);
|
|
222
|
+
|
|
223
|
+
await check('complete JSON file creation', () => {
|
|
224
|
+
assert.equal(imported.enabled, false);
|
|
225
|
+
assert.deepEqual(imported.limits, [limit]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
for (const topic of ['categories', 'category', 'category list', 'category get', 'category create', 'category update', 'category delete']) {
|
|
229
|
+
await check('help ' + topic, () => assert.ok(xy(['help', ...topic.split(' ')]).length > 100));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await check('human create suggestions', () => {
|
|
233
|
+
const humanID = id + '_human';
|
|
234
|
+
categories.add(humanID);
|
|
235
|
+
assert.match(xy(['category', 'create', '--id', humanID, '--title', title]), /Other Commands:/);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
// Events must be removed before their category. Titles also catch an
|
|
240
|
+
// auto-generated ID if a create succeeded before its output was parsed.
|
|
241
|
+
await cleanupFixtures([
|
|
242
|
+
{ list: 'events', method: 'deleteEvent', match: item => events.has(item.id) },
|
|
243
|
+
{ list: 'categories', method: 'deleteCategory', match: item => categories.has(item.id) || item.title.startsWith(title) }
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const Path = require('node:path');
|
|
5
|
+
const { loadTestConfig, createCheck, createTempDir, xy, json, call, call: apiCall, cleanupFixtures } = require('./helpers/common.js');
|
|
6
|
+
|
|
7
|
+
test('channels', async t => {
|
|
8
|
+
loadTestConfig();
|
|
9
|
+
const check = createCheck(t);
|
|
10
|
+
|
|
11
|
+
// Disposable live CLI lifecycle. Configure channels without invoking them.
|
|
12
|
+
const id = 'cli_channel_test_' + Date.now();
|
|
13
|
+
const title = 'CLI Channel Test ' + id;
|
|
14
|
+
const ids = new Set([id]);
|
|
15
|
+
const temp = createTempDir(t, 'xycli-channel-test-');
|
|
16
|
+
try {
|
|
17
|
+
const original = (await apiCall('getChannels')).rows;
|
|
18
|
+
const events = (await apiCall('getEvents')).rows;
|
|
19
|
+
const hooks = (await apiCall('getWebHooks')).rows;
|
|
20
|
+
const eventID = events[0] ? events[0].id : '';
|
|
21
|
+
const hookID = hooks[0] ? hooks[0].id : '';
|
|
22
|
+
|
|
23
|
+
await check('dry creation fills defaults', () => {
|
|
24
|
+
const req = json(['channel', 'create', '--id', id, '--title', title, '--dry']);
|
|
25
|
+
assert.equal(req.enabled, true);
|
|
26
|
+
assert.equal(req.max_per_day, 0);
|
|
27
|
+
assert.deepEqual(req.users, []);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
await check('dry creation does not persist', () => assert.deepEqual(json(['channels', '--id', id]), []));
|
|
31
|
+
|
|
32
|
+
const created = json(['channel', 'create', '--id', id, '--title', title, '--enabled', 'false', '--users', 'admin,oncall', '--user', 'admin', '--user', 'backup', '--email', 'ops@example.invalid,sre@example.invalid', '--web_hook', hookID, '--run_event', eventID, '--sound', 'attention-3.mp3', '--icon', 'bullhorn-outline', '--max_per_day', '25', '--notes', 'Hello']);
|
|
33
|
+
|
|
34
|
+
await check('create persists all fields and deduplicates users', () => {
|
|
35
|
+
assert.equal(created.id, id);
|
|
36
|
+
assert.equal(created.enabled, false);
|
|
37
|
+
assert.equal(created.revision, 1);
|
|
38
|
+
assert.deepEqual(created.users, ['admin', 'oncall', 'backup']);
|
|
39
|
+
assert.equal(created.max_per_day, 25);
|
|
40
|
+
assert.equal(created.web_hook, hookID);
|
|
41
|
+
assert.equal(created.run_event, eventID);
|
|
42
|
+
assert.equal(created.sound, 'attention-3.mp3');
|
|
43
|
+
assert.equal(created.notes, 'Hello');
|
|
44
|
+
assert.equal(created.icon, 'bullhorn-outline');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await check('exact ID get', () => assert.deepEqual(json(['channel', id]), created));
|
|
48
|
+
|
|
49
|
+
await check('fuzzy title get', () => assert.equal(json(['channel', 'get', '--title', title.toLowerCase()]).id, id));
|
|
50
|
+
|
|
51
|
+
await check('named ID get', () => assert.equal(json(['channel', 'get', '--id', id]).id, id));
|
|
52
|
+
|
|
53
|
+
await check('combined list filters', () => assert.equal(json(['channels', title, '--enabled', 'false', '--user', 'admin']).length, 1));
|
|
54
|
+
|
|
55
|
+
await check('username filter is exact', () => assert.equal(json(['channels', title, '--user', 'adm']).length, 0));
|
|
56
|
+
|
|
57
|
+
await check('repeated username filters', () => assert.equal(json(['channel', 'list', title, '--user', 'admin', '--user', 'backup']).length, 1));
|
|
58
|
+
|
|
59
|
+
await check('JSON list ignores pagination as filters', () => assert.deepEqual(json(['channels', '--id', id, '--limit', '1', '--page', '2']), [created]));
|
|
60
|
+
|
|
61
|
+
await check('alphabetical list ordering', () => {
|
|
62
|
+
const rows = json(['channels']);
|
|
63
|
+
assert.ok(rows.every((row, idx) => !idx || rows[idx-1].title.toLowerCase().localeCompare(row.title.toLowerCase()) <= 0));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await check('human list pagination and suggestions', () => {
|
|
67
|
+
const out = xy(['channels', '--limit', '1', '--page', '2']);
|
|
68
|
+
assert.match(out, /page 2 of/);
|
|
69
|
+
assert.match(out, /Other Commands:/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await check('empty human list', () => assert.match(xy(['channels', id, '--enabled', 'true']), /No filtered notification channels found/));
|
|
73
|
+
|
|
74
|
+
await check('human detail covers configuration', () => {
|
|
75
|
+
const out = xy(['channel', id]);
|
|
76
|
+
fs.writeFileSync(Path.join(temp, 'detail.txt'), out);
|
|
77
|
+
for (const label of ['Channel Summary', 'Users', 'Web Hook', 'Run Event', 'Sound', 'Daily Cap', 'Revision', 'Channel Notes', 'Hello']) assert.ok(out.toLowerCase().includes(label.toLowerCase()));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await check('human update shows target and parsed data', () => {
|
|
81
|
+
const out = xy(['channel', 'update', id, '--notes', 'Preview', '--dry']);
|
|
82
|
+
assert.match(out, /UPDATE CHANNEL/);
|
|
83
|
+
assert.match(out, new RegExp('Channel ID:\\s+' + id));
|
|
84
|
+
assert.match(out, /UPDATE DATA[\s\S]*"notes": "Preview"/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await check('sparse dry update excludes unrelated fields', () => assert.deepEqual(json(['channel', 'update', id, '--notes', 'Preview', '--dry']), { notes: 'Preview', id }));
|
|
88
|
+
|
|
89
|
+
await check('dry update keeps revision', () => assert.equal(json(['channel', id]).revision, 1));
|
|
90
|
+
|
|
91
|
+
json(['channel', 'update', id, '--users.1', 'replacement', '--user', 'second', '--user', 'admin', '--enabled', 'true', '--max_per_day', '10']);
|
|
92
|
+
|
|
93
|
+
await check('indexed edits and appends preserve other fields', () => {
|
|
94
|
+
const row = json(['channel', id]);
|
|
95
|
+
assert.deepEqual(row.users, ['admin', 'replacement', 'backup', 'second']);
|
|
96
|
+
assert.equal(row.revision, 2);
|
|
97
|
+
assert.equal(row.enabled, true);
|
|
98
|
+
assert.equal(row.max_per_day, 10);
|
|
99
|
+
assert.equal(row.notes, 'Hello');
|
|
100
|
+
assert.equal(row.created, created.created);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
json(['channel', 'update', id, '--users.0', 'changed', '--users', '["first","second"]', '--user', 'third']);
|
|
104
|
+
|
|
105
|
+
await check('list replacement precedes indexed edits', () => assert.deepEqual(json(['channel', id]).users, ['changed', 'second', 'third']));
|
|
106
|
+
|
|
107
|
+
const userFile = Path.join(temp, 'users.json');
|
|
108
|
+
fs.writeFileSync(userFile, '["admin","oncall"]');
|
|
109
|
+
json(['channel', 'update', '--id', id, '--users', '@' + userFile]);
|
|
110
|
+
|
|
111
|
+
await check('user list from JSON file', () => assert.deepEqual(json(['channel', id]).users, ['admin', 'oncall']));
|
|
112
|
+
|
|
113
|
+
json(['channel', 'update', id, '--json', '@-'], { input: JSON.stringify({ notes: 'From stdin', max_per_day: 0, enabled: false }) });
|
|
114
|
+
|
|
115
|
+
await check('complete stdin update', () => {
|
|
116
|
+
const row = json(['channel', id]);
|
|
117
|
+
assert.equal(row.notes, 'From stdin');
|
|
118
|
+
assert.equal(row.max_per_day, 0);
|
|
119
|
+
assert.equal(row.enabled, false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
json(['channel', 'update', id, '--users', '[]', '--email', '', '--web_hook', '', '--run_event', '', '--sound', '', '--icon', '', '--notes', '']);
|
|
123
|
+
|
|
124
|
+
await check('clear optional recipients and targets', () => {
|
|
125
|
+
const row = json(['channel', id]);
|
|
126
|
+
assert.deepEqual(row.users, []);
|
|
127
|
+
for (const key of ['email', 'web_hook', 'run_event', 'sound', 'icon', 'notes']) assert.equal(row[key], '');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const revision = json(['channel', id]).revision;
|
|
131
|
+
await check('unconfirmed delete shows target and warning toast', () => {
|
|
132
|
+
for (const args of [['delete', id], ['delete', id, '--confirm', 'false']]) {
|
|
133
|
+
const out = xy(['channel', ...args]);
|
|
134
|
+
assert.match(out, /DELETE CHANNEL/);
|
|
135
|
+
assert.match(out, /⚠️[\s\S]*Please confirm the channel delete/);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
for (const args of [
|
|
140
|
+
['update', id, '--users.99', 'admin'], ['update', id, '--users.__proto__.x', 'bad'],
|
|
141
|
+
['update', id, '--users', '{}'], ['update', id, '--users', '[5]'], ['update', id, '--user', ''],
|
|
142
|
+
['update', id, '--title', ' '], ['update', id, '--enabled', 'perhaps'], ['update', id, '--max_per_day', '-1'],
|
|
143
|
+
['update', id, '--max_per_day', '1.5'], ['update', id, '--sound', 'bad.wav'], ['update', id, '--email', 'true'],
|
|
144
|
+
['update', id, '--revision', '99'], ['update', id, '--unknown', 'x'], ['update', id],
|
|
145
|
+
['update', title, '--notes', 'wrong'], ['update', id, '--id', 'sev1', '--notes', 'wrong'],
|
|
146
|
+
['delete', title, '--confirm']
|
|
147
|
+
]) await check('reject ' + args.slice(0,3).join(' '), () => xy(['channel', ...args], { fail: true }));
|
|
148
|
+
|
|
149
|
+
await check('rejected requests leave revision unchanged', () => assert.equal(json(['channel', id]).revision, revision));
|
|
150
|
+
|
|
151
|
+
await check('duplicate channel ID error is surfaced', () => assert.match(xy(['channel', 'create', '--id', id, '--title', title], { fail:true }), /already exists/));
|
|
152
|
+
|
|
153
|
+
const auto = json(['channel', 'create', '--title', title]);
|
|
154
|
+
ids.add(auto.id);
|
|
155
|
+
|
|
156
|
+
await check('automatic ID and default settings', () => {
|
|
157
|
+
assert.match(auto.id, /^ch/);
|
|
158
|
+
assert.equal(auto.enabled, true);
|
|
159
|
+
assert.equal(auto.max_per_day, 0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const reqFile = Path.join(temp, 'channel.json');
|
|
163
|
+
fs.writeFileSync(reqFile, JSON.stringify({ title: title + ' file', users: ['admin'], enabled: false }));
|
|
164
|
+
const imported = json(['channel', 'create', '--json', '@' + reqFile]);
|
|
165
|
+
ids.add(imported.id);
|
|
166
|
+
|
|
167
|
+
await check('complete creation from JSON file', () => {
|
|
168
|
+
assert.deepEqual(imported.users, ['admin']);
|
|
169
|
+
assert.equal(imported.enabled, false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const humanID = id + '_human';
|
|
173
|
+
ids.add(humanID);
|
|
174
|
+
|
|
175
|
+
await check('human creation suggestions', () => assert.match(xy(['channel', 'create', '--id', humanID, '--title', title]), /Other Commands:/));
|
|
176
|
+
|
|
177
|
+
// Remove auxiliary fixtures before checking fuzzy searches after deletion.
|
|
178
|
+
for (const channelID of Array.from(ids)) {
|
|
179
|
+
if (channelID === id) continue;
|
|
180
|
+
json(['channel', 'delete', channelID, '--confirm']);
|
|
181
|
+
ids.delete(channelID);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await check('dry delete request', () => assert.deepEqual(json(['channel', 'delete', id, '--confirm', '--dry']), { id }));
|
|
185
|
+
|
|
186
|
+
await check('dry delete leaves channel', () => assert.equal(json(['channel', id]).id, id));
|
|
187
|
+
|
|
188
|
+
json(['channel', 'delete', id, '--confirm']);
|
|
189
|
+
ids.delete(id);
|
|
190
|
+
|
|
191
|
+
await check('delete removes channel', () => assert.deepEqual(json(['channels', '--id', id]), []));
|
|
192
|
+
|
|
193
|
+
await check('get after deletion fails', () => xy(['channel', id], { fail:true }));
|
|
194
|
+
|
|
195
|
+
for (const topic of ['channels', 'channel', 'channel list', 'channel get', 'channel create', 'channel update', 'channel delete']) {
|
|
196
|
+
await check('help ' + topic, () => assert.ok(xy(['help', ...topic.split(' ')]).length > 100));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await check('existing channels remain unchanged', () => {
|
|
200
|
+
const now = json(['channels']);
|
|
201
|
+
for (const channel of original) assert.deepEqual(now.find(item => item.id === channel.id), channel);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
await cleanupFixtures([
|
|
206
|
+
{ list: 'channels', method: 'deleteChannel', match: item => ids.has(item.id) || item.title.startsWith(title) }
|
|
207
|
+
]);
|
|
208
|
+
}
|
|
209
|
+
});
|