thinkwork-cli 0.12.15 → 0.12.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{api-client-4VT3FWOH.js → api-client-JUBCQQDF.js} +1 -1
- package/dist/{chunk-H7AB42ES.js → chunk-STY44FQO.js} +13 -0
- package/dist/cli.js +1107 -10225
- package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +3 -8
- package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +1 -184
- package/dist/drizzle/0050_brain_v0_entity_subtype.sql +2 -2
- package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +6 -6
- package/dist/drizzle/0052_brain_v0_external_refs.sql +2 -2
- package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +2 -2
- package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +14 -14
- package/dist/drizzle/0146_knowledge_graph_source_scope.sql +1 -1
- package/dist/drizzle/0157_knowledge_graph_observations_source.sql +2 -2
- package/dist/drizzle/0158_wiki_tenant_scope.sql +1 -1
- package/dist/drizzle/0165_plugin_premium_entitlements.sql +2 -2
- package/dist/drizzle/{0166_company_brain_substrate_contract.sql → 0166_brain_substrate_contract.sql} +9 -9
- package/dist/drizzle/{0167_company_brain_artifact_manifest_runtime.sql → 0167_brain_artifact_manifest_runtime.sql} +5 -5
- package/dist/drizzle/0168_eval_skill_gate.sql +1 -1
- package/dist/drizzle/0183_okf_artifact_manifests.sql +1 -1
- package/dist/drizzle/0197_rcm_context_columns.sql +28 -0
- package/dist/drizzle/0200_eval_results_execution_tier.sql +40 -0
- package/dist/drizzle/0201_brain_dream_runs.sql +72 -0
- package/dist/drizzle/0202_backfill_thread_participants_from_senders.sql +110 -0
- package/dist/drizzle/0203_threads_mode_override.sql +61 -0
- package/dist/drizzle/0204_thread_turns_triggering_message_id.sql +48 -0
- package/dist/drizzle/0205_routines_git_python.sql +185 -0
- package/dist/drizzle/0206_agent_loop_versions_routine_actions.sql +40 -0
- package/dist/drizzle/0207_tenant_credentials_github_repo_kind.sql +52 -0
- package/dist/drizzle/0208_knowledge_graph_neutral_identifiers.sql +253 -0
- package/dist/drizzle/0209_living_artifacts_schema.sql +109 -0
- package/dist/drizzle/0210_think137_automations_additive_schema.sql +113 -0
- package/dist/drizzle/0211_backfill_agent_loop_versions_target_spec.sql +120 -0
- package/dist/drizzle/0212_fold_webhooks_into_automations.sql +312 -0
- package/dist/drizzle/0213_agent_loop_versions_judge_evidence_nullable.sql +63 -0
- package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +99 -0
- package/dist/drizzle/0215_agent_loop_versions_goal_worker_policy_nullable.sql +70 -0
- package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +81 -0
- package/dist/drizzle/0217_agents_capability_folder_dispatch.sql +12 -0
- package/dist/drizzle/0218_document_plates.sql +30 -0
- package/dist/drizzle/0219_document_section_waivers.sql +29 -0
- package/dist/drizzle/0220_artifacts_created_by_user.sql +21 -0
- package/dist/drizzle/0221_artifact_shares.sql +29 -0
- package/dist/drizzle/0221_workflow_interpreter_thin_slice.sql +71 -0
- package/dist/drizzle/0222_compliance_drainer_select_audit_events.sql +21 -0
- package/dist/drizzle/0223_document_conformance_reports.sql +38 -0
- package/dist/drizzle/0224_artifact_refresh_state.sql +11 -0
- package/dist/drizzle/0225_think216_agent_loop_convergence.sql +21 -0
- package/dist/drizzle/0226_drop_n8n_agent_step_runs.sql +6 -0
- package/dist/drizzle/0227_analyst_reader_role.sql +1055 -0
- package/dist/drizzle/0228_tenant_credentials_rds_iam_kind.sql +62 -0
- package/dist/drizzle/0229_analyst_reader_rds_iam_grant.sql +79 -0
- package/dist/drizzle/0230_analyst_rls.sql +1613 -0
- package/dist/drizzle/0231_cost_events_cache_write_enforcement_exempt.sql +11 -0
- package/dist/plugins/catalog/package.json +0 -1
- package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
- package/dist/plugins/catalog/src/__tests__/catalog.test.ts +16 -8
- package/dist/plugins/catalog/src/__tests__/contracts.test.ts +56 -0
- package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
- package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
- package/dist/plugins/catalog/src/contracts.ts +112 -0
- package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
- package/dist/plugins/n8n/README.md +4 -36
- package/dist/plugins/n8n/src/deployment/managed-app.ts +0 -17
- package/dist/plugins/n8n/src/manifest.ts +0 -5
- package/dist/plugins/n8n/terraform/n8n/main.tf +0 -26
- package/dist/plugins/n8n/terraform/n8n/outputs.tf +0 -5
- package/dist/plugins/n8n/terraform/n8n/variables.tf +0 -6
- package/dist/plugins/n8n/test/manifest.test.ts +0 -1
- package/dist/plugins/twenty/package.json +5 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +232 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +489 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +199 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +43 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +230 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/twenty-client.test.ts +207 -0
- package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +282 -0
- package/dist/plugins/twenty/scripts/lib/load-attachments.ts +220 -0
- package/dist/plugins/twenty/scripts/lib/load-records.ts +680 -0
- package/dist/plugins/twenty/scripts/lib/mappers.ts +559 -0
- package/dist/plugins/twenty/scripts/lib/members-ensure.ts +381 -0
- package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +191 -0
- package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +425 -0
- package/dist/plugins/twenty/scripts/lib/twenty-client.ts +246 -0
- package/dist/plugins/twenty/scripts/migrate-lastmile.ts +610 -0
- package/dist/plugins/twenty/scripts/provision-twenty-members.ts +214 -0
- package/dist/plugins/twenty/src/manifest.ts +99 -0
- package/dist/plugins/twenty/terraform/twenty/README.md +1 -1
- package/dist/plugins/twenty/test/manifest.test.ts +12 -2
- package/dist/terraform/examples/greenfield/main.tf +90 -380
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +0 -1
- package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
- package/dist/terraform/modules/app/agentcore-pi/main.tf +36 -32
- package/dist/terraform/modules/app/agentcore-pi/variables.tf +23 -15
- package/dist/terraform/modules/app/agentcore-runtime/main.tf +3 -3
- package/dist/terraform/modules/app/customer-domain/main.tf +16 -2
- package/dist/terraform/modules/app/deployment-control-plane/main.tf +5 -0
- package/dist/terraform/modules/app/deployment-control-plane/runner.py +85 -207
- package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +56 -2
- package/dist/terraform/modules/app/hindsight-memory/main.tf +52 -4
- package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +7 -0
- package/dist/terraform/modules/app/lambda-api/handlers.tf +493 -89
- package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +215 -96
- package/dist/terraform/modules/app/lambda-api/main.tf +24 -5
- package/dist/terraform/modules/app/lambda-api/outputs.tf +19 -11
- package/dist/terraform/modules/app/lambda-api/variables.tf +94 -67
- package/dist/terraform/modules/app/ses-email/main.tf +14 -2
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +493 -0
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/outputs.tf +34 -0
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/variables.tf +32 -0
- package/dist/terraform/modules/data/aurora-postgres/main.tf +55 -0
- package/dist/terraform/modules/data/aurora-postgres/outputs.tf +15 -0
- package/dist/terraform/modules/data/s3-buckets/main.tf +26 -0
- package/dist/terraform/modules/thinkwork/README.md +0 -1
- package/dist/terraform/modules/thinkwork/main.tf +167 -446
- package/dist/terraform/modules/thinkwork/outputs.tf +2 -142
- package/dist/terraform/modules/thinkwork/variables.tf +55 -309
- package/dist/terraform/schema.graphql +4 -0
- package/dist/workspace-defaults/files/AGENTS.md +45 -31
- package/dist/workspace-defaults/files/CONTEXT.md +48 -4
- package/dist/workspace-defaults/files/GUARDRAILS.md +18 -0
- package/dist/workspace-defaults/files/MEMORY_GUIDE.md +32 -4
- package/dist/workspace-defaults/files/SPACE.md +34 -19
- package/dist/workspace-defaults/files/TOOLS.md +30 -0
- package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +154 -0
- package/dist/workspace-defaults/files/skills/document-composer/references/authoring-rules.md +101 -0
- package/package.json +2 -1
- package/dist/plugins/company-brain/README.md +0 -37
- package/dist/plugins/company-brain/package.json +0 -33
- package/dist/plugins/company-brain/runtime/cognee/Dockerfile +0 -11
- package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +0 -458
- package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +0 -539
- package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +0 -312
- package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +0 -449
- package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +0 -655
- package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +0 -474
- package/dist/plugins/company-brain/src/api/cognee-client.ts +0 -896
- package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +0 -49
- package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +0 -84
- package/dist/plugins/company-brain/src/api/context-engine-provider.ts +0 -888
- package/dist/plugins/company-brain/src/api/migration.ts +0 -665
- package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +0 -396
- package/dist/plugins/company-brain/src/index.ts +0 -62
- package/dist/plugins/company-brain/src/manifest.ts +0 -112
- package/dist/plugins/company-brain/terraform/cognee/README.md +0 -263
- package/dist/plugins/company-brain/terraform/cognee/main.tf +0 -766
- package/dist/plugins/company-brain/terraform/cognee/outputs.tf +0 -144
- package/dist/plugins/company-brain/terraform/cognee/variables.tf +0 -402
- package/dist/plugins/company-brain/test/api/cognee-client.test.ts +0 -562
- package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +0 -74
- package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +0 -447
- package/dist/plugins/company-brain/test/api/migration.test.ts +0 -465
- package/dist/plugins/company-brain/test/manifest.test.ts +0 -95
- package/dist/plugins/company-brain/tsconfig.json +0 -8
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import bcrypt from "bcryptjs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { hashPassword, quoteIdent } from "../provision-members-db";
|
|
5
|
+
|
|
6
|
+
describe("hashPassword", () => {
|
|
7
|
+
it("emits the $2b$ prefix Twenty's own signup writes", () => {
|
|
8
|
+
const hash = hashPassword("TestPassword1!", 4);
|
|
9
|
+
expect(hash.startsWith("$2b$")).toBe(true);
|
|
10
|
+
expect(hash).toHaveLength(60);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("produces a hash the password verifies against, and nothing else does", () => {
|
|
14
|
+
const hash = hashPassword("TestPassword1!", 4);
|
|
15
|
+
expect(bcrypt.compareSync("TestPassword1!", hash)).toBe(true);
|
|
16
|
+
expect(bcrypt.compareSync("TestPassword1", hash)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("handles the shell-hostile characters in the shared rep password", () => {
|
|
20
|
+
const hash = hashPassword("Abc123$$", 4);
|
|
21
|
+
expect(bcrypt.compareSync("Abc123$$", hash)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("refuses passwords past bcrypt's silent 72-byte truncation point", () => {
|
|
25
|
+
expect(() => hashPassword("x".repeat(73), 4)).toThrow(/72-byte/);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("quoteIdent", () => {
|
|
30
|
+
it("accepts a Twenty workspace data schema", () => {
|
|
31
|
+
expect(quoteIdent("workspace_brl3ypdat40udm5gtn95sozcg")).toBe(
|
|
32
|
+
'"workspace_brl3ypdat40udm5gtn95sozcg"',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("refuses anything that is not a workspace schema", () => {
|
|
37
|
+
expect(() => quoteIdent("core")).toThrow(/Refusing/);
|
|
38
|
+
expect(() =>
|
|
39
|
+
quoteIdent('workspace_x"; drop table core."user"; --'),
|
|
40
|
+
).toThrow(/Refusing/);
|
|
41
|
+
expect(() => quoteIdent("public")).toThrow(/Refusing/);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applySchemaEnsure,
|
|
5
|
+
FIELD_SPECS,
|
|
6
|
+
PENDING_OBJECT_ID,
|
|
7
|
+
planSchemaEnsure,
|
|
8
|
+
} from "../schema-ensure";
|
|
9
|
+
import { MIGRATION_STAGE_OPTIONS } from "../mappers";
|
|
10
|
+
import type { TwentyClient } from "../twenty-client";
|
|
11
|
+
|
|
12
|
+
const EXISTING_STAGE_OPTIONS = [
|
|
13
|
+
{ id: "id-new", label: "New", value: "NEW", color: "red", position: 0 },
|
|
14
|
+
{
|
|
15
|
+
id: "id-cust",
|
|
16
|
+
label: "Customer",
|
|
17
|
+
value: "CUSTOMER",
|
|
18
|
+
color: "yellow",
|
|
19
|
+
position: 1,
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
interface FieldFixture {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
type: string;
|
|
27
|
+
options: typeof EXISTING_STAGE_OPTIONS | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function objectsFixture(
|
|
31
|
+
fieldsByObject: Record<string, FieldFixture[]>,
|
|
32
|
+
): Map<
|
|
33
|
+
string,
|
|
34
|
+
{ id: string; nameSingular: string; fields: Map<string, FieldFixture> }
|
|
35
|
+
> {
|
|
36
|
+
const map = new Map<
|
|
37
|
+
string,
|
|
38
|
+
{ id: string; nameSingular: string; fields: Map<string, FieldFixture> }
|
|
39
|
+
>();
|
|
40
|
+
for (const [name, fields] of Object.entries(fieldsByObject)) {
|
|
41
|
+
map.set(name, {
|
|
42
|
+
id: `obj-${name}`,
|
|
43
|
+
nameSingular: name,
|
|
44
|
+
fields: new Map(fields.map((field) => [field.name, field])),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return map;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const stageField: FieldFixture = {
|
|
51
|
+
id: "field-stage",
|
|
52
|
+
name: "stage",
|
|
53
|
+
type: "SELECT",
|
|
54
|
+
options: EXISTING_STAGE_OPTIONS,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function bareObjects() {
|
|
58
|
+
return objectsFixture({
|
|
59
|
+
company: [],
|
|
60
|
+
person: [],
|
|
61
|
+
opportunity: [stageField],
|
|
62
|
+
note: [],
|
|
63
|
+
attachment: [],
|
|
64
|
+
// The product-line object exists (ensureOpportunityProductObject ran) but
|
|
65
|
+
// has none of its fields yet.
|
|
66
|
+
opportunityProduct: [],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A workspace that has never seen the product-line object at all. */
|
|
71
|
+
function objectsWithoutProductObject() {
|
|
72
|
+
return objectsFixture({
|
|
73
|
+
company: [],
|
|
74
|
+
person: [],
|
|
75
|
+
opportunity: [stageField],
|
|
76
|
+
note: [],
|
|
77
|
+
attachment: [],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function provisionedObjects() {
|
|
82
|
+
const withAll = (object: string): FieldFixture[] =>
|
|
83
|
+
FIELD_SPECS.filter((spec) => spec.object === object).map((spec, index) => ({
|
|
84
|
+
id: `f-${object}-${index}`,
|
|
85
|
+
name: spec.name,
|
|
86
|
+
type: spec.type,
|
|
87
|
+
options: null,
|
|
88
|
+
}));
|
|
89
|
+
const opportunityFields = [
|
|
90
|
+
...withAll("opportunity"),
|
|
91
|
+
{
|
|
92
|
+
...stageField,
|
|
93
|
+
options: [
|
|
94
|
+
...EXISTING_STAGE_OPTIONS,
|
|
95
|
+
...MIGRATION_STAGE_OPTIONS.map((option, index) => ({
|
|
96
|
+
id: `id-${option.value}`,
|
|
97
|
+
...option,
|
|
98
|
+
position: EXISTING_STAGE_OPTIONS.length + index,
|
|
99
|
+
})),
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
return objectsFixture({
|
|
104
|
+
company: withAll("company"),
|
|
105
|
+
person: withAll("person"),
|
|
106
|
+
opportunity: opportunityFields,
|
|
107
|
+
note: withAll("note"),
|
|
108
|
+
attachment: withAll("attachment"),
|
|
109
|
+
opportunityProduct: [
|
|
110
|
+
...withAll("opportunityProduct"),
|
|
111
|
+
{
|
|
112
|
+
id: "f-op-rel",
|
|
113
|
+
name: "opportunity",
|
|
114
|
+
type: "RELATION",
|
|
115
|
+
options: null,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe("planSchemaEnsure", () => {
|
|
122
|
+
it("plans every missing field and all stage options on an empty workspace", () => {
|
|
123
|
+
const plan = planSchemaEnsure(bareObjects());
|
|
124
|
+
expect(plan.createFields).toHaveLength(FIELD_SPECS.length);
|
|
125
|
+
expect(plan.stageOptionsToAdd).toEqual(
|
|
126
|
+
MIGRATION_STAGE_OPTIONS.map((option) => option.value),
|
|
127
|
+
);
|
|
128
|
+
// Full-replace semantics (U1): the merged array keeps existing options first.
|
|
129
|
+
expect(
|
|
130
|
+
plan.mergedStageOptions?.slice(0, 2).map((option) => option.value),
|
|
131
|
+
).toEqual(["NEW", "CUSTOMER"]);
|
|
132
|
+
expect(plan.mergedStageOptions).toHaveLength(
|
|
133
|
+
EXISTING_STAGE_OPTIONS.length + MIGRATION_STAGE_OPTIONS.length,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("is a no-op against already-provisioned metadata (idempotent)", () => {
|
|
138
|
+
const plan = planSchemaEnsure(provisionedObjects());
|
|
139
|
+
expect(plan.createFields).toHaveLength(0);
|
|
140
|
+
expect(plan.stageOptionsToAdd).toEqual([]);
|
|
141
|
+
expect(plan.mergedStageOptions).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("creates only the gap on partial presence", () => {
|
|
145
|
+
const objects = bareObjects();
|
|
146
|
+
objects.get("opportunity")!.fields.set("product", {
|
|
147
|
+
id: "f-product",
|
|
148
|
+
name: "product",
|
|
149
|
+
type: "TEXT",
|
|
150
|
+
options: null,
|
|
151
|
+
});
|
|
152
|
+
const plan = planSchemaEnsure(objects);
|
|
153
|
+
const names = plan.createFields.map(
|
|
154
|
+
(field) => `${field.object}.${field.name}`,
|
|
155
|
+
);
|
|
156
|
+
expect(names).not.toContain("opportunity.product");
|
|
157
|
+
expect(names).toContain("opportunity.quantity");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("aborts when the stage field is missing", () => {
|
|
161
|
+
const objects = objectsFixture({
|
|
162
|
+
company: [],
|
|
163
|
+
person: [],
|
|
164
|
+
opportunity: [],
|
|
165
|
+
note: [],
|
|
166
|
+
attachment: [],
|
|
167
|
+
opportunityProduct: [],
|
|
168
|
+
});
|
|
169
|
+
expect(() => planSchemaEnsure(objects)).toThrow(/stage field/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("plans product-line fields against a pending object id when it does not exist yet", () => {
|
|
173
|
+
const plan = planSchemaEnsure(objectsWithoutProductObject());
|
|
174
|
+
const productFields = plan.createFields.filter(
|
|
175
|
+
(field) => field.object === "opportunityProduct",
|
|
176
|
+
);
|
|
177
|
+
expect(productFields.length).toBeGreaterThan(0);
|
|
178
|
+
expect(
|
|
179
|
+
productFields.every(
|
|
180
|
+
(field) => field.objectMetadataId === PENDING_OBJECT_ID,
|
|
181
|
+
),
|
|
182
|
+
).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("applySchemaEnsure", () => {
|
|
187
|
+
it("issues createOneField per missing field and one full-replace options write", async () => {
|
|
188
|
+
const requestOnce = vi.fn(async () => ({}));
|
|
189
|
+
const client = { requestOnce } as unknown as TwentyClient;
|
|
190
|
+
const plan = planSchemaEnsure(bareObjects());
|
|
191
|
+
const result = await applySchemaEnsure(client, plan);
|
|
192
|
+
expect(result.createdFields).toHaveLength(FIELD_SPECS.length);
|
|
193
|
+
// one createOneField call per field + one updateOneField for stage options
|
|
194
|
+
expect(requestOnce).toHaveBeenCalledTimes(FIELD_SPECS.length + 1);
|
|
195
|
+
const lastCall = requestOnce.mock.calls.at(-1) as unknown as [
|
|
196
|
+
string,
|
|
197
|
+
string,
|
|
198
|
+
{ input: { update: { options: Array<{ value: string }> } } },
|
|
199
|
+
];
|
|
200
|
+
expect(
|
|
201
|
+
lastCall[2].input.update.options.map((option) => option.value),
|
|
202
|
+
).toContain("CUSTOMER");
|
|
203
|
+
expect(
|
|
204
|
+
lastCall[2].input.update.options.map((option) => option.value),
|
|
205
|
+
).toContain("LEAD");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("refuses to create a field against a pending object id", async () => {
|
|
209
|
+
// Guards the ordering bug: fields must be planned only after
|
|
210
|
+
// ensureOpportunityProductObject has created the object.
|
|
211
|
+
const requestOnce = vi.fn(async () => ({}));
|
|
212
|
+
const client = { requestOnce } as unknown as TwentyClient;
|
|
213
|
+
const plan = planSchemaEnsure(objectsWithoutProductObject());
|
|
214
|
+
await expect(applySchemaEnsure(client, plan)).rejects.toThrow(
|
|
215
|
+
/planned against an uncreated object/,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("aborts on the first metadata mutation error", async () => {
|
|
220
|
+
const requestOnce = vi.fn(async () => {
|
|
221
|
+
throw new Error("metadata boom");
|
|
222
|
+
});
|
|
223
|
+
const client = { requestOnce } as unknown as TwentyClient;
|
|
224
|
+
const plan = planSchemaEnsure(bareObjects());
|
|
225
|
+
await expect(applySchemaEnsure(client, plan)).rejects.toThrow(
|
|
226
|
+
/metadata boom/,
|
|
227
|
+
);
|
|
228
|
+
expect(requestOnce).toHaveBeenCalledTimes(1);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BATCH_LIMIT,
|
|
5
|
+
chunk,
|
|
6
|
+
normalizeBaseUrl,
|
|
7
|
+
TwentyClient,
|
|
8
|
+
TwentyGraphqlError,
|
|
9
|
+
} from "../twenty-client";
|
|
10
|
+
|
|
11
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
12
|
+
return new Response(JSON.stringify(body), {
|
|
13
|
+
status,
|
|
14
|
+
headers: { "content-type": "application/json" },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("normalizeBaseUrl", () => {
|
|
19
|
+
it("resolves the endpoint from TWENTY_PUBLIC_URL plus path", () => {
|
|
20
|
+
const client = new TwentyClient({
|
|
21
|
+
baseUrl: "https://crm.tei.thinkwork.ai/",
|
|
22
|
+
authToken: "key",
|
|
23
|
+
});
|
|
24
|
+
expect(client.endpoint("/graphql")).toBe(
|
|
25
|
+
"https://crm.tei.thinkwork.ai/graphql",
|
|
26
|
+
);
|
|
27
|
+
expect(client.endpoint("/metadata")).toBe(
|
|
28
|
+
"https://crm.tei.thinkwork.ai/metadata",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("rejects non-HTTPS URLs", () => {
|
|
33
|
+
expect(() => normalizeBaseUrl("http://crm.tei.thinkwork.ai")).toThrow(
|
|
34
|
+
/HTTPS/,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("chunk", () => {
|
|
40
|
+
it("splits 130 records into 3 chunks at the batch limit", () => {
|
|
41
|
+
const chunks = chunk(
|
|
42
|
+
Array.from({ length: 130 }, (_, index) => index),
|
|
43
|
+
BATCH_LIMIT,
|
|
44
|
+
);
|
|
45
|
+
expect(chunks.map((c) => c.length)).toEqual([60, 60, 10]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects a chunk size below 1", () => {
|
|
49
|
+
expect(() => chunk([1], 0)).toThrow(/>= 1/);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("TwentyClient.requestOnce", () => {
|
|
54
|
+
it("throws with the message when GraphQL returns errors[]", async () => {
|
|
55
|
+
const fetchImpl = vi.fn(async () =>
|
|
56
|
+
jsonResponse({ errors: [{ message: "Field boom is required" }] }),
|
|
57
|
+
);
|
|
58
|
+
const client = new TwentyClient({
|
|
59
|
+
baseUrl: "https://crm.example.com",
|
|
60
|
+
authToken: "key",
|
|
61
|
+
fetchImpl,
|
|
62
|
+
minRequestIntervalMs: 0,
|
|
63
|
+
rateLimitWaitMs: 1,
|
|
64
|
+
});
|
|
65
|
+
await expect(client.requestOnce("/graphql", "query { x }")).rejects.toThrow(
|
|
66
|
+
/Field boom is required/,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("sends the Bearer auth header", async () => {
|
|
71
|
+
const fetchImpl = vi.fn(async () => jsonResponse({ data: { ok: true } }));
|
|
72
|
+
const client = new TwentyClient({
|
|
73
|
+
baseUrl: "https://crm.example.com",
|
|
74
|
+
authToken: "secret-key",
|
|
75
|
+
fetchImpl,
|
|
76
|
+
minRequestIntervalMs: 0,
|
|
77
|
+
});
|
|
78
|
+
await client.requestOnce("/graphql", "query { x }");
|
|
79
|
+
const [, init] = fetchImpl.mock.calls[0] as unknown as [
|
|
80
|
+
string,
|
|
81
|
+
RequestInit,
|
|
82
|
+
];
|
|
83
|
+
expect((init.headers as Record<string, string>).authorization).toBe(
|
|
84
|
+
"Bearer secret-key",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("flags duplicate-key errors", async () => {
|
|
89
|
+
const fetchImpl = vi.fn(async () =>
|
|
90
|
+
jsonResponse({
|
|
91
|
+
errors: [
|
|
92
|
+
{ message: 'duplicate key value violates unique constraint "x"' },
|
|
93
|
+
],
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
const client = new TwentyClient({
|
|
97
|
+
baseUrl: "https://crm.example.com",
|
|
98
|
+
authToken: "key",
|
|
99
|
+
fetchImpl,
|
|
100
|
+
minRequestIntervalMs: 0,
|
|
101
|
+
rateLimitWaitMs: 1,
|
|
102
|
+
});
|
|
103
|
+
const error = await client
|
|
104
|
+
.requestOnce("/graphql", "mutation { y }")
|
|
105
|
+
.catch((e) => e);
|
|
106
|
+
expect(error).toBeInstanceOf(TwentyGraphqlError);
|
|
107
|
+
expect((error as TwentyGraphqlError).isDuplicateError).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("TwentyClient.requestWithRetry", () => {
|
|
112
|
+
it("retries on 429 with backoff then succeeds", async () => {
|
|
113
|
+
const fetchImpl = vi
|
|
114
|
+
.fn()
|
|
115
|
+
.mockResolvedValueOnce(new Response("rate limited", { status: 429 }))
|
|
116
|
+
.mockResolvedValueOnce(jsonResponse({ data: { ok: true } }));
|
|
117
|
+
const client = new TwentyClient({
|
|
118
|
+
baseUrl: "https://crm.example.com",
|
|
119
|
+
authToken: "key",
|
|
120
|
+
fetchImpl,
|
|
121
|
+
backoffMs: 1,
|
|
122
|
+
minRequestIntervalMs: 0,
|
|
123
|
+
rateLimitWaitMs: 1,
|
|
124
|
+
});
|
|
125
|
+
const data = await client.requestWithRetry<{ ok: boolean }>(
|
|
126
|
+
"/graphql",
|
|
127
|
+
"query { x }",
|
|
128
|
+
);
|
|
129
|
+
expect(data.ok).toBe(true);
|
|
130
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("does not retry GraphQL validation errors", async () => {
|
|
134
|
+
const fetchImpl = vi.fn(async () =>
|
|
135
|
+
jsonResponse({ errors: [{ message: "bad query" }] }),
|
|
136
|
+
);
|
|
137
|
+
const client = new TwentyClient({
|
|
138
|
+
baseUrl: "https://crm.example.com",
|
|
139
|
+
authToken: "key",
|
|
140
|
+
fetchImpl,
|
|
141
|
+
backoffMs: 1,
|
|
142
|
+
minRequestIntervalMs: 0,
|
|
143
|
+
rateLimitWaitMs: 1,
|
|
144
|
+
});
|
|
145
|
+
await expect(
|
|
146
|
+
client.requestWithRetry("/graphql", "query { x }"),
|
|
147
|
+
).rejects.toThrow(/bad query/);
|
|
148
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("gives up after maxRetries on persistent 5xx", async () => {
|
|
152
|
+
const fetchImpl = vi.fn(async () => new Response("boom", { status: 502 }));
|
|
153
|
+
const client = new TwentyClient({
|
|
154
|
+
baseUrl: "https://crm.example.com",
|
|
155
|
+
authToken: "key",
|
|
156
|
+
fetchImpl,
|
|
157
|
+
backoffMs: 1,
|
|
158
|
+
maxRetries: 2,
|
|
159
|
+
minRequestIntervalMs: 0,
|
|
160
|
+
rateLimitWaitMs: 1,
|
|
161
|
+
});
|
|
162
|
+
await expect(
|
|
163
|
+
client.requestWithRetry("/graphql", "query { x }"),
|
|
164
|
+
).rejects.toThrow(/502/);
|
|
165
|
+
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("network errors", () => {
|
|
170
|
+
it("retries socket-level failures on reads", async () => {
|
|
171
|
+
const fetchImpl = vi
|
|
172
|
+
.fn()
|
|
173
|
+
.mockRejectedValueOnce(new TypeError("fetch failed"))
|
|
174
|
+
.mockResolvedValueOnce(jsonResponse({ data: { ok: true } }));
|
|
175
|
+
const client = new TwentyClient({
|
|
176
|
+
baseUrl: "https://crm.example.com",
|
|
177
|
+
authToken: "key",
|
|
178
|
+
fetchImpl,
|
|
179
|
+
backoffMs: 1,
|
|
180
|
+
minRequestIntervalMs: 0,
|
|
181
|
+
rateLimitWaitMs: 1,
|
|
182
|
+
});
|
|
183
|
+
const data = await client.requestWithRetry<{ ok: boolean }>(
|
|
184
|
+
"/graphql",
|
|
185
|
+
"query { x }",
|
|
186
|
+
);
|
|
187
|
+
expect(data.ok).toBe(true);
|
|
188
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("wraps network failures with context on requestOnce", async () => {
|
|
192
|
+
const fetchImpl = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
|
|
193
|
+
const client = new TwentyClient({
|
|
194
|
+
baseUrl: "https://crm.example.com",
|
|
195
|
+
authToken: "key",
|
|
196
|
+
fetchImpl,
|
|
197
|
+
minRequestIntervalMs: 0,
|
|
198
|
+
rateLimitWaitMs: 1,
|
|
199
|
+
});
|
|
200
|
+
const error = await client
|
|
201
|
+
.requestOnce("/graphql", "mutation { y }")
|
|
202
|
+
.catch((e) => e);
|
|
203
|
+
expect(error).toBeInstanceOf(TwentyGraphqlError);
|
|
204
|
+
expect((error as TwentyGraphqlError).isNetworkError).toBe(true);
|
|
205
|
+
expect((error as TwentyGraphqlError).message).toMatch(/network error/);
|
|
206
|
+
});
|
|
207
|
+
});
|