@tsctl/cli 0.2.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 +735 -0
- package/bin/tsctl.js +2 -0
- package/package.json +65 -0
- package/src/__tests__/analyticsrules.test.ts +303 -0
- package/src/__tests__/apikeys.test.ts +223 -0
- package/src/__tests__/apply.test.ts +245 -0
- package/src/__tests__/client.test.ts +48 -0
- package/src/__tests__/collection-advanced.test.ts +274 -0
- package/src/__tests__/config-loader.test.ts +217 -0
- package/src/__tests__/curationsets.test.ts +190 -0
- package/src/__tests__/helpers.ts +17 -0
- package/src/__tests__/import-drift.test.ts +231 -0
- package/src/__tests__/migrate-advanced.test.ts +197 -0
- package/src/__tests__/migrate.test.ts +220 -0
- package/src/__tests__/plan-new-resources.test.ts +258 -0
- package/src/__tests__/plan.test.ts +337 -0
- package/src/__tests__/presets.test.ts +97 -0
- package/src/__tests__/resources.test.ts +592 -0
- package/src/__tests__/setup.ts +77 -0
- package/src/__tests__/state.test.ts +312 -0
- package/src/__tests__/stemmingdictionaries.test.ts +111 -0
- package/src/__tests__/stopwords.test.ts +109 -0
- package/src/__tests__/synonymsets.test.ts +170 -0
- package/src/__tests__/types.test.ts +416 -0
- package/src/apply/index.ts +336 -0
- package/src/cli/index.ts +1106 -0
- package/src/client/index.ts +55 -0
- package/src/config/loader.ts +158 -0
- package/src/index.ts +45 -0
- package/src/migrate/index.ts +220 -0
- package/src/plan/index.ts +1333 -0
- package/src/resources/alias.ts +59 -0
- package/src/resources/analyticsrule.ts +134 -0
- package/src/resources/apikey.ts +203 -0
- package/src/resources/collection.ts +424 -0
- package/src/resources/curationset.ts +155 -0
- package/src/resources/index.ts +11 -0
- package/src/resources/override.ts +174 -0
- package/src/resources/preset.ts +83 -0
- package/src/resources/stemmingdictionary.ts +103 -0
- package/src/resources/stopword.ts +100 -0
- package/src/resources/synonym.ts +152 -0
- package/src/resources/synonymset.ts +144 -0
- package/src/state/index.ts +206 -0
- package/src/types/index.ts +451 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { writeFileSync, mkdirSync, rmSync, existsSync } from "fs";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
import { loadConfig, findConfigFile } from "../config/loader.js";
|
|
5
|
+
|
|
6
|
+
const TEST_DIR = resolve("/tmp/tsctl-config-test");
|
|
7
|
+
|
|
8
|
+
describe("config loader", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
if (existsSync(TEST_DIR)) {
|
|
11
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (existsSync(TEST_DIR)) {
|
|
18
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("JSON config", () => {
|
|
23
|
+
test("loads tsctl.config.json", async () => {
|
|
24
|
+
const configPath = resolve(TEST_DIR, "tsctl.config.json");
|
|
25
|
+
writeFileSync(
|
|
26
|
+
configPath,
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
collections: [
|
|
29
|
+
{
|
|
30
|
+
name: "products",
|
|
31
|
+
fields: [{ name: "title", type: "string" }],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const config = await loadConfig(configPath);
|
|
38
|
+
expect(config.collections).toHaveLength(1);
|
|
39
|
+
expect(config.collections![0]!.name).toBe("products");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("loads .tsctlrc.json", async () => {
|
|
43
|
+
const configPath = resolve(TEST_DIR, ".tsctlrc.json");
|
|
44
|
+
writeFileSync(
|
|
45
|
+
configPath,
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
aliases: [{ name: "test", collection: "products" }],
|
|
48
|
+
})
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const config = await loadConfig(configPath);
|
|
52
|
+
expect(config.aliases).toHaveLength(1);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("YAML config", () => {
|
|
57
|
+
test("loads tsctl.config.yaml", async () => {
|
|
58
|
+
const configPath = resolve(TEST_DIR, "tsctl.config.yaml");
|
|
59
|
+
writeFileSync(
|
|
60
|
+
configPath,
|
|
61
|
+
`collections:
|
|
62
|
+
- name: products
|
|
63
|
+
fields:
|
|
64
|
+
- name: title
|
|
65
|
+
type: string
|
|
66
|
+
- name: price
|
|
67
|
+
type: float
|
|
68
|
+
`
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const config = await loadConfig(configPath);
|
|
72
|
+
expect(config.collections).toHaveLength(1);
|
|
73
|
+
expect(config.collections![0]!.fields).toHaveLength(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("loads tsctl.config.yml", async () => {
|
|
77
|
+
const configPath = resolve(TEST_DIR, "tsctl.config.yml");
|
|
78
|
+
writeFileSync(
|
|
79
|
+
configPath,
|
|
80
|
+
`aliases:
|
|
81
|
+
- name: products_live
|
|
82
|
+
collection: products
|
|
83
|
+
`
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const config = await loadConfig(configPath);
|
|
87
|
+
expect(config.aliases).toHaveLength(1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("TypeScript config", () => {
|
|
92
|
+
test("loads tsctl.config.ts", async () => {
|
|
93
|
+
const configPath = resolve(TEST_DIR, "tsctl.config.ts");
|
|
94
|
+
writeFileSync(
|
|
95
|
+
configPath,
|
|
96
|
+
`export default {
|
|
97
|
+
collections: [
|
|
98
|
+
{
|
|
99
|
+
name: "products",
|
|
100
|
+
fields: [{ name: "title", type: "string" }],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
`
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const config = await loadConfig(configPath);
|
|
108
|
+
expect(config.collections).toHaveLength(1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("validation", () => {
|
|
113
|
+
test("validates field types", async () => {
|
|
114
|
+
const configPath = resolve(TEST_DIR, "tsctl.config.json");
|
|
115
|
+
writeFileSync(
|
|
116
|
+
configPath,
|
|
117
|
+
JSON.stringify({
|
|
118
|
+
collections: [
|
|
119
|
+
{
|
|
120
|
+
name: "bad",
|
|
121
|
+
fields: [{ name: "x", type: "invalid_type" }],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(loadConfig(configPath)).rejects.toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("validates collection requires fields", async () => {
|
|
131
|
+
const configPath = resolve(TEST_DIR, "tsctl.config.json");
|
|
132
|
+
writeFileSync(
|
|
133
|
+
configPath,
|
|
134
|
+
JSON.stringify({
|
|
135
|
+
collections: [{ name: "bad" }],
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(loadConfig(configPath)).rejects.toThrow();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("accepts empty config", async () => {
|
|
143
|
+
const configPath = resolve(TEST_DIR, "tsctl.config.json");
|
|
144
|
+
writeFileSync(configPath, "{}");
|
|
145
|
+
|
|
146
|
+
const config = await loadConfig(configPath);
|
|
147
|
+
expect(config.collections).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("validates full config with all resource types", async () => {
|
|
151
|
+
const configPath = resolve(TEST_DIR, "tsctl.config.json");
|
|
152
|
+
writeFileSync(
|
|
153
|
+
configPath,
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
collections: [
|
|
156
|
+
{ name: "products", fields: [{ name: "title", type: "string" }] },
|
|
157
|
+
],
|
|
158
|
+
aliases: [{ name: "products_live", collection: "products" }],
|
|
159
|
+
stopwords: [{ id: "english", stopwords: ["the", "a"] }],
|
|
160
|
+
presets: [{ name: "default", value: { q: "*" } }],
|
|
161
|
+
apiKeys: [
|
|
162
|
+
{ description: "key1", actions: ["documents:search"], collections: ["*"] },
|
|
163
|
+
],
|
|
164
|
+
curationSets: [
|
|
165
|
+
{
|
|
166
|
+
name: "curations",
|
|
167
|
+
items: [
|
|
168
|
+
{ id: "rule1", rule: { query: "test", match: "exact" }, filter_by: "x:=1" },
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
stemmingDictionaries: [
|
|
173
|
+
{ id: "dict1", words: [{ word: "dogs", root: "dog" }] },
|
|
174
|
+
],
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const config = await loadConfig(configPath);
|
|
179
|
+
expect(config.collections).toHaveLength(1);
|
|
180
|
+
expect(config.stopwords).toHaveLength(1);
|
|
181
|
+
expect(config.presets).toHaveLength(1);
|
|
182
|
+
expect(config.curationSets).toHaveLength(1);
|
|
183
|
+
expect(config.stemmingDictionaries).toHaveLength(1);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("findConfigFile", () => {
|
|
188
|
+
test("finds tsctl.config.json in directory", async () => {
|
|
189
|
+
writeFileSync(
|
|
190
|
+
resolve(TEST_DIR, "tsctl.config.json"),
|
|
191
|
+
JSON.stringify({ collections: [] })
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const found = await findConfigFile(TEST_DIR);
|
|
195
|
+
expect(found).not.toBeNull();
|
|
196
|
+
expect(found!).toContain("tsctl.config.json");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("returns null when no config exists", async () => {
|
|
200
|
+
const emptyDir = resolve(TEST_DIR, "empty");
|
|
201
|
+
mkdirSync(emptyDir, { recursive: true });
|
|
202
|
+
// findConfigFile searches up to root, so it might find the repo's config
|
|
203
|
+
// Just test it doesn't throw
|
|
204
|
+
const found = await findConfigFile(emptyDir);
|
|
205
|
+
// May or may not find a config in parent dirs
|
|
206
|
+
expect(typeof found === "string" || found === null).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("error handling", () => {
|
|
211
|
+
test("throws on non-existent file", async () => {
|
|
212
|
+
expect(
|
|
213
|
+
loadConfig(resolve(TEST_DIR, "nonexistent.json"))
|
|
214
|
+
).rejects.toThrow();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { setupClient, cleanupTypesense } from "./setup.js";
|
|
3
|
+
import { getTypesenseVersion } from "./helpers.js";
|
|
4
|
+
import {
|
|
5
|
+
createCurationSet,
|
|
6
|
+
getCurationSet,
|
|
7
|
+
listCurationSets,
|
|
8
|
+
updateCurationSet,
|
|
9
|
+
deleteCurationSet,
|
|
10
|
+
curationSetConfigsEqual,
|
|
11
|
+
} from "../resources/curationset.js";
|
|
12
|
+
import type { CurationSetConfig } from "../types/index.js";
|
|
13
|
+
|
|
14
|
+
describe("curation sets (v30+)", () => {
|
|
15
|
+
let version: number;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
setupClient();
|
|
19
|
+
await cleanupTypesense();
|
|
20
|
+
version = await getTypesenseVersion();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await cleanupTypesense();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("createCurationSet creates a curation set", async () => {
|
|
28
|
+
if (version < 30) return; // Skip on pre-v30
|
|
29
|
+
|
|
30
|
+
const config: CurationSetConfig = {
|
|
31
|
+
name: "product-curations",
|
|
32
|
+
items: [
|
|
33
|
+
{
|
|
34
|
+
id: "pin-featured",
|
|
35
|
+
rule: { query: "featured", match: "exact" },
|
|
36
|
+
includes: [{ id: "product-123", position: 1 }],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
await createCurationSet(config);
|
|
42
|
+
const result = await getCurationSet("product-curations");
|
|
43
|
+
expect(result).not.toBeNull();
|
|
44
|
+
expect(result!.name).toBe("product-curations");
|
|
45
|
+
expect(result!.items).toHaveLength(1);
|
|
46
|
+
expect(result!.items[0]!.id).toBe("pin-featured");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("getCurationSet returns null for non-existent", async () => {
|
|
50
|
+
if (version < 30) return;
|
|
51
|
+
|
|
52
|
+
const result = await getCurationSet("nonexistent");
|
|
53
|
+
expect(result).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("createCurationSet with filter_by rule", async () => {
|
|
57
|
+
if (version < 30) return;
|
|
58
|
+
|
|
59
|
+
const config: CurationSetConfig = {
|
|
60
|
+
name: "category-boost",
|
|
61
|
+
items: [
|
|
62
|
+
{
|
|
63
|
+
id: "boost-shoes",
|
|
64
|
+
rule: { query: "shoes", match: "contains" },
|
|
65
|
+
filter_by: "category:=footwear",
|
|
66
|
+
sort_by: "popularity:desc",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
await createCurationSet(config);
|
|
72
|
+
const result = await getCurationSet("category-boost");
|
|
73
|
+
expect(result).not.toBeNull();
|
|
74
|
+
expect(result!.items[0]!.filter_by).toBe("category:=footwear");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("listCurationSets returns all curation sets", async () => {
|
|
78
|
+
if (version < 30) return;
|
|
79
|
+
|
|
80
|
+
await createCurationSet({
|
|
81
|
+
name: "set1",
|
|
82
|
+
items: [
|
|
83
|
+
{
|
|
84
|
+
id: "rule1",
|
|
85
|
+
rule: { query: "a", match: "exact" },
|
|
86
|
+
filter_by: "x:=1",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
await createCurationSet({
|
|
91
|
+
name: "set2",
|
|
92
|
+
items: [
|
|
93
|
+
{
|
|
94
|
+
id: "rule2",
|
|
95
|
+
rule: { query: "b", match: "exact" },
|
|
96
|
+
filter_by: "y:=2",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const sets = await listCurationSets();
|
|
102
|
+
const names = sets.map((s) => s.name);
|
|
103
|
+
expect(names).toContain("set1");
|
|
104
|
+
expect(names).toContain("set2");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("updateCurationSet modifies items", async () => {
|
|
108
|
+
if (version < 30) return;
|
|
109
|
+
|
|
110
|
+
const initial: CurationSetConfig = {
|
|
111
|
+
name: "my-curations",
|
|
112
|
+
items: [
|
|
113
|
+
{
|
|
114
|
+
id: "rule1",
|
|
115
|
+
rule: { query: "a", match: "exact" },
|
|
116
|
+
filter_by: "x:=1",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
await createCurationSet(initial);
|
|
121
|
+
|
|
122
|
+
const updated: CurationSetConfig = {
|
|
123
|
+
name: "my-curations",
|
|
124
|
+
items: [
|
|
125
|
+
{
|
|
126
|
+
id: "rule1",
|
|
127
|
+
rule: { query: "a", match: "exact" },
|
|
128
|
+
filter_by: "x:=1",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "rule2",
|
|
132
|
+
rule: { query: "b", match: "contains" },
|
|
133
|
+
filter_by: "y:=2",
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
await updateCurationSet(updated, initial);
|
|
138
|
+
|
|
139
|
+
const result = await getCurationSet("my-curations");
|
|
140
|
+
expect(result!.items).toHaveLength(2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("deleteCurationSet removes set", async () => {
|
|
144
|
+
if (version < 30) return;
|
|
145
|
+
|
|
146
|
+
await createCurationSet({
|
|
147
|
+
name: "to-delete",
|
|
148
|
+
items: [
|
|
149
|
+
{
|
|
150
|
+
id: "rule1",
|
|
151
|
+
rule: { query: "x", match: "exact" },
|
|
152
|
+
filter_by: "a:=1",
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
await deleteCurationSet("to-delete");
|
|
157
|
+
const result = await getCurationSet("to-delete");
|
|
158
|
+
expect(result).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("curationSetConfigsEqual compares correctly", () => {
|
|
162
|
+
const a: CurationSetConfig = {
|
|
163
|
+
name: "test",
|
|
164
|
+
items: [
|
|
165
|
+
{ id: "a", rule: { query: "x", match: "exact" } },
|
|
166
|
+
{ id: "b", rule: { query: "y", match: "contains" } },
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
const b: CurationSetConfig = {
|
|
170
|
+
name: "test",
|
|
171
|
+
items: [
|
|
172
|
+
{ id: "b", rule: { query: "y", match: "contains" } },
|
|
173
|
+
{ id: "a", rule: { query: "x", match: "exact" } },
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
expect(curationSetConfigsEqual(a, b)).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("curationSetConfigsEqual detects differences", () => {
|
|
180
|
+
const a: CurationSetConfig = {
|
|
181
|
+
name: "test",
|
|
182
|
+
items: [{ id: "a", rule: { query: "x", match: "exact" } }],
|
|
183
|
+
};
|
|
184
|
+
const b: CurationSetConfig = {
|
|
185
|
+
name: "test",
|
|
186
|
+
items: [{ id: "a", rule: { query: "y", match: "exact" } }],
|
|
187
|
+
};
|
|
188
|
+
expect(curationSetConfigsEqual(a, b)).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getClient } from "../client/index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get the Typesense server version as a number (e.g., 27 or 30)
|
|
5
|
+
*/
|
|
6
|
+
export async function getTypesenseVersion(): Promise<number> {
|
|
7
|
+
const client = getClient();
|
|
8
|
+
try {
|
|
9
|
+
const debug = await client.debug.retrieve();
|
|
10
|
+
const version = (debug as any).version as string;
|
|
11
|
+
// Version string is like "30.0" or "27.1"
|
|
12
|
+
return parseInt(version.split(".")[0]!, 10);
|
|
13
|
+
} catch {
|
|
14
|
+
// Fallback: try health endpoint or default to 27
|
|
15
|
+
return 27;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { setupClient, cleanupTypesense } from "./setup.js";
|
|
3
|
+
import { getTypesenseVersion } from "./helpers.js";
|
|
4
|
+
import { importResources, detectDrift, buildPlan } from "../plan/index.js";
|
|
5
|
+
import { applyPlan } from "../apply/index.js";
|
|
6
|
+
import { loadState, saveState } from "../state/index.js";
|
|
7
|
+
import { createCollection, deleteCollection } from "../resources/collection.js";
|
|
8
|
+
import { upsertAlias, deleteAlias } from "../resources/alias.js";
|
|
9
|
+
import { upsertStopwordSet, deleteStopwordSet } from "../resources/stopword.js";
|
|
10
|
+
import { upsertPreset } from "../resources/preset.js";
|
|
11
|
+
import { createApiKey } from "../resources/apikey.js";
|
|
12
|
+
import type { TypesenseConfig, State } from "../types/index.js";
|
|
13
|
+
|
|
14
|
+
describe("import", () => {
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
setupClient();
|
|
17
|
+
await cleanupTypesense();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await cleanupTypesense();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("imports collections and aliases", async () => {
|
|
25
|
+
await createCollection({
|
|
26
|
+
name: "products",
|
|
27
|
+
fields: [{ name: "title", type: "string" }],
|
|
28
|
+
});
|
|
29
|
+
await createCollection({
|
|
30
|
+
name: "users",
|
|
31
|
+
fields: [{ name: "name", type: "string" }],
|
|
32
|
+
});
|
|
33
|
+
await upsertAlias({ name: "products_live", collection: "products" });
|
|
34
|
+
|
|
35
|
+
const result = await importResources();
|
|
36
|
+
|
|
37
|
+
expect(result.collections.length).toBeGreaterThanOrEqual(2);
|
|
38
|
+
const collectionNames = result.collections.map((c) => c.name);
|
|
39
|
+
expect(collectionNames).toContain("products");
|
|
40
|
+
expect(collectionNames).toContain("users");
|
|
41
|
+
|
|
42
|
+
expect(result.aliases).toHaveLength(1);
|
|
43
|
+
expect(result.aliases[0]!.name).toBe("products_live");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("imports stopwords", async () => {
|
|
47
|
+
await upsertStopwordSet({ id: "english", stopwords: ["the", "a", "an"] });
|
|
48
|
+
|
|
49
|
+
const result = await importResources();
|
|
50
|
+
|
|
51
|
+
expect(result.stopwords.length).toBeGreaterThanOrEqual(1);
|
|
52
|
+
const ids = result.stopwords.map((s) => s.id);
|
|
53
|
+
expect(ids).toContain("english");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("imports presets", async () => {
|
|
57
|
+
await upsertPreset({ name: "listing", value: { q: "*" } });
|
|
58
|
+
|
|
59
|
+
const result = await importResources();
|
|
60
|
+
|
|
61
|
+
expect(result.presets.length).toBeGreaterThanOrEqual(1);
|
|
62
|
+
const names = result.presets.map((p) => p.name);
|
|
63
|
+
expect(names).toContain("listing");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("imports API keys", async () => {
|
|
67
|
+
await createApiKey({
|
|
68
|
+
description: "Test search key",
|
|
69
|
+
actions: ["documents:search"],
|
|
70
|
+
collections: ["*"],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const result = await importResources();
|
|
74
|
+
|
|
75
|
+
const descriptions = result.apiKeys.map((k) => k.description);
|
|
76
|
+
expect(descriptions).toContain("Test search key");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("imports empty state when no resources exist", async () => {
|
|
80
|
+
const result = await importResources();
|
|
81
|
+
expect(result.collections).toHaveLength(0);
|
|
82
|
+
expect(result.aliases).toHaveLength(0);
|
|
83
|
+
expect(result.stopwords).toHaveLength(0);
|
|
84
|
+
expect(result.presets).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("drift detection", () => {
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
setupClient();
|
|
91
|
+
await cleanupTypesense();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
await cleanupTypesense();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("reports no drift when state matches", async () => {
|
|
99
|
+
const config: TypesenseConfig = {
|
|
100
|
+
collections: [
|
|
101
|
+
{ name: "products", fields: [{ name: "title", type: "string" }] },
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
const plan = await buildPlan(config);
|
|
105
|
+
await applyPlan(plan, config);
|
|
106
|
+
|
|
107
|
+
const report = await detectDrift();
|
|
108
|
+
// May have no drift or just unmanaged resources
|
|
109
|
+
const managedDrift = report.items.filter(
|
|
110
|
+
(i) => i.type === "modified" || i.type === "deleted"
|
|
111
|
+
);
|
|
112
|
+
expect(managedDrift).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("detects deleted collection", async () => {
|
|
116
|
+
// Apply config
|
|
117
|
+
const config: TypesenseConfig = {
|
|
118
|
+
collections: [
|
|
119
|
+
{ name: "products", fields: [{ name: "title", type: "string" }] },
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
const plan = await buildPlan(config);
|
|
123
|
+
await applyPlan(plan, config);
|
|
124
|
+
|
|
125
|
+
// Delete outside tsctl
|
|
126
|
+
await deleteCollection("products");
|
|
127
|
+
|
|
128
|
+
const report = await detectDrift();
|
|
129
|
+
const deleted = report.items.filter((i) => i.type === "deleted");
|
|
130
|
+
expect(deleted.length).toBeGreaterThanOrEqual(1);
|
|
131
|
+
const deletedNames = deleted.map((i) => i.identifier.name);
|
|
132
|
+
expect(deletedNames).toContain("products");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("detects unmanaged collection", async () => {
|
|
136
|
+
// Create collection outside tsctl (empty state)
|
|
137
|
+
await createCollection({
|
|
138
|
+
name: "unmanaged",
|
|
139
|
+
fields: [{ name: "title", type: "string" }],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const report = await detectDrift();
|
|
143
|
+
const unmanaged = report.items.filter((i) => i.type === "unmanaged");
|
|
144
|
+
const unmanagedNames = unmanaged.map((i) => i.identifier.name);
|
|
145
|
+
expect(unmanagedNames).toContain("unmanaged");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("detects unmanaged alias", async () => {
|
|
149
|
+
await createCollection({
|
|
150
|
+
name: "products",
|
|
151
|
+
fields: [{ name: "title", type: "string" }],
|
|
152
|
+
});
|
|
153
|
+
await upsertAlias({ name: "unmanaged_alias", collection: "products" });
|
|
154
|
+
|
|
155
|
+
const report = await detectDrift();
|
|
156
|
+
const unmanaged = report.items.filter(
|
|
157
|
+
(i) => i.type === "unmanaged" && i.identifier.type === "alias"
|
|
158
|
+
);
|
|
159
|
+
expect(unmanaged.length).toBeGreaterThanOrEqual(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("detects unmanaged stopword set", async () => {
|
|
163
|
+
await upsertStopwordSet({ id: "unmanaged-sw", stopwords: ["the"] });
|
|
164
|
+
|
|
165
|
+
const report = await detectDrift();
|
|
166
|
+
const unmanaged = report.items.filter(
|
|
167
|
+
(i) => i.type === "unmanaged" && i.identifier.type === "stopword"
|
|
168
|
+
);
|
|
169
|
+
const names = unmanaged.map((i) => i.identifier.name);
|
|
170
|
+
expect(names).toContain("unmanaged-sw");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("detects unmanaged preset", async () => {
|
|
174
|
+
await upsertPreset({ name: "unmanaged-preset", value: { q: "*" } });
|
|
175
|
+
|
|
176
|
+
const report = await detectDrift();
|
|
177
|
+
const unmanaged = report.items.filter(
|
|
178
|
+
(i) => i.type === "unmanaged" && i.identifier.type === "preset"
|
|
179
|
+
);
|
|
180
|
+
const names = unmanaged.map((i) => i.identifier.name);
|
|
181
|
+
expect(names).toContain("unmanaged-preset");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("detects deleted stopword set", async () => {
|
|
185
|
+
// Apply config with stopword
|
|
186
|
+
const config: TypesenseConfig = {
|
|
187
|
+
stopwords: [{ id: "english", stopwords: ["the", "a"] }],
|
|
188
|
+
};
|
|
189
|
+
const plan = await buildPlan(config);
|
|
190
|
+
await applyPlan(plan, config);
|
|
191
|
+
|
|
192
|
+
// Delete outside tsctl
|
|
193
|
+
await deleteStopwordSet("english");
|
|
194
|
+
|
|
195
|
+
const report = await detectDrift();
|
|
196
|
+
const deleted = report.items.filter(
|
|
197
|
+
(i) => i.type === "deleted" && i.identifier.type === "stopword"
|
|
198
|
+
);
|
|
199
|
+
expect(deleted.length).toBeGreaterThanOrEqual(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("hasDrift is false when no drift exists", async () => {
|
|
203
|
+
// Empty state, no resources
|
|
204
|
+
const report = await detectDrift();
|
|
205
|
+
expect(report.hasDrift).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("hasDrift is true when drift exists", async () => {
|
|
209
|
+
await createCollection({
|
|
210
|
+
name: "unmanaged",
|
|
211
|
+
fields: [{ name: "x", type: "string" }],
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const report = await detectDrift();
|
|
215
|
+
expect(report.hasDrift).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("summary counts are correct", async () => {
|
|
219
|
+
// Create unmanaged resources
|
|
220
|
+
await createCollection({
|
|
221
|
+
name: "unmanaged1",
|
|
222
|
+
fields: [{ name: "x", type: "string" }],
|
|
223
|
+
});
|
|
224
|
+
await upsertStopwordSet({ id: "unmanaged-sw", stopwords: ["the"] });
|
|
225
|
+
|
|
226
|
+
const report = await detectDrift();
|
|
227
|
+
expect(report.summary.unmanaged).toBeGreaterThanOrEqual(2);
|
|
228
|
+
expect(report.summary.deleted).toBe(0);
|
|
229
|
+
expect(report.summary.modified).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
});
|