diff --git a/doc/examples/server/synonymSets.ts b/doc/examples/server/synonymSets.ts index 78bae238..892159d2 100644 --- a/doc/examples/server/synonymSets.ts +++ b/doc/examples/server/synonymSets.ts @@ -17,7 +17,7 @@ async function synonymSetsExample(): Promise { // Create a synonym set const synonymSet = await client.synonymSets().upsert({ name: "foobar", - synonyms: [ + items: [ { id: "dummy", synonyms: ["foo", "bar", "baz"], diff --git a/src/Typesense/AnalyticsEvent.ts b/src/Typesense/AnalyticsEvent.ts index 32cb2bd9..6e7a6ecf 100644 --- a/src/Typesense/AnalyticsEvent.ts +++ b/src/Typesense/AnalyticsEvent.ts @@ -1,5 +1,5 @@ export interface AnalyticsEventCreateSchema { - type: string; + type?: string; name: string; data: Record; } diff --git a/src/Typesense/AnalyticsEvents.ts b/src/Typesense/AnalyticsEvents.ts index 0c251975..196318bc 100644 --- a/src/Typesense/AnalyticsEvents.ts +++ b/src/Typesense/AnalyticsEvents.ts @@ -3,6 +3,19 @@ import { AnalyticsEventCreateSchema } from "./AnalyticsEvent"; const RESOURCEPATH = "/analytics/events"; +export interface AnalyticsEventsRetrieveSchema { + events: { + name: string; + event_type: string; + collection: string; + timestamp: number; + user_id: string; + doc_id?: string; + doc_ids?: string[]; + query?: string; + }[]; +} + export default class AnalyticsEvents { constructor(private readonly apiCall: ApiCall) { this.apiCall = apiCall; @@ -17,6 +30,17 @@ export default class AnalyticsEvents { ); } + async retrieve(params: { + user_id: string; + name: string; + n: number; + }): Promise { + return this.apiCall.get( + this.endpointPath(), + params, + ); + } + private endpointPath(operation?: string): string { return `${AnalyticsEvents.RESOURCEPATH}${ operation === undefined ? "" : "/" + encodeURIComponent(operation) diff --git a/src/Typesense/AnalyticsRule.ts b/src/Typesense/AnalyticsRule.ts index 7b68dcdb..fe8b5028 100644 --- a/src/Typesense/AnalyticsRule.ts +++ b/src/Typesense/AnalyticsRule.ts @@ -2,23 +2,36 @@ import ApiCall from "./ApiCall"; import AnalyticsRules from "./AnalyticsRules"; export interface AnalyticsRuleCreateSchema { - type: "popular_queries" | "nohits_queries" | "counter" | "log"; - params: { - enable_auto_aggregation?: boolean; - source: { - collections: string[]; - events?: Array<{ - type: string; - weight?: number; - name: string; - }>; - }; + name: string; + type: string; + collection: string; + event_type: string; + rule_tag?: string; + params?: { + destination_collection?: string; + limit?: number; + capture_search_requests?: boolean; + meta_fields?: string[]; expand_query?: boolean; - destination?: { - collection: string; - counter_field?: string; - }; + counter_field?: string; + weight?: number; + }; +} + +export interface AnalyticsRuleUpsertSchema { + name?: string; + type?: string; + collection?: string; + event_type?: string; + rule_tag?: string; + params?: { + destination_collection?: string; limit?: number; + capture_search_requests?: boolean; + meta_fields?: string[]; + expand_query?: boolean; + counter_field?: string; + weight?: number; }; } diff --git a/src/Typesense/AnalyticsRuleV1.ts b/src/Typesense/AnalyticsRuleV1.ts new file mode 100644 index 00000000..d404637e --- /dev/null +++ b/src/Typesense/AnalyticsRuleV1.ts @@ -0,0 +1,52 @@ +import ApiCall from "./ApiCall"; +import AnalyticsRulesV1 from "./AnalyticsRulesV1"; + +export interface AnalyticsRuleCreateSchemaV1 { + type: "popular_queries" | "nohits_queries" | "counter" | "log"; + params: { + enable_auto_aggregation?: boolean; + source: { + collections: string[]; + events?: { + type: string; + weight?: number; + name: string; + }[]; + }; + expand_query?: boolean; + destination?: { + collection: string; + counter_field?: string; + }; + limit?: number; + }; +} + +export interface AnalyticsRuleDeleteSchemaV1 { + name: string; +} + +export interface AnalyticsRuleSchemaV1 extends AnalyticsRuleCreateSchemaV1 { + name: string; +} + +export default class AnalyticsRuleV1 { + constructor( + private name: string, + private apiCall: ApiCall, + ) {} + + async retrieve(): Promise { + return this.apiCall.get(this.endpointPath()); + } + + async delete(): Promise { + return this.apiCall.delete(this.endpointPath()); + } + + private endpointPath(): string { + return `${AnalyticsRulesV1.RESOURCEPATH}/${encodeURIComponent(this.name)}`; + } +} + + diff --git a/src/Typesense/AnalyticsRules.ts b/src/Typesense/AnalyticsRules.ts index f154ebf7..f3a6c85a 100644 --- a/src/Typesense/AnalyticsRules.ts +++ b/src/Typesense/AnalyticsRules.ts @@ -2,6 +2,7 @@ import ApiCall from "./ApiCall"; import { AnalyticsRuleCreateSchema, AnalyticsRuleSchema, + AnalyticsRuleUpsertSchema, } from "./AnalyticsRule"; export interface AnalyticsRulesRetrieveSchema { @@ -15,9 +16,30 @@ export default class AnalyticsRules { this.apiCall = apiCall; } + async create( + params: + | AnalyticsRuleCreateSchema + | AnalyticsRuleCreateSchema[], + ): Promise< + | AnalyticsRuleSchema + | ( + | AnalyticsRuleSchema + | { + error?: string; + } + )[] + > { + return this.apiCall.post( + this.endpointPath(), + params, + {}, + {}, + ); + } + async upsert( name: string, - params: AnalyticsRuleCreateSchema + params: AnalyticsRuleUpsertSchema ): Promise { return this.apiCall.put( this.endpointPath(name), @@ -25,8 +47,15 @@ export default class AnalyticsRules { ); } - async retrieve(): Promise { - return this.apiCall.get(this.endpointPath()); + async retrieve(ruleTag?: string): Promise { + const query: Record = {}; + if (ruleTag) { + query["rule_tag"] = ruleTag; + } + return this.apiCall.get( + this.endpointPath(), + query, + ); } private endpointPath(operation?: string): string { diff --git a/src/Typesense/AnalyticsRulesV1.ts b/src/Typesense/AnalyticsRulesV1.ts new file mode 100644 index 00000000..dfa5da7b --- /dev/null +++ b/src/Typesense/AnalyticsRulesV1.ts @@ -0,0 +1,43 @@ +import ApiCall from "./ApiCall"; +import { + AnalyticsRuleCreateSchemaV1, + AnalyticsRuleSchemaV1, +} from "./AnalyticsRuleV1"; + +export interface AnalyticsRulesRetrieveSchemaV1 { + rules: AnalyticsRuleSchemaV1[]; +} + +const RESOURCEPATH = "/analytics/rules"; + +export default class AnalyticsRulesV1 { + constructor(private readonly apiCall: ApiCall) { + this.apiCall = apiCall; + } + + async upsert( + name: string, + params: AnalyticsRuleCreateSchemaV1 + ): Promise { + return this.apiCall.put( + this.endpointPath(name), + params + ); + } + + async retrieve(): Promise { + return this.apiCall.get(this.endpointPath()); + } + + private endpointPath(operation?: string): string { + return `${AnalyticsRulesV1.RESOURCEPATH}${ + operation === undefined ? "" : "/" + encodeURIComponent(operation) + }`; + } + + static get RESOURCEPATH() { + return RESOURCEPATH; + } +} + + diff --git a/src/Typesense/AnalyticsV1.ts b/src/Typesense/AnalyticsV1.ts new file mode 100644 index 00000000..74398c66 --- /dev/null +++ b/src/Typesense/AnalyticsV1.ts @@ -0,0 +1,52 @@ +import ApiCall from "./ApiCall"; +import AnalyticsRulesV1 from "./AnalyticsRulesV1"; +import AnalyticsRuleV1 from "./AnalyticsRuleV1"; +import AnalyticsEvents from "./AnalyticsEvents"; + +const RESOURCEPATH = "/analytics"; + +/** + * @deprecated Deprecated starting with Typesense Server v30. Please migrate to `client.analytics` (new Analytics APIs). + */ +export default class AnalyticsV1 { + private static hasWarnedDeprecation = false; + private readonly _analyticsRules: AnalyticsRulesV1; + private readonly individualAnalyticsRules: Record = {}; + private readonly _analyticsEvents: AnalyticsEvents; + + constructor(private readonly apiCall: ApiCall) { + this.apiCall = apiCall; + this._analyticsRules = new AnalyticsRulesV1(this.apiCall); + this._analyticsEvents = new AnalyticsEvents(this.apiCall); + } + + rules(): AnalyticsRulesV1; + rules(id: string): AnalyticsRuleV1; + rules(id?: string): AnalyticsRulesV1 | AnalyticsRuleV1 { + if (!AnalyticsV1.hasWarnedDeprecation) { + // eslint-disable-next-line no-console + console.warn( + "[typesense] 'analyticsV1' is deprecated starting with Typesense Server v30 and will be removed in a future release. Please use 'analytics' instead.", + ); + AnalyticsV1.hasWarnedDeprecation = true; + } + if (id === undefined) { + return this._analyticsRules; + } else { + if (this.individualAnalyticsRules[id] === undefined) { + this.individualAnalyticsRules[id] = new AnalyticsRuleV1(id, this.apiCall); + } + return this.individualAnalyticsRules[id]; + } + } + + events(): AnalyticsEvents { + return this._analyticsEvents; + } + + static get RESOURCEPATH() { + return RESOURCEPATH; + } +} + + diff --git a/src/Typesense/Client.ts b/src/Typesense/Client.ts index ff0b0e36..0c2e741e 100644 --- a/src/Typesense/Client.ts +++ b/src/Typesense/Client.ts @@ -16,6 +16,7 @@ import Operations from "./Operations"; import MultiSearch from "./MultiSearch"; import Presets from "./Presets"; import Preset from "./Preset"; +import AnalyticsV1 from "./AnalyticsV1"; import Analytics from "./Analytics"; import Stopwords from "./Stopwords"; import Stopword from "./Stopword"; @@ -37,6 +38,7 @@ export default class Client { operations: Operations; multiSearch: MultiSearch; analytics: Analytics; + analyticsV1: AnalyticsV1; stemming: Stemming; private readonly _collections: Collections; private readonly individualCollections: Record; @@ -77,6 +79,7 @@ export default class Client { this._stopwords = new Stopwords(this.apiCall); this.individualStopwords = {}; this.analytics = new Analytics(this.apiCall); + this.analyticsV1 = new AnalyticsV1(this.apiCall); this.stemming = new Stemming(this.apiCall); this._conversations = new Conversations(this.apiCall); this.individualConversations = {}; diff --git a/src/Typesense/Synonym.ts b/src/Typesense/Synonym.ts index e6458df0..b06e0f69 100644 --- a/src/Typesense/Synonym.ts +++ b/src/Typesense/Synonym.ts @@ -10,7 +10,11 @@ export interface SynonymDeleteSchema { id: string; } +/** + * @deprecated Deprecated starting with Typesense Server v30. Please migrate to `client.synonymSets` (new Synonym Sets APIs). + */ export default class Synonym { + private static hasWarnedDeprecation = false; constructor( private collectionName: string, private synonymId: string, @@ -26,6 +30,13 @@ export default class Synonym { } private endpointPath(): string { + if (!Synonym.hasWarnedDeprecation) { + // eslint-disable-next-line no-console + console.warn( + "[typesense] 'synonym' APIs are deprecated starting with Typesense Server v30. Please migrate to synonym sets 'synonym_sets'.", + ); + Synonym.hasWarnedDeprecation = true; + } return `${Collections.RESOURCEPATH}/${encodeURIComponent(this.collectionName)}${Synonyms.RESOURCEPATH}/${encodeURIComponent(this.synonymId)}`; } } diff --git a/src/Typesense/SynonymSet.ts b/src/Typesense/SynonymSet.ts index a0c6b583..2d8467fc 100644 --- a/src/Typesense/SynonymSet.ts +++ b/src/Typesense/SynonymSet.ts @@ -1,6 +1,8 @@ import ApiCall from "./ApiCall"; import SynonymSets from "./SynonymSets"; import type { SynonymSetCreateSchema, SynonymSetSchema } from "./SynonymSets"; +import SynonymSetItems from "./SynonymSetItems"; +import SynonymSetItem from "./SynonymSetItem"; export interface SynonymSetDeleteSchema { name: string; @@ -9,10 +11,14 @@ export interface SynonymSetDeleteSchema { export type SynonymSetRetrieveSchema = SynonymSetCreateSchema; export default class SynonymSet { + private readonly _items: SynonymSetItems; + private individualItems: Record = {}; constructor( private synonymSetName: string, private apiCall: ApiCall, - ) {} + ) { + this._items = new SynonymSetItems(this.synonymSetName, apiCall); + } async upsert( params: SynonymSetCreateSchema, @@ -28,6 +34,23 @@ export default class SynonymSet { return this.apiCall.delete(this.endpointPath()); } + items(): SynonymSetItems; + items(itemId: string): SynonymSetItem; + items(itemId?: string): SynonymSetItems | SynonymSetItem { + if (itemId === undefined) { + return this._items; + } else { + if (this.individualItems[itemId] === undefined) { + this.individualItems[itemId] = new SynonymSetItem( + this.synonymSetName, + itemId, + this.apiCall, + ); + } + return this.individualItems[itemId]; + } + } + private endpointPath(): string { return `${SynonymSets.RESOURCEPATH}/${encodeURIComponent(this.synonymSetName)}`; } diff --git a/src/Typesense/SynonymSetItem.ts b/src/Typesense/SynonymSetItem.ts new file mode 100644 index 00000000..6af22446 --- /dev/null +++ b/src/Typesense/SynonymSetItem.ts @@ -0,0 +1,28 @@ +import ApiCall from "./ApiCall"; +import SynonymSets, { SynonymItemSchema } from "./SynonymSets"; + +export interface SynonymSetItemDeleteSchema { + id: string; +} + +export default class SynonymSetItem { + constructor( + private synonymSetName: string, + private itemId: string, + private apiCall: ApiCall, + ) {} + + async retrieve(): Promise { + return this.apiCall.get(this.endpointPath()); + } + + async delete(): Promise { + return this.apiCall.delete(this.endpointPath()); + } + + private endpointPath(): string { + return `${SynonymSets.RESOURCEPATH}/${encodeURIComponent(this.synonymSetName)}/items/${encodeURIComponent(this.itemId)}`; + } +} + + diff --git a/src/Typesense/SynonymSetItems.ts b/src/Typesense/SynonymSetItems.ts new file mode 100644 index 00000000..afd3ae82 --- /dev/null +++ b/src/Typesense/SynonymSetItems.ts @@ -0,0 +1,35 @@ +import ApiCall from "./ApiCall"; +import SynonymSets, { SynonymItemSchema } from "./SynonymSets"; + +export interface SynonymSetItemDeleteSchema { + id: string; +} + +export default class SynonymSetItems { + constructor( + private synonymSetName: string, + private apiCall: ApiCall, + ) {} + + async upsert( + itemId: string, + params: Omit, + ): Promise { + return this.apiCall.put( + this.endpointPath(itemId), + params, + ); + } + + async retrieve(): Promise { + return this.apiCall.get(this.endpointPath()); + } + + private endpointPath(operation?: string): string { + return `${SynonymSets.RESOURCEPATH}/${encodeURIComponent(this.synonymSetName)}/items${ + operation === undefined ? "" : "/" + encodeURIComponent(operation) + }`; + } +} + + diff --git a/src/Typesense/SynonymSets.ts b/src/Typesense/SynonymSets.ts index c4db9a42..545d3c88 100644 --- a/src/Typesense/SynonymSets.ts +++ b/src/Typesense/SynonymSets.ts @@ -9,7 +9,7 @@ export interface SynonymItemSchema { } export interface SynonymSetCreateSchema { - synonyms: SynonymItemSchema[]; + items: SynonymItemSchema[]; } export interface SynonymSetSchema extends SynonymSetCreateSchema { diff --git a/src/Typesense/Synonyms.ts b/src/Typesense/Synonyms.ts index eec60c4f..2cee7efa 100644 --- a/src/Typesense/Synonyms.ts +++ b/src/Typesense/Synonyms.ts @@ -15,7 +15,11 @@ export interface SynonymsRetrieveSchema { synonyms: SynonymSchema[]; } +/** + * @deprecated Deprecated starting with Typesense Server v30. Please migrate to `client.synonymSets` (new Synonym Sets APIs). + */ export default class Synonyms { + private static hasWarnedDeprecation = false; constructor(private collectionName: string, private apiCall: ApiCall) {} async upsert( @@ -33,6 +37,13 @@ export default class Synonyms { } private endpointPath(operation?: string) { + if (!Synonyms.hasWarnedDeprecation) { + // eslint-disable-next-line no-console + console.warn( + "[typesense] 'synonyms' APIs are deprecated starting with Typesense Server v30. Please migrate to synonym sets ('synonym_sets').", + ); + Synonyms.hasWarnedDeprecation = true; + } return `${Collections.RESOURCEPATH}/${encodeURIComponent(this.collectionName)}${ Synonyms.RESOURCEPATH }${operation === undefined ? "" : "/" + encodeURIComponent(operation)}`; diff --git a/test/Typesense/AnalyticsEvents.spec.ts b/test/Typesense/AnalyticsEvents.spec.ts index 7726cc29..08619150 100644 --- a/test/Typesense/AnalyticsEvents.spec.ts +++ b/test/Typesense/AnalyticsEvents.spec.ts @@ -15,7 +15,7 @@ const typesense = new TypesenseClient({ connectionTimeoutSeconds: 180, }); -describe.skipIf(await isV30OrAbove(typesense))("AnalyticsEvents", function () { +describe("AnalyticsEvents", async function () { const analyticsEvents = typesense.analytics.events(); beforeAll(async function () { @@ -46,7 +46,7 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsEvents", function () { await typesense.collections().create(sourceCollection); await typesense.collections().create(counterCollection); - const counterRule = { + const counterRuleV1 = { name: "counter-rule", type: "counter" as const, params: { @@ -67,7 +67,24 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsEvents", function () { }, }; - await typesense.analytics.rules().upsert(counterRule.name, counterRule); + const counterRule = { + name: "event_conversion", + type: "counter" as const, + event_type: "conversion", + collection: "event_source", + params: { + counter_field: "counter", + destination_collection: "event_counter", + weight: 3, + }, + }; + + if (!(await isV30OrAbove(typesense))) { + await typesense.analyticsV1.rules().upsert(counterRuleV1.name, counterRuleV1); + } else { + await typesense.analytics.rules().upsert(counterRule.name, counterRule); + } + }); afterAll(async function () { @@ -88,7 +105,11 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsEvents", function () { } try { - await typesense.analytics.rules("counter-rule").delete(); + if (!(await isV30OrAbove(typesense))) { + await typesense.analyticsV1.rules("counter-rule").delete(); + } else { + await typesense.analytics.rules("event_conversion").delete(); + } } catch (error) { if (!(error instanceof ObjectNotFound)) { console.warn("Failed to cleanup analytics rule:", error); @@ -98,6 +119,10 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsEvents", function () { describe(".create", function () { it("shouldn't create an event for a non-existing name", async function () { + let errorMessage = "Request failed with HTTP code 400 | Server said: Rule not found"; + if (!(await isV30OrAbove(typesense))) { + errorMessage = "Request failed with HTTP code 400 | Server said: No analytics rule defined for event name non-existing-event"; + } await expect( analyticsEvents.create({ name: "non-existing-event", @@ -108,24 +133,10 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsEvents", function () { }, }), ).rejects.toThrow( - "No analytics rule defined for event name non-existing-event", + errorMessage, ); }); - it("shouldn't create an event for a mismatched name-type", async function () { - await expect( - analyticsEvents.create({ - name: "event_conversion", - type: "click", - data: { - doc_id: "123", - user_id: "456", - q: "test", - }, - }), - ).rejects.toThrow("event_type mismatch in analytic rules."); - }); - it("should create an event for a valid name-type", async function () { const result = await analyticsEvents.create({ name: "event_conversion", @@ -141,4 +152,34 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsEvents", function () { expect(typeof result).toBe("object"); }); }); + + describe.skipIf(!(await isV30OrAbove(typesense)))(".retrieve", function () { + it("should retrieve recent events for a user and rule", async function () { + // Ensure at least one event exists + await analyticsEvents.create({ + name: "event_conversion", + type: "conversion", + data: { + doc_id: "999", + user_id: "user-1", + q: "abc", + }, + }); + + const events = await analyticsEvents.retrieve({ + user_id: "user-1", + name: "event_conversion", + n: 10, + }); + + expect(events).toBeDefined(); + expect(Array.isArray(events.events)).toBe(true); + if (events.events.length > 0) { + const evt = events.events[0]; + expect(evt.name).toBeDefined(); + expect(evt.event_type).toBeDefined(); + expect(evt.user_id).toBeDefined(); + } + }); + }); }); diff --git a/test/Typesense/AnalyticsRule.spec.ts b/test/Typesense/AnalyticsRule.spec.ts index e91a0ad3..c598eb1d 100644 --- a/test/Typesense/AnalyticsRule.spec.ts +++ b/test/Typesense/AnalyticsRule.spec.ts @@ -4,7 +4,7 @@ import { ObjectNotFound, ObjectAlreadyExists, } from "../../src/Typesense/Errors"; -import { AnalyticsRuleCreateSchema } from "../../src/Typesense/AnalyticsRule"; +import { AnalyticsRuleCreateSchemaV1 } from "../../src/Typesense/AnalyticsRuleV1"; import { isV30OrAbove } from "../utils"; const typesense = new TypesenseClient({ @@ -19,7 +19,7 @@ const typesense = new TypesenseClient({ connectionTimeoutSeconds: 180, }); -describe.skipIf(await isV30OrAbove(typesense))("AnalyticsRule", function () { +describe.skipIf(await isV30OrAbove(typesense))("AnalyticsRuleV1", function () { const testRuleName = "test_analytics_rule"; const testRuleData = { type: "popular_queries", @@ -31,7 +31,7 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsRule", function () { collection: "top_queries", }, }, - } as const satisfies AnalyticsRuleCreateSchema; + } as const satisfies AnalyticsRuleCreateSchemaV1; let createdRuleNames: string[] = []; @@ -60,7 +60,7 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsRule", function () { afterEach(async function () { for (const ruleName of createdRuleNames) { try { - await typesense.analytics.rules(ruleName).delete(); + await typesense.analyticsV1.rules(ruleName).delete(); } catch (error) { if (!(error instanceof ObjectNotFound)) { console.warn("Failed to cleanup test analytics rule:", error); @@ -80,10 +80,10 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsRule", function () { describe(".retrieve", function () { it("retrieves the rule", async function () { - await typesense.analytics.rules().upsert(testRuleName, testRuleData); + await typesense.analyticsV1.rules().upsert(testRuleName, testRuleData); createdRuleNames.push(testRuleName); - const analyticsRule = typesense.analytics.rules(testRuleName); + const analyticsRule = typesense.analyticsV1.rules(testRuleName); const ruleData = await analyticsRule.retrieve(); expect(ruleData).toBeDefined(); @@ -99,10 +99,10 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsRule", function () { describe(".delete", function () { it("deletes a rule", async function () { - await typesense.analytics.rules().upsert(testRuleName, testRuleData); + await typesense.analyticsV1.rules().upsert(testRuleName, testRuleData); createdRuleNames.push(testRuleName); - const analyticsRule = typesense.analytics.rules(testRuleName); + const analyticsRule = typesense.analyticsV1.rules(testRuleName); const deleteResponse = await analyticsRule.delete(); expect(deleteResponse).toBeDefined(); diff --git a/test/Typesense/AnalyticsRules.spec.ts b/test/Typesense/AnalyticsRules.spec.ts index b1878f05..d45b1956 100644 --- a/test/Typesense/AnalyticsRules.spec.ts +++ b/test/Typesense/AnalyticsRules.spec.ts @@ -5,6 +5,7 @@ import { ObjectAlreadyExists, } from "../../src/Typesense/Errors"; import { AnalyticsRuleCreateSchema } from "../../src/Typesense/AnalyticsRule"; +import { AnalyticsRuleCreateSchemaV1 } from "../../src/Typesense/AnalyticsRuleV1"; import { isV30OrAbove } from "../utils"; const typesense = new TypesenseClient({ @@ -19,8 +20,8 @@ const typesense = new TypesenseClient({ connectionTimeoutSeconds: 180, }); -describe.skipIf(await isV30OrAbove(typesense))("AnalyticsRules", function () { - const analyticsRules = typesense.analytics.rules(); +describe.skipIf(await isV30OrAbove(typesense))("AnalyticsRulesV1", function () { + const analyticsRules = typesense.analyticsV1.rules(); const testRuleName = "search_suggestions"; const testRuleData = { type: "popular_queries", @@ -30,7 +31,7 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsRules", function () { expand_query: true, limit: 100, }, - } as const satisfies AnalyticsRuleCreateSchema; + } as const satisfies AnalyticsRuleCreateSchemaV1; const createdRuleNames: string[] = []; @@ -79,7 +80,7 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsRules", function () { afterEach(async function () { for (const ruleName of createdRuleNames) { try { - await typesense.analytics.rules(ruleName).delete(); + await typesense.analyticsV1.rules(ruleName).delete(); } catch (error) { if (!(error instanceof ObjectNotFound)) { console.warn("Failed to cleanup test analytics rule:", error); @@ -148,3 +149,110 @@ describe.skipIf(await isV30OrAbove(typesense))("AnalyticsRules", function () { }); }); }); + +describe.skipIf(!(await isV30OrAbove(typesense)))("AnalyticsRules", function () { + const analyticsRules = typesense.analytics.rules(); + const testRuleName = "search_suggestions"; + const testRuleData: AnalyticsRuleCreateSchema = { + name: testRuleName, + type: "popular_queries", + collection: "products", + event_type: "query", + params: { + destination_collection: "products_top_queries", + expand_query: true, + limit: 100, + }, + }; + + const createdRuleNames: string[] = []; + + beforeEach(async function () { + try { + await typesense.collections().create({ + name: "products", + fields: [ + { name: "name", type: "string" as const }, + { name: "category", type: "string" as const }, + ], + }); + } catch (error) { + if (!(error instanceof ObjectAlreadyExists)) { + throw error; + } + } + + try { + await typesense.collections().create({ + name: "products_top_queries", + fields: [ + { name: "q", type: "string" as const }, + { name: "count", type: "int32" as const }, + ], + }); + } catch (error) { + if (!(error instanceof ObjectAlreadyExists)) { + throw error; + } + } + }); + + afterEach(async function () { + for (const ruleName of createdRuleNames) { + try { + await typesense.analytics.rules(ruleName).delete(); + } catch (error) { + if (!(error instanceof ObjectNotFound)) { + console.warn("Failed to cleanup test analytics rule:", error); + } + } + } + createdRuleNames.length = 0; + + try { + await typesense.collections("products").delete(); + } catch (error) { + if (!(error instanceof ObjectNotFound)) { + console.warn("Failed to cleanup test collection:", error); + } + } + + try { + await typesense.collections("products_top_queries").delete(); + } catch (error) { + if (!(error instanceof ObjectNotFound)) { + console.warn("Failed to cleanup test collection:", error); + } + } + }); + + describe(".create", function () { + it("creates an analytics rule with POST", async function () { + const created = await analyticsRules.create(testRuleData); + // The server returns the created rule + const createdRule = Array.isArray(created) ? created[0] : created; + expect(createdRule).toBeDefined(); + createdRuleNames.push(testRuleName); + }); + }); + + describe(".upsert", function () { + it("upserts an analytics rule with PUT", async function () { + const created = await analyticsRules.create(testRuleData); + const createdRule = Array.isArray(created) ? created[0] : created; + expect(createdRule).toBeDefined(); + createdRuleNames.push(testRuleName); + const updated = await analyticsRules.upsert(testRuleName, { + params: { limit: 50 }, + }); + expect(updated).toBeDefined(); + }); + }); + + describe(".retrieve", function () { + it("retrieves analytics rules (optionally filtered)", async function () { + const rulesData = await analyticsRules.retrieve(); + expect(rulesData).toBeDefined(); + }); + }); +}); diff --git a/test/Typesense/SynonymSet.spec.ts b/test/Typesense/SynonymSet.spec.ts index a27a01fd..d08c0fc1 100644 --- a/test/Typesense/SynonymSet.spec.ts +++ b/test/Typesense/SynonymSet.spec.ts @@ -18,9 +18,9 @@ const typesense = new TypesenseClient({ describe.skipIf(!(await isV30OrAbove(typesense)))("SynonymSet", function () { const testSynonymSetName = "test-synonym-set"; const synonymSetData = { - synonyms: [ + items: [ { - id: "dummy", + id: "test-synonym-set-0", synonyms: ["foo", "bar", "baz"], }, ], @@ -51,8 +51,28 @@ describe.skipIf(!(await isV30OrAbove(typesense)))("SynonymSet", function () { .upsert(synonymSetData); expect(createResult).toBeDefined(); - expect(createResult.synonyms[0].id).toBe("dummy"); - expect(createResult.synonyms).toMatchObject(synonymSetData.synonyms); + expect(createResult.items[0].id).toBe("test-synonym-set-0"); + expect(createResult.items).toMatchObject(synonymSetData.items); + }); + }); + + describe(".items", function () { + it("upserts and retrieves individual items", async function () { + await typesense.synonymSets(testSynonymSetName).upsert(synonymSetData); + + const item = await typesense + .synonymSets(testSynonymSetName) + .items() + .upsert("custom-item", { synonyms: ["alpha", "beta"] }); + + expect(item).toBeDefined(); + expect(item.id).toBe("custom-item"); + + const retrieved = await typesense + .synonymSets(testSynonymSetName) + .items("custom-item") + .retrieve(); + expect(retrieved.synonyms).toEqual(["alpha", "beta"]); }); }); @@ -65,9 +85,9 @@ describe.skipIf(!(await isV30OrAbove(typesense)))("SynonymSet", function () { .retrieve(); expect(retrievedSynonymSet).toBeDefined(); - expect(retrievedSynonymSet.synonyms[0].id).toBe("dummy"); - expect(retrievedSynonymSet.synonyms).toMatchObject( - synonymSetData.synonyms, + expect(retrievedSynonymSet.items[0].id).toBe("test-synonym-set-0"); + expect(retrievedSynonymSet.items).toMatchObject( + synonymSetData.items, ); }); }); diff --git a/test/Typesense/SynonymSetItem.spec.ts b/test/Typesense/SynonymSetItem.spec.ts new file mode 100644 index 00000000..7e66099d --- /dev/null +++ b/test/Typesense/SynonymSetItem.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { Client as TypesenseClient } from "../../src/Typesense"; +import { ObjectNotFound } from "../../src/Typesense/Errors"; +import { isV30OrAbove } from "../utils"; + +const typesense = new TypesenseClient({ + nodes: [ + { + host: "localhost", + port: 8108, + protocol: "http", + }, + ], + apiKey: "xyz", + connectionTimeoutSeconds: 180, +}); + +describe.skipIf(!(await isV30OrAbove(typesense)))("SynonymSetItem", function () { + const testSynonymSetName = "test-synonym-set-item"; + + beforeEach(async function () { + try { + await typesense.synonymSets(testSynonymSetName).delete(); + } catch (error) { + // Ignore if synonym set doesn't exist + } + await typesense.synonymSets(testSynonymSetName).upsert({ + items: [ + { + id: "custom-item", + synonyms: ["alpha", "a"], + }, + ], + }); + }); + + afterEach(async function () { + try { + await typesense.synonymSets(testSynonymSetName).delete(); + } catch (error) { + if (!(error instanceof ObjectNotFound)) { + console.warn("Failed to cleanup test synonym set:", error); + } + } + }); + + it("retrieves a specific item", async function () { + await typesense + .synonymSets(testSynonymSetName) + .items() + .upsert("custom-item", { synonyms: ["beta", "b"] }); + + const retrieved = await typesense + .synonymSets(testSynonymSetName) + .items("custom-item") + .retrieve(); + + expect(retrieved.id).toBe("custom-item"); + expect(retrieved.synonyms).toEqual(["beta", "b"]); + }); + + it("deletes a specific item", async function () { + await typesense + .synonymSets(testSynonymSetName) + .items() + .upsert("custom-item", { synonyms: ["gamma", "g"] }); + + const deleteResult = await typesense + .synonymSets(testSynonymSetName) + .items("custom-item") + .delete(); + + expect(deleteResult.id).toBe("custom-item"); + + await expect( + typesense.synonymSets(testSynonymSetName).items("custom-item").retrieve(), + ).rejects.toThrow(ObjectNotFound); + }); +}); + + diff --git a/test/Typesense/SynonymSetItems.spec.ts b/test/Typesense/SynonymSetItems.spec.ts new file mode 100644 index 00000000..742996a0 --- /dev/null +++ b/test/Typesense/SynonymSetItems.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { Client as TypesenseClient } from "../../src/Typesense"; +import { ObjectNotFound } from "../../src/Typesense/Errors"; +import { isV30OrAbove } from "../utils"; + +const typesense = new TypesenseClient({ + nodes: [ + { + host: "localhost", + port: 8108, + protocol: "http", + }, + ], + apiKey: "xyz", + connectionTimeoutSeconds: 180, +}); + +describe.skipIf(!(await isV30OrAbove(typesense)))("SynonymSetItems", function () { + const testSynonymSetName = "test-synonym-set-items"; + const synonymSetData = { + items: [ + { + id: "color-item", + synonyms: ["red", "scarlet"], + }, + ], + }; + + beforeEach(async function () { + try { + await typesense.synonymSets(testSynonymSetName).delete(); + } catch (error) { + // Ignore if synonym set doesn't exist + } + }); + + afterEach(async function () { + try { + await typesense.synonymSets(testSynonymSetName).delete(); + } catch (error) { + if (!(error instanceof ObjectNotFound)) { + console.warn("Failed to cleanup test synonym set:", error); + } + } + }); + + describe(".retrieve", function () { + it("lists items in a synonym set", async function () { + await typesense.synonymSets(testSynonymSetName).upsert(synonymSetData); + + const items = await typesense + .synonymSets(testSynonymSetName) + .items() + .retrieve(); + + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThan(0); + expect(items[0].synonyms).toEqual(["red", "scarlet"]); + }); + }); + + describe(".upsert", function () { + it("creates or updates a synonym set item", async function () { + await typesense.synonymSets(testSynonymSetName).upsert(synonymSetData); + + const upserted = await typesense + .synonymSets(testSynonymSetName) + .items() + .upsert("color-item", { synonyms: ["blue", "azure"] }); + + expect(upserted.id).toBe("color-item"); + expect(upserted.synonyms).toEqual(["blue", "azure"]); + }); + }); +}); + + diff --git a/test/Typesense/SynonymSets.spec.ts b/test/Typesense/SynonymSets.spec.ts index bbc21bae..25a5ab00 100644 --- a/test/Typesense/SynonymSets.spec.ts +++ b/test/Typesense/SynonymSets.spec.ts @@ -18,7 +18,7 @@ const typesense = new TypesenseClient({ describe.skipIf(!(await isV30OrAbove(typesense)))("SynonymSets", function () { const testSynonymSetName = "test-synonym-set"; const synonymSetData = { - synonyms: [ + items: [ { id: "dummy", synonyms: ["foo", "bar", "baz"], @@ -59,8 +59,8 @@ describe.skipIf(!(await isV30OrAbove(typesense)))("SynonymSets", function () { ); expect(createdSynonymSet).toBeDefined(); - expect(createdSynonymSet?.synonyms).toMatchObject( - synonymSetData.synonyms, + expect(createdSynonymSet?.items).toMatchObject( + synonymSetData.items, ); }); });