@indemn/cli 1.7.8 → 1.7.10
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/README.md +2 -2
- package/dist/mcp/tool-meta.d.ts.map +1 -1
- package/dist/mcp/tool-meta.js +52 -2
- package/dist/mcp/tool-meta.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +141 -6
- package/dist/mcp/tools.js.map +1 -1
- package/dist/sdk/access.d.ts +4 -0
- package/dist/sdk/access.d.ts.map +1 -1
- package/dist/sdk/access.js +11 -0
- package/dist/sdk/access.js.map +1 -1
- package/dist/sdk/campaigns.d.ts +82 -3
- package/dist/sdk/campaigns.d.ts.map +1 -1
- package/dist/sdk/campaigns.js +106 -4
- package/dist/sdk/campaigns.js.map +1 -1
- package/dist/sdk/orgs.d.ts +16 -0
- package/dist/sdk/orgs.d.ts.map +1 -1
- package/dist/sdk/orgs.js +30 -0
- package/dist/sdk/orgs.js.map +1 -1
- package/package.json +1 -1
- package/src/mcp/tool-meta.ts +54 -2
- package/src/mcp/tools.ts +181 -7
- package/src/sdk/access.ts +12 -0
- package/src/sdk/campaigns.ts +125 -4
- package/src/sdk/orgs.ts +37 -0
- package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +8 -0
- package/test/mcp/access.test.ts +18 -0
- package/test/mcp/campaigns.test.ts +165 -0
- package/test/mcp/tools-snapshot.test.ts +2 -2
- package/test/sdk/campaigns.test.ts +76 -18
- package/test/sdk/orgs.test.ts +31 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { on, recordFetch, type RecordedCall, type Responder } from '../helpers/fetch-recorder.js';
|
|
3
|
+
import { cleanEnv } from '../helpers/config-home.js';
|
|
4
|
+
import { connectPair } from '../helpers/mcp-pair.js';
|
|
5
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
6
|
+
import { registerTools } from '../../src/mcp/tools.js';
|
|
7
|
+
import { TOOL_META } from '../../src/mcp/tool-meta.js';
|
|
8
|
+
|
|
9
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
10
|
+
const solutionsOn = on('GET', `/organization/${ORG}/configurations`, { json: { enableSolutions: true } });
|
|
11
|
+
const approve = () => ({ action: 'accept' as const, content: { confirm: true } });
|
|
12
|
+
async function pair(elicit: () => { action: 'accept' | 'decline'; content?: Record<string, unknown> } = approve) {
|
|
13
|
+
cleanEnv();
|
|
14
|
+
const client = new IndemnClient({ apiKey: `ind_dev_${'a'.repeat(64)}`, orgId: ORG, environment: 'dev' });
|
|
15
|
+
return connectPair((server) => { registerTools(server, client); }, { elicit });
|
|
16
|
+
}
|
|
17
|
+
const fetches = (responders: Responder[]) => recordFetch([solutionsOn, ...responders]);
|
|
18
|
+
const writes = (calls: RecordedCall[]) => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.url.split('campaigns-service')[1]}`);
|
|
19
|
+
const draft = { audience: null, touchpoints: [], escalation_policy: {}, channel_policy: {} };
|
|
20
|
+
const campaign = { id: 'c1', name: 'GL renewals', status: 'draft', current_version_n: null, draft };
|
|
21
|
+
|
|
22
|
+
describe('campaign tools', () => {
|
|
23
|
+
let p: Awaited<ReturnType<typeof pair>> | undefined;
|
|
24
|
+
afterEach(async () => { await p?.close(); p = undefined; });
|
|
25
|
+
|
|
26
|
+
it('with Solutions off, reads and changes are refused before any card or call, saying how to turn it on', async () => {
|
|
27
|
+
const { calls } = recordFetch([on('GET', '/configurations', { json: { enableSolutions: false } })]);
|
|
28
|
+
p = await pair();
|
|
29
|
+
for (const [name, args] of [['get_campaigns', { what: 'list' }], ['save_campaign', { solution_id: 's1', book_id: 'b1', name: 'X', campaign_type: 'renewal' }]] as const) {
|
|
30
|
+
const res = await p.call(name, args);
|
|
31
|
+
expect(res.isError).toBe(true);
|
|
32
|
+
expect(String(res.body)).toMatch(/Solutions is not turned on.*set_org_admin_settings.*Nothing was changed/);
|
|
33
|
+
}
|
|
34
|
+
expect(p.questions).toHaveLength(0);
|
|
35
|
+
expect(calls.filter((c) => c.url.includes('campaigns-service'))).toHaveLength(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('save_campaign without an id creates, asking first by name; confirm and empty fields never reach the service', async () => {
|
|
39
|
+
const { calls } = fetches([on('GET', '/campaigns-service/books/b1', { json: { id: 'b1', name: 'GL book' } }), on('POST', '/campaigns-service/campaigns', { json: campaign })]);
|
|
40
|
+
p = await pair();
|
|
41
|
+
const res = await p.call('save_campaign', { solution_id: 's1', book_id: 'b1', name: 'GL renewals', campaign_type: 'renewal', closes: '', confirm: true });
|
|
42
|
+
expect(res.isError).toBe(false);
|
|
43
|
+
expect(p.questions[0].message).toContain('create campaign "GL renewals" as a draft; it sends nothing until it is published and started');
|
|
44
|
+
expect(writes(calls)).toEqual(['POST /campaigns']);
|
|
45
|
+
expect(calls.find((c) => c.method === 'POST')!.body).toEqual({ solution_id: 's1', book_id: 'b1', name: 'GL renewals', campaign_type: 'renewal' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('a create missing what it needs is answered at once: no card, no call', async () => {
|
|
49
|
+
const { calls } = fetches([]);
|
|
50
|
+
p = await pair();
|
|
51
|
+
const res = await p.call('save_campaign', { name: 'GL renewals' });
|
|
52
|
+
expect(res.isError).toBe(true);
|
|
53
|
+
expect(String(res.body)).toContain('To create a campaign also give solution_id, book_id, campaign_type');
|
|
54
|
+
expect(p.questions).toHaveLength(0);
|
|
55
|
+
expect(writes(calls)).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('save_campaign with an id changes only the draft parts given, by name on the card', async () => {
|
|
59
|
+
const touchpoints = [{ key: 't1', channel: 'email' }];
|
|
60
|
+
const { calls } = fetches([
|
|
61
|
+
on('GET', '/campaigns-service/campaigns/c1', { json: { ...campaign, draft: { ...draft, touchpoints } } }),
|
|
62
|
+
on('PUT', '/campaigns-service/campaigns/c1/draft', (c) => ({ json: c.body })),
|
|
63
|
+
]);
|
|
64
|
+
p = await pair();
|
|
65
|
+
const res = await p.call('save_campaign', { campaign_id: 'c1', audience: '{"field":"status","op":"eq","value":"active"}' });
|
|
66
|
+
expect(res.isError).toBe(false);
|
|
67
|
+
expect(p.questions[0].message).toContain('change the audience in the draft of campaign "GL renewals"');
|
|
68
|
+
expect(calls.find((c) => c.method === 'PUT')!.body).toEqual({ ...draft, touchpoints, audience: { field: 'status', op: 'eq', value: 'active' } });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('set_touchpoint takes flat fields and rules as JSON text, and drafts unless told to send', async () => {
|
|
72
|
+
const { calls } = fetches([
|
|
73
|
+
on('GET', '/campaigns-service/campaigns/c1', { json: campaign }),
|
|
74
|
+
on('PUT', '/campaigns-service/campaigns/c1/draft', (c) => ({ json: c.body })),
|
|
75
|
+
]);
|
|
76
|
+
p = await pair();
|
|
77
|
+
const res = await p.call('set_touchpoint', { campaign_id: 'c1', key: 't1_60', channel: 'email', when: '{"field":"derived.days_out","op":"lte","value":60}' });
|
|
78
|
+
expect(res.isError).toBe(false);
|
|
79
|
+
expect((calls.find((c) => c.method === 'PUT')!.body as typeof draft).touchpoints).toEqual([{ key: 't1_60', channel: 'email', terminal_action: 'draft', when: { field: 'derived.days_out', op: 'lte', value: 60 } }]);
|
|
80
|
+
expect(p.questions[0].message).toContain('touchpoint "t1_60" in the draft of campaign "GL renewals" (email)');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('starting a campaign is its own approval and says it reaches customers; a decline changes nothing', async () => {
|
|
84
|
+
const { calls } = fetches([on('GET', '/campaigns-service/campaigns/c1', { json: campaign }), on('POST', '/status', { json: { ...campaign, status: 'running' } })]);
|
|
85
|
+
p = await pair(() => ({ action: 'decline' }));
|
|
86
|
+
await p.call('set_campaign_status', { campaign_id: 'c1', status: 'running' });
|
|
87
|
+
expect(p.questions[0].message).toMatch(/START campaign "GL renewals".*reach real customers/);
|
|
88
|
+
expect(writes(calls)).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("the service's refusal reaches the person as it is", async () => {
|
|
92
|
+
fetches([
|
|
93
|
+
on('GET', '/campaigns-service/campaigns/c1', { json: campaign }),
|
|
94
|
+
on('POST', '/status', { status: 409, json: { detail: 'cannot start a campaign that has never published a version' } }),
|
|
95
|
+
]);
|
|
96
|
+
p = await pair();
|
|
97
|
+
const res = await p.call('set_campaign_status', { campaign_id: 'c1', status: 'running' });
|
|
98
|
+
expect(res.isError).toBe(true);
|
|
99
|
+
expect(String(res.body)).toContain('never published a version');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('an id that names nothing gets no card', async () => {
|
|
103
|
+
const { calls } = fetches([on('GET', '/campaigns-service/campaigns/nope', { status: 404, json: { detail: 'not found' } })]);
|
|
104
|
+
p = await pair();
|
|
105
|
+
const res = await p.call('publish_campaign', { campaign_id: 'nope' });
|
|
106
|
+
expect(String(res.body)).toContain('Not found: campaign nope');
|
|
107
|
+
expect(p.questions).toHaveLength(0);
|
|
108
|
+
expect(writes(calls)).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('save_campaign_template with an id saves a new version from the latest one, named on the card', async () => {
|
|
112
|
+
const { calls } = fetches([
|
|
113
|
+
on('GET', '/campaigns-service/templates/tpl1/versions', { json: { items: [{ version_n: 1, subject: 'Renewal', body: 'Old', uses_variables: [] }] } }),
|
|
114
|
+
on('GET', '/campaigns-service/templates/tpl1', { json: { id: 'tpl1', name: 'GL reminder' } }),
|
|
115
|
+
on('POST', '/campaigns-service/templates/tpl1/versions', (c) => ({ json: { version_n: 2, ...(c.body as object) } })),
|
|
116
|
+
]);
|
|
117
|
+
p = await pair();
|
|
118
|
+
const res = await p.call('save_campaign_template', { template_id: 'tpl1', body: 'New', subject: '' });
|
|
119
|
+
expect(res.isError).toBe(false);
|
|
120
|
+
expect(calls.find((c) => c.method === 'POST')!.body).toEqual({ subject: 'Renewal', body: 'New', uses_variables: [] }); // "" kept the subject
|
|
121
|
+
expect(p.questions[0].message).toContain('new version of template "GL reminder" (body)');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('save_template_skill creates and changes, by name on the card', async () => {
|
|
125
|
+
const { calls } = fetches([
|
|
126
|
+
on('GET', '/campaigns-service/template-skills/sk1', { json: { id: 'sk1', name: 'Tone', instructions: 'Brief.' } }),
|
|
127
|
+
(c) => (c.method === 'GET' ? undefined : { json: { id: 'sk1' } }),
|
|
128
|
+
]);
|
|
129
|
+
p = await pair();
|
|
130
|
+
await p.call('save_template_skill', { solution_id: 's1', key: 'tone', name: 'Tone', instructions: 'Brief.' });
|
|
131
|
+
await p.call('save_template_skill', { template_skill_id: 'sk1', instructions: 'Warm.' });
|
|
132
|
+
expect(writes(calls)).toEqual(['POST /template-skills', 'PUT /template-skills/sk1']);
|
|
133
|
+
expect(calls.filter((c) => c.method !== 'GET').map((c) => c.body)).toEqual([{ solution_id: 's1', key: 'tone', name: 'Tone', instructions: 'Brief.' }, { name: 'Tone', instructions: 'Warm.' }]);
|
|
134
|
+
expect(p.questions[1].message).toContain('change template skill "Tone" (instructions)');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('save_book changes only the parts given; a create whose change is refused says the book exists', async () => {
|
|
138
|
+
const { calls } = fetches([
|
|
139
|
+
on('GET', '/campaigns-service/books/b1', { json: { id: 'b1', name: 'GL book' } }),
|
|
140
|
+
on('POST', '/campaigns-service/books', { json: { id: 'b2', name: 'New book' } }),
|
|
141
|
+
on('PUT', '/books/b2/display', { status: 400, json: { detail: 'display names keys absent from the schema: no.such' } }),
|
|
142
|
+
(c) => (c.method === 'GET' ? undefined : { json: { id: 'b1' } }),
|
|
143
|
+
]);
|
|
144
|
+
p = await pair();
|
|
145
|
+
const res = await p.call('save_book', { book_id: 'b1', identity_keys: ['policy.number'], display: '{"primary":["policy.number"],"secondary":["insured.name"]}' });
|
|
146
|
+
expect(res.isError).toBe(false);
|
|
147
|
+
expect(p.questions[0].message).toContain('change book "GL book": identity keys to policy.number; display to primary policy.number; secondary insured.name.');
|
|
148
|
+
const made = await p.call('save_book', { solution_id: 's1', name: 'New book', display: '{"primary":["no.such"]}' });
|
|
149
|
+
expect(String(made.body)).toMatch(/The book was created \(id b2\), but .*no\.such.*do not create it again/);
|
|
150
|
+
expect(writes(calls)).toEqual(['PUT /books/b1/identity-keys', 'PUT /books/b1/display', 'POST /books', 'PUT /books/b2/display']);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('a book change card shows the new values', () => {
|
|
154
|
+
expect(TOOL_META.save_book.describe!({ book_id: 'b1', source: { kind: 'upload' }, field_map: [{ source_path: 'Policy #', schema_key: 'policy.number' }, { source_path: 'Insured', schema_key: 'insured.name' }] }))
|
|
155
|
+
.toBe('This will change book b1: source to upload; field map to Policy # → policy.number, Insured → insured.name.');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('get_campaigns reads books', async () => {
|
|
159
|
+
const { calls } = fetches([on('GET', '/campaigns-service/books', { json: { total: 1, items: [{ id: 'b1', name: 'GL book' }] } })]);
|
|
160
|
+
p = await pair();
|
|
161
|
+
const res = await p.call('get_campaigns', { what: 'books', solution_id: 's1' });
|
|
162
|
+
expect(res.isError).toBe(false);
|
|
163
|
+
expect(calls.some((c) => c.method === 'GET' && c.url.includes('/campaigns-service/books?') && c.url.includes('solution_id=s1'))).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -17,7 +17,7 @@ describe('MCP tool list', () => {
|
|
|
17
17
|
expect(typeof tool.annotations?.readOnlyHint, tool.name).toBe('boolean');
|
|
18
18
|
}
|
|
19
19
|
expect(tools.map((t) => t.name).sort()).toMatchSnapshot();
|
|
20
|
-
expect(tools).toHaveLength(
|
|
20
|
+
expect(tools).toHaveLength(149); // +invite_org_member // +7 campaign tools // +numbers // +set_agent_transcriber // +set_org_admin_settings // +remove_holiday // +create_sandbox_org
|
|
21
21
|
const props = (name: string) => Object.keys((tools.find((t) => t.name === name)!.inputSchema as { properties?: object }).properties ?? {});
|
|
22
22
|
for (const name of ['list_agents', 'delete_agent', 'set_prompt', 'list_org_members', 'list_channels', 'chat_with_agent']) {
|
|
23
23
|
expect(props(name), name).toContain('org_id');
|
|
@@ -26,7 +26,7 @@ describe('MCP tool list', () => {
|
|
|
26
26
|
expect(props(name), name).not.toContain('org_id');
|
|
27
27
|
}
|
|
28
28
|
// Writes that change what customers get or reach outside ask first on every host (a dry run, then confirm).
|
|
29
|
-
for (const name of ['delete_agent', 'create_function', 'test_function', 'add_kb_data', 'scrape_url', 'upload_kb_file']) {
|
|
29
|
+
for (const name of ['delete_agent', 'create_function', 'test_function', 'add_kb_data', 'scrape_url', 'upload_kb_file', 'save_campaign', 'set_touchpoint', 'publish_campaign', 'set_campaign_status', 'save_campaign_template', 'save_template_skill', 'save_book']) {
|
|
30
30
|
expect(props(name), name).toContain('confirm');
|
|
31
31
|
}
|
|
32
32
|
// The person chooses how much of a website is crawled: only scrape_url crawls, and it needs their choice.
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
2
|
+
import { on, recordFetch, type RecordedCall } from '../helpers/fetch-recorder.js';
|
|
4
3
|
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
5
4
|
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
6
5
|
import { IndemnClient } from '../../src/sdk/client.js';
|
|
7
6
|
import { CampaignsSDK } from '../../src/sdk/campaigns.js';
|
|
8
7
|
|
|
9
8
|
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
9
|
+
const base = `/api/organization/${ORG}/campaigns-service`;
|
|
10
|
+
const seen = (calls: RecordedCall[]) => calls.map((c) => `${c.method} ${new URL(c.url).pathname.replace(base, '')}`);
|
|
10
11
|
|
|
11
|
-
describe('campaigns
|
|
12
|
+
describe('campaigns: reads, and writes to configuration only', () => {
|
|
12
13
|
let home: ReturnType<typeof useTempHome>;
|
|
13
14
|
beforeEach(() => {
|
|
14
15
|
cleanEnv();
|
|
@@ -18,26 +19,83 @@ describe('campaigns are read-only', () => {
|
|
|
18
19
|
});
|
|
19
20
|
afterEach(() => home.cleanup());
|
|
20
21
|
|
|
21
|
-
it('
|
|
22
|
-
const src = readFileSync(new URL('../../src/sdk/campaigns.ts', import.meta.url), 'utf8');
|
|
23
|
-
expect(src).not.toMatch(/copilot(Post|Put|Delete|RequestRaw|PostForm|Binary)/);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('reads through the org proxy with GET only', async () => {
|
|
22
|
+
it('reads through the org proxy with GET, books included', async () => {
|
|
27
23
|
const { calls } = recordFetch([on('GET', '/campaigns-service/', { json: { total: 0, limit: 50, skip: 0, items: [] } })]);
|
|
28
24
|
const sdk = new CampaignsSDK(new IndemnClient());
|
|
29
|
-
await sdk.list(); await sdk.detail('c1', 'forecast', 30); await sdk.templates(); await sdk.template('t1', true);
|
|
30
|
-
expect(calls.
|
|
31
|
-
`GET /api/organization/${ORG}/campaigns-service/campaigns`,
|
|
32
|
-
`GET /api/organization/${ORG}/campaigns-service/campaigns/c1/forecast`,
|
|
33
|
-
`GET /api/organization/${ORG}/campaigns-service/templates`,
|
|
34
|
-
`GET /api/organization/${ORG}/campaigns-service/templates/t1/versions`,
|
|
35
|
-
]);
|
|
25
|
+
await sdk.list(); await sdk.detail('c1', 'forecast', 30); await sdk.templates(); await sdk.template('t1', true); await sdk.books(); await sdk.book('b1');
|
|
26
|
+
expect(seen(calls)).toEqual(['GET /campaigns', 'GET /campaigns/c1/forecast', 'GET /templates', 'GET /templates/t1/versions', 'GET /books', 'GET /books/b1']);
|
|
36
27
|
});
|
|
37
28
|
|
|
38
|
-
it('
|
|
29
|
+
it('never sends, runs, uploads, ticks or deletes: those paths are refused before any call', async () => {
|
|
39
30
|
const { calls } = recordFetch();
|
|
40
|
-
|
|
31
|
+
const sdk = new CampaignsSDK(new IndemnClient());
|
|
32
|
+
await expect(sdk.get('c1/../../communications/x/send')).rejects.toThrow(/not readable/);
|
|
33
|
+
const write = (sdk as unknown as { write: (m: string, p: string, b?: unknown) => Promise<unknown> }).write.bind(sdk);
|
|
34
|
+
for (const [method, path] of [['POST', '/communications/x/send'], ['POST', '/work-items/w/resolve'], ['POST', '/books/b/runs'], ['POST', '/books/b/uploads'],
|
|
35
|
+
['POST', '/campaigns/c/test-tick'], ['POST', '/campaigns/c/candidates/evaluate'], ['POST', '/enrollments/e/close'], ['POST', '/activity'],
|
|
36
|
+
['DELETE', '/campaigns/c'], ['PUT', '/campaigns/c'], ['POST', '/campaigns/c/../../communications/x/send']]) {
|
|
37
|
+
await expect(write(method, path, {})).rejects.toThrow(/not allowed from the CLI/);
|
|
38
|
+
}
|
|
41
39
|
expect(calls).toHaveLength(0);
|
|
42
40
|
});
|
|
41
|
+
|
|
42
|
+
it('a touchpoint is added, or merged into the one with its key; a template given without a version is pinned to its latest', async () => {
|
|
43
|
+
const draft = { audience: { field: 'status', op: 'eq', value: 'active' }, touchpoints: [{ key: 't1', channel: 'email', terminal_action: 'draft', when: { field: 'derived.days_out', op: 'lte', value: 60 } }], escalation_policy: { then: 'create_work_item' }, channel_policy: {} };
|
|
44
|
+
const { calls } = recordFetch([
|
|
45
|
+
on('GET', '/campaigns/c1', { json: { id: 'c1', name: 'Renewals', status: 'draft', draft } }),
|
|
46
|
+
on('GET', '/templates/tpl1/versions', { json: { total: 2, items: [{ version_n: 2, subject: 'S2', body: 'B2' }, { version_n: 1 }] } }),
|
|
47
|
+
on('PUT', '/campaigns/c1/draft', (c) => ({ json: c.body })),
|
|
48
|
+
]);
|
|
49
|
+
const sdk = new CampaignsSDK(new IndemnClient());
|
|
50
|
+
await sdk.setTouchpoint('c1', { key: 't2', channel: 'email', when: { field: 'derived.days_out', op: 'lte', value: 30 }, template_id: 'tpl1' });
|
|
51
|
+
await sdk.setTouchpoint('c1', { key: 't1', when: { field: 'derived.days_out', op: 'lte', value: 45 } });
|
|
52
|
+
const puts = calls.filter((c) => c.method === 'PUT').map((c) => c.body as typeof draft);
|
|
53
|
+
expect(puts[0].touchpoints).toEqual([draft.touchpoints[0], { key: 't2', channel: 'email', terminal_action: 'draft', when: { field: 'derived.days_out', op: 'lte', value: 30 }, template_id: 'tpl1', template_version_n: 2 }]);
|
|
54
|
+
expect(puts[0].audience).toEqual(draft.audience); // the rest of the draft is kept
|
|
55
|
+
expect(puts[1].touchpoints).toEqual([{ ...draft.touchpoints[0], when: { field: 'derived.days_out', op: 'lte', value: 45 } }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('a draft change keeps its touchpoints; a template change starts from its latest version', async () => {
|
|
59
|
+
const draft = { audience: null, touchpoints: [{ key: 't1', channel: 'email' }], escalation_policy: {}, channel_policy: {} };
|
|
60
|
+
const { calls } = recordFetch([
|
|
61
|
+
on('GET', '/campaigns/c1', { json: { id: 'c1', draft } }),
|
|
62
|
+
on('PUT', '/campaigns/c1/draft', (c) => ({ json: c.body })),
|
|
63
|
+
on('GET', '/templates/tpl1/versions', { json: { items: [{ version_n: 3, subject: 'Hi {{ contact.first_name }}', body: 'Old body', html: '', uses_variables: [{ key: 'contact.first_name', required: true, example: 'Ana' }], selection_tags: {}, skills: {}, batch_row: '', published_by: 'u0' }] } }),
|
|
64
|
+
on('POST', '/templates/tpl1/versions', (c) => ({ json: { version_n: 4, ...(c.body as object) } })),
|
|
65
|
+
]);
|
|
66
|
+
const sdk = new CampaignsSDK(new IndemnClient());
|
|
67
|
+
await sdk.updateDraft('c1', { audience: { field: 'status', op: 'eq', value: 'active' } });
|
|
68
|
+
await sdk.updateTemplate('tpl1', { body: 'New body {{ record.policy.number }}', uses_variables: ['contact.first_name', 'record.policy.number'], change_summary: 'shorter' });
|
|
69
|
+
expect(calls.find((c) => c.method === 'PUT')!.body).toEqual({ ...draft, audience: { field: 'status', op: 'eq', value: 'active' } });
|
|
70
|
+
// Variables given as keys: a key already declared keeps its entry, a new one is declared as {key}.
|
|
71
|
+
expect(calls.find((c) => c.method === 'POST')!.body).toEqual({ subject: 'Hi {{ contact.first_name }}', body: 'New body {{ record.policy.number }}', html: '', uses_variables: [{ key: 'contact.first_name', required: true, example: 'Ana' }, { key: 'record.policy.number' }], selection_tags: {}, skills: {}, batch_row: '', change_summary: 'shorter' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('writes each configuration call with its method, path and body', async () => {
|
|
75
|
+
const { calls } = recordFetch([
|
|
76
|
+
on('GET', '/template-skills/s1', { json: { id: 's1', name: 'Tone', instructions: 'Be brief.' } }),
|
|
77
|
+
on('GET', '/books/b1', { json: { id: 'b1', name: 'Book' } }),
|
|
78
|
+
(c) => (c.method === 'GET' ? undefined : { json: { id: 'x' } }),
|
|
79
|
+
]);
|
|
80
|
+
const sdk = new CampaignsSDK(new IndemnClient());
|
|
81
|
+
await sdk.createCampaign({ solution_id: 's', book_id: 'b', name: 'N', campaign_type: 'renewal' });
|
|
82
|
+
await sdk.publish('c1', 'first');
|
|
83
|
+
await sdk.setStatus('c1', 'paused');
|
|
84
|
+
await sdk.createTemplate({ solution_id: 's', name: 'T', channel: 'email', subject: 'S', body: 'B {{ record.account.name }}', uses_variables: ['record.account.name'] });
|
|
85
|
+
await sdk.createTemplateSkill({ solution_id: 's', key: 'tone', name: 'Tone', instructions: 'Be brief.' });
|
|
86
|
+
await sdk.updateTemplateSkill('s1', { instructions: 'Be warm.' });
|
|
87
|
+
await sdk.createBook({ solution_id: 's', name: 'Book' });
|
|
88
|
+
await sdk.updateBook('b1', { source: { kind: 'upload' }, identity_keys: ['policy.number'], display: { primary: ['policy.number'], secondary: ['insured.name'] } });
|
|
89
|
+
const writes = calls.filter((c) => c.method !== 'GET');
|
|
90
|
+
expect(seen(writes)).toEqual([
|
|
91
|
+
'POST /campaigns', 'POST /campaigns/c1/publish', 'POST /campaigns/c1/status', 'POST /templates', 'POST /template-skills',
|
|
92
|
+
'PUT /template-skills/s1', 'POST /books', 'PATCH /books/b1/source', 'PUT /books/b1/identity-keys', 'PUT /books/b1/display',
|
|
93
|
+
]);
|
|
94
|
+
expect(writes.map((c) => c.body)).toEqual([
|
|
95
|
+
{ solution_id: 's', book_id: 'b', name: 'N', campaign_type: 'renewal' }, { change_summary: 'first' }, { status: 'paused' },
|
|
96
|
+
{ solution_id: 's', name: 'T', channel: 'email', subject: 'S', body: 'B {{ record.account.name }}', uses_variables: [{ key: 'record.account.name' }] }, { solution_id: 's', key: 'tone', name: 'Tone', instructions: 'Be brief.' },
|
|
97
|
+
{ name: 'Tone', instructions: 'Be warm.' }, { solution_id: 's', name: 'Book' }, { source: { kind: 'upload' } }, // the service reads body.source
|
|
98
|
+
{ identity_keys: ['policy.number'] }, { display: { primary: ['policy.number'], secondary: ['insured.name'] } },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
43
101
|
});
|
package/test/sdk/orgs.test.ts
CHANGED
|
@@ -58,6 +58,37 @@ describe('OrgsSDK', () => {
|
|
|
58
58
|
await expect(new OrgsSDK(new IndemnClient({ orgId: A })).listMembers()).resolves.toEqual([]);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
it('invites by email into the project, as the dashboard does', async () => {
|
|
62
|
+
const { calls } = recordFetch([on('POST', '/project_users/invite', { json: { msg: 'User not found, save invite in pending ', pendingInvitation: { _id: 'i1' } } })]);
|
|
63
|
+
await expect(new OrgsSDK(new IndemnClient({ orgId: A })).inviteMember('p1', ' Jane@Acme.test ', 'agent')).resolves.toEqual({
|
|
64
|
+
email: 'jane@acme.test', role: 'agent', outcome: 'invited',
|
|
65
|
+
message: 'Invitation sent to jane@acme.test. They join as agent once they sign up from the email.',
|
|
66
|
+
});
|
|
67
|
+
expect(new URL(calls[0].url).pathname).toBe('/api/p1/project_users/invite');
|
|
68
|
+
expect(calls[0].body).toEqual({ email: 'jane@acme.test', role: 'agent', user_available: false, id_organization: A });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('says when an existing account joined at once', async () => {
|
|
72
|
+
recordFetch([on('POST', '/project_users/invite', { json: { _id: 'pu1', role: 'admin', id_project: 'p1' } })]);
|
|
73
|
+
await expect(new OrgsSDK(new IndemnClient({ orgId: A })).inviteMember('p1', 'jo@acme.test', 'admin')).resolves.toMatchObject({ outcome: 'added', role: 'admin' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('already invited or already a member: nothing sent, said plainly', async () => {
|
|
77
|
+
recordFetch([on('POST', '/project_users/invite', { json: { success: false, msg: 'Pending Invitation already exist.', invite: {} } })]);
|
|
78
|
+
await expect(new OrgsSDK(new IndemnClient({ orgId: A })).inviteMember('p1', 'jane@acme.test', 'agent')).resolves.toMatchObject({
|
|
79
|
+
outcome: 'already_invited', message: 'jane@acme.test already has a pending invitation. Nothing was sent again.',
|
|
80
|
+
});
|
|
81
|
+
recordFetch([on('POST', '/project_users/invite', { status: 500, json: { success: false, msg: 'Forbidden. User is already a member', code: 4001, alreadyCurrentProjectMember: true } })]);
|
|
82
|
+
await expect(new OrgsSDK(new IndemnClient({ orgId: A })).inviteMember('p1', 'jo@acme.test', 'member')).resolves.toMatchObject({ outcome: 'already_member' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('refuses inviting yourself and keeps copilot\'s other refusals', async () => {
|
|
86
|
+
recordFetch([on('POST', '/project_users/invite', { status: 403, json: { success: false, msg: 'Forbidden. It is not allowed to invite oneself', code: 4000 } })]);
|
|
87
|
+
await expect(new OrgsSDK(new IndemnClient({ orgId: A })).inviteMember('p1', 'pat@acme.test', 'agent')).rejects.toThrow('You can\'t invite yourself. Nothing was sent.');
|
|
88
|
+
recordFetch([on('POST', '/project_users/invite', { status: 403, json: { success: false, msg: 'You don\'t have the required role.' } })]);
|
|
89
|
+
await expect(new OrgsSDK(new IndemnClient({ orgId: A })).inviteMember('p1', 'jane@acme.test', 'agent')).rejects.toThrow('Not allowed: You don\'t have the required role.');
|
|
90
|
+
});
|
|
91
|
+
|
|
61
92
|
it('creates a sandbox org with only its name and links to it', async () => {
|
|
62
93
|
const { calls } = recordFetch([on('POST', '/organization/sandbox', { json: { success: true, id_organization: A, id_project: 'p1', id_plan: 'plan1' } })]);
|
|
63
94
|
await expect(new OrgsSDK(new IndemnClient()).createSandbox('Acme Insurance')).resolves.toEqual({
|