diff --git a/README.md b/README.md index 961f6b3..3afbb2b 100644 --- a/README.md +++ b/README.md @@ -515,11 +515,11 @@ may call — list, aliases / versions, a call by hand, a sample envelope for a local run, and the last runs of a trigger or task. The five function hooks need `@norbix.ai/ts` 4.19.0. -| Area | Hooks | Cache tag | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| Module switch | `useEnableCodeModuleMutation`, `useDisableCodeModuleMutation` | invalidates `Account`, `Projects`, `Code` | +| Area | Hooks | Cache tag | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Module switch | `useEnableCodeModuleMutation`, `useDisableCodeModuleMutation` | invalidates `Account`, `Projects`, `Code` | | Integrations | `useGetCodeIntegrationsQuery`, `useGetCodeIntegrationQuery`, `useSaveCodeIntegrationMutation`, `useDeleteCodeIntegrationMutation`, `useEnableCodeIntegrationMutation`, `useDisableCodeIntegrationMutation`, `useTestCodeIntegrationMutation` | provides / invalidates `CodeIntegrations/LIST` and `CodeIntegrations/`; `test` touches nothing | -| Cloud functions | `useGetCodeFunctionsQuery`, `useGetCodeFunctionQualifiersQuery`, `useGetCodeFunctionEventSampleQuery`, `useGetCodeFunctionRunsQuery`, `useInvokeCodeFunctionMutation` | provides `CodeFunctions/LIST`, `CodeFunctions/SAMPLE`, `CodeFunctions/RUNS`; `invoke` touches nothing | +| Cloud functions | `useGetCodeFunctionsQuery`, `useGetCodeFunctionQualifiersQuery`, `useGetCodeFunctionEventSampleQuery`, `useGetCodeFunctionRunsQuery`, `useInvokeCodeFunctionMutation` | provides `CodeFunctions/LIST`, `CodeFunctions/SAMPLE`, `CodeFunctions/RUNS`; `invoke` touches nothing | ```tsx const integrationId = 'int_6rBACnEaxQ0x1jTiHtAuWV'; @@ -544,6 +544,29 @@ The function side — parsing the envelope your function receives — is `parseFunctionEvent` in the core SDK (`docs/code-functions.md`), not a hook. Full method reference: the core SDK's `docs/hub/code.md`. +## Code — marketplace + +The marketplace methods of `hub.code` have hooks too, 1:1 with +`norbix.hub.code` (gateway contract `Code.Marketplace.md` §14): the catalog of +listings, the installed connections (`int_…`) with their secrets, token +mappings and validation test, the vendor function catalog, and the functions +added on a connection (`func_…`). Every tag is of type `CodeMarketplace`. + +| Area | Hooks | Cache tag (`CodeMarketplace/…`) | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | +| Catalog | `useGetMarketplaceListingsQuery`, `useGetMarketplaceListingQuery`, `useGetMarketplaceListingFunctionTokensQuery` | provides `LISTINGS` | +| Connections | `useGetMarketplaceIntegrationsQuery`, `useGetMarketplaceIntegrationQuery`, `useSaveMarketplaceIntegrationMutation`, `useDeleteMarketplaceIntegrationMutation`, `useEnableMarketplaceIntegrationMutation`, `useDisableMarketplaceIntegrationMutation`, `useReplaceMarketplaceIntegrationSecretsMutation`, `useRevealMarketplaceIntegrationSecretsMutation`, `useSetMarketplaceIntegrationTokenMappingsMutation`, `useTestMarketplaceIntegrationMutation`, `useGetMarketplaceFunctionCatalogQuery` | `INTEGRATIONS`, ``, `CATALOG:`; reveal touches nothing | +| Functions | `useGetMarketplaceFunctionsQuery`, `useGetMarketplaceFunctionQuery`, `useSaveMarketplaceFunctionMutation`, `useDeleteMarketplaceFunctionMutation`, `useEnableMarketplaceFunctionMutation`, `useDisableMarketplaceFunctionMutation`, `useGetMarketplaceFunctionTokensQuery`, `useInvokeMarketplaceFunctionMutation`, `useGetMarketplaceFunctionRunsQuery` | `FUNCTIONS:`, ``, `RUNS`; invoke touches nothing | + +Revealing secrets is a mutation on purpose: a query would keep the clear values +in the store's cache. + +```tsx +const { data: listings } = useGetMarketplaceListingsQuery({ search: 'slack' }); +const [invoke] = useInvokeMarketplaceFunctionMutation(); +await invoke({ functionViewId: 'func_4hT2b9QkLm3XcT1yRfGh7a', payload: { text: 'Order shipped' } }); +``` + ## Working with terms A **taxonomy** is a named tree of **terms** (labels). A term can have one parent (a clean hierarchy) or several parents (the same item under many categories). There is one read hook per scenario — pick the one that matches what you want: diff --git a/package-lock.json b/package-lock.json index 70c5778..3a36897 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", "@eslint/js": "^10.0.1", - "@norbix.ai/ts": "^4.20.0", + "@norbix.ai/ts": "^4.21.0", "@reduxjs/toolkit": "^2.12.0", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.9", @@ -1490,9 +1490,9 @@ } }, "node_modules/@norbix.ai/ts": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-4.20.0.tgz", - "integrity": "sha512-zJF56+EBULemMivFGfqpbnUYqqHcYd82hkKu0W1p90LaRghhCJvLrdZL058JABgPq8UMkeNIXNL7Ie/GWkyWOg==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-4.21.0.tgz", + "integrity": "sha512-q3iIBPcVFwLozQ10mtLBI6pOb1Kp01Ixw0mYWWknrP9nZvJIGA4ZX/xY4P1q110SqSBUblB2Sa8DKoRWZciz4g==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index c17dca4..ee891d3 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", "@eslint/js": "^10.0.1", - "@norbix.ai/ts": "^4.20.0", + "@norbix.ai/ts": "^4.21.0", "@reduxjs/toolkit": "^2.12.0", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/github": "^12.0.9", diff --git a/src/hooks/hub/code.ts b/src/hooks/hub/code.ts index 2accb57..4dbb24d 100644 --- a/src/hooks/hub/code.ts +++ b/src/hooks/hub/code.ts @@ -8,7 +8,7 @@ import type { Arg, Result } from '../types.js'; * Azure Functions, Google Cloud Functions credentials) and the cloud functions * they may call: list, aliases / versions, a call by hand, a sample envelope * and the last runs of a trigger or task. The marketplace half of `hub.code` - * is not wrapped here. + * is in `./marketplace.ts`. * * Tag conventions: * - `Code` — module state diff --git a/src/hooks/hub/marketplace.ts b/src/hooks/hub/marketplace.ts new file mode 100644 index 0000000..76a1209 --- /dev/null +++ b/src/hooks/hub/marketplace.ts @@ -0,0 +1,183 @@ +import type { Norbix } from '@norbix.ai/ts'; + +import type { Builder } from '../index.js'; +import type { Arg, Result } from '../types.js'; + +/** + * `hub.code` marketplace — the catalog of listings, the installed connections + * (`int_…`), their secrets, token mappings and validation test, the vendor + * function catalog, and the functions added on a connection (`func_…`). + * 1:1 with the marketplace methods of `norbix.hub.code` (gateway contract + * `Code.Marketplace.md` §14). + * + * Tag conventions (all type `CodeMarketplace`): + * - `LISTINGS` — the catalog, one listing, a listing's function tokens + * - `INTEGRATIONS` — the connection list + * - `` — one connection (secrets, token mappings and the + * last validation steps live on it) + * - `CATALOG:` — the vendor functions a connection offers + * - `FUNCTIONS:`— the functions added on a connection + * - `` — one function and its tokens + * - `RUNS` — the last runs of a trigger or task (core SDK 4.21.0) + * + * Revealing secrets is a mutation on purpose: a query would keep the clear + * values in the store's cache. + */ +export const hubCodeMarketplace = (b: Builder) => { + type HC = Norbix['hub']['code']; + type Fn = HC[K] extends (...a: never[]) => unknown ? HC[K] : never; + + type GetListings = Fn<'getMarketplaceListings'>; + type GetListing = Fn<'getMarketplaceListing'>; + type GetListingFunctionTokens = Fn<'getMarketplaceListingFunctionTokens'>; + type GetIntegrations = Fn<'getMarketplaceIntegrations'>; + type GetIntegration = Fn<'getMarketplaceIntegration'>; + type SaveIntegration = Fn<'saveMarketplaceIntegration'>; + type DeleteIntegration = Fn<'deleteMarketplaceIntegration'>; + type EnableIntegration = Fn<'enableMarketplaceIntegration'>; + type DisableIntegration = Fn<'disableMarketplaceIntegration'>; + type ReplaceSecrets = Fn<'replaceMarketplaceIntegrationSecrets'>; + type RevealSecrets = Fn<'revealMarketplaceIntegrationSecrets'>; + type SetTokenMappings = Fn<'setMarketplaceIntegrationTokenMappings'>; + type TestIntegration = Fn<'testMarketplaceIntegration'>; + type GetCatalog = Fn<'getMarketplaceFunctionCatalog'>; + type GetFunctions = Fn<'getMarketplaceFunctions'>; + type GetFunction = Fn<'getMarketplaceFunction'>; + type SaveFunction = Fn<'saveMarketplaceFunction'>; + type DeleteFunction = Fn<'deleteMarketplaceFunction'>; + type EnableFunction = Fn<'enableMarketplaceFunction'>; + type DisableFunction = Fn<'disableMarketplaceFunction'>; + type GetFunctionTokens = Fn<'getMarketplaceFunctionTokens'>; + type InvokeFunction = Fn<'invokeMarketplaceFunction'>; + type GetRuns = Fn<'getMarketplaceFunctionRuns'>; + + const callHC = (norbix: Norbix) => norbix.hub.code as HC; + + const T = 'CodeMarketplace' as const; + const integrationId = (arg: unknown) => + (arg as { integrationViewId?: string })?.integrationViewId ?? + (arg as { IntegrationViewId?: string })?.IntegrationViewId ?? + 'CURRENT'; + const functionId = (arg: unknown) => + (arg as { functionViewId?: string })?.functionViewId ?? + (arg as { FunctionViewId?: string })?.FunctionViewId ?? + 'CURRENT'; + const listings = [{ type: T, id: 'LISTINGS' }]; + const integration = (arg: unknown) => [ + { type: T, id: 'INTEGRATIONS' }, + { type: T, id: integrationId(arg) }, + ]; + const functions = (arg: unknown) => [ + { type: T, id: `FUNCTIONS:${integrationId(arg)}` }, + { type: T, id: functionId(arg) }, + ]; + + return { + // ---- Catalog ---- + getMarketplaceListings: b.query, Arg>({ + query: (args) => (norbix) => callHC(norbix).getMarketplaceListings(args), + providesTags: listings, + }), + getMarketplaceListing: b.query, Arg>({ + query: (args) => (norbix) => callHC(norbix).getMarketplaceListing(args), + providesTags: listings, + }), + getMarketplaceListingFunctionTokens: b.query< + Result, + Arg + >({ + query: (args) => (norbix) => callHC(norbix).getMarketplaceListingFunctionTokens(args), + providesTags: listings, + }), + + // ---- Connections ---- + getMarketplaceIntegrations: b.query, Arg>({ + query: (args) => (norbix) => callHC(norbix).getMarketplaceIntegrations(args), + providesTags: [{ type: T, id: 'INTEGRATIONS' }], + }), + getMarketplaceIntegration: b.query, Arg>({ + query: (args) => (norbix) => callHC(norbix).getMarketplaceIntegration(args), + providesTags: (_res, _err, arg) => [{ type: T, id: integrationId(arg) }], + }), + saveMarketplaceIntegration: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).saveMarketplaceIntegration(args), + invalidatesTags: [{ type: T, id: 'INTEGRATIONS' }], + }), + deleteMarketplaceIntegration: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).deleteMarketplaceIntegration(args), + invalidatesTags: (_res, _err, arg) => [ + ...integration(arg), + { type: T, id: `FUNCTIONS:${integrationId(arg)}` }, + ], + }), + enableMarketplaceIntegration: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).enableMarketplaceIntegration(args), + invalidatesTags: (_res, _err, arg) => integration(arg), + }), + disableMarketplaceIntegration: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).disableMarketplaceIntegration(args), + invalidatesTags: (_res, _err, arg) => integration(arg), + }), + replaceMarketplaceIntegrationSecrets: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).replaceMarketplaceIntegrationSecrets(args), + invalidatesTags: (_res, _err, arg) => [{ type: T, id: integrationId(arg) }], + }), + revealMarketplaceIntegrationSecrets: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).revealMarketplaceIntegrationSecrets(args), + }), + setMarketplaceIntegrationTokenMappings: b.mutation< + Result, + Arg + >({ + query: (args) => (norbix) => callHC(norbix).setMarketplaceIntegrationTokenMappings(args), + invalidatesTags: (_res, _err, arg) => [{ type: T, id: integrationId(arg) }], + }), + testMarketplaceIntegration: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).testMarketplaceIntegration(args), + invalidatesTags: (_res, _err, arg) => [{ type: T, id: integrationId(arg) }], + }), + getMarketplaceFunctionCatalog: b.query, Arg>({ + query: (args) => (norbix) => callHC(norbix).getMarketplaceFunctionCatalog(args), + providesTags: (_res, _err, arg) => [{ type: T, id: `CATALOG:${integrationId(arg)}` }], + }), + + // ---- Functions on a connection ---- + getMarketplaceFunctions: b.query, Arg>({ + query: (args) => (norbix) => callHC(norbix).getMarketplaceFunctions(args), + providesTags: (_res, _err, arg) => [{ type: T, id: `FUNCTIONS:${integrationId(arg)}` }], + }), + getMarketplaceFunction: b.query, Arg>({ + query: (args) => (norbix) => callHC(norbix).getMarketplaceFunction(args), + providesTags: (_res, _err, arg) => [{ type: T, id: functionId(arg) }], + }), + saveMarketplaceFunction: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).saveMarketplaceFunction(args), + invalidatesTags: (_res, _err, arg) => functions(arg), + }), + deleteMarketplaceFunction: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).deleteMarketplaceFunction(args), + invalidatesTags: (_res, _err, arg) => functions(arg), + }), + enableMarketplaceFunction: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).enableMarketplaceFunction(args), + invalidatesTags: (_res, _err, arg) => functions(arg), + }), + disableMarketplaceFunction: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).disableMarketplaceFunction(args), + invalidatesTags: (_res, _err, arg) => functions(arg), + }), + getMarketplaceFunctionTokens: b.query, Arg>({ + query: (args) => (norbix) => callHC(norbix).getMarketplaceFunctionTokens(args), + providesTags: (_res, _err, arg) => [{ type: T, id: functionId(arg) }], + }), + invokeMarketplaceFunction: b.mutation, Arg>({ + query: (args) => (norbix) => callHC(norbix).invokeMarketplaceFunction(args), + }), + + // ---- Last runs (core SDK 4.21.0) ---- + getMarketplaceFunctionRuns: b.query, Arg>({ + query: (args) => (norbix) => callHC(norbix).getMarketplaceFunctionRuns(args), + providesTags: [{ type: T, id: 'RUNS' }], + }), + }; +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 997dc85..53c7cd2 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -19,6 +19,7 @@ import { hubEmail } from './hub/email.js'; import { hubEnvironments } from './hub/environments.js'; import { hubFiles } from './hub/files.js'; import { hubLogs } from './hub/logs.js'; +import { hubCodeMarketplace } from './hub/marketplace.js'; import { hubMembership } from './hub/membership.js'; import { hubNotifications } from './hub/notifications.js'; import { hubPayments } from './hub/payments.js'; @@ -145,6 +146,7 @@ export function buildEndpoints(builder: Builder) { ...hubAi(builder), ...hubApikeys(builder), ...hubCode(builder), + ...hubCodeMarketplace(builder), ...hubMembership(builder), ...hubDatabase(builder), ...hubFiles(builder), diff --git a/tests/marketplace.test.ts b/tests/marketplace.test.ts new file mode 100644 index 0000000..2731caf --- /dev/null +++ b/tests/marketplace.test.ts @@ -0,0 +1,314 @@ +import { URL } from 'node:url'; + +import { Norbix } from '@norbix.ai/ts'; +import { describe, expect, it, vi } from 'vitest'; + +import { createNorbixBaseQuery } from '../src/baseQuery.js'; +import { hubCodeMarketplace } from '../src/hooks/hub/marketplace.js'; + +/** + * The marketplace hooks of `hub.code` (gateway contract Code.Marketplace.md + * §14). The hooks are a 1:1 wrapper, so the contract is: each hook reaches + * the right SDK method with the caller's arguments, is a query or a mutation + * as expected, carries the cache tags, and — through the real `@norbix.ai/ts` + * client with a fake fetch, never a real server — sends the verb, the path + * with the ids filled in, and the fields in the query string (GET / DELETE) + * or the JSON body (POST / PUT). + */ +function fakeBuilder() { + return { + query: (def: Record) => ({ ...def, __kind: 'query' }), + mutation: (def: Record) => ({ ...def, __kind: 'mutation' }), + } as never; +} + +type Tags = unknown[] | ((res: unknown, err: unknown, arg: unknown) => unknown[]); + +type Def = { + __kind: 'query' | 'mutation'; + query: (args: unknown) => (norbix: unknown) => unknown; + providesTags?: Tags; + invalidatesTags?: Tags; +}; + +const INTEGRATION = 'int_6Qp1dW2kRm8NsT4vYc0Hb'; +const FUNCTION = 'func_4hT2b9QkLm3XcT1yRfGh7a'; +const LISTING = 'ml_slack_v1'; +const IDS = { integrationViewId: INTEGRATION }; +const FN_IDS = { integrationViewId: INTEGRATION, functionViewId: FUNCTION }; +const I = `/code/marketplace/integrations/${INTEGRATION}`; + +/** hook → kind, verb, path after /v2, the caller's args, the fields that must go on the wire. */ +const CASES: Array< + [string, 'query' | 'mutation', string, string, Record, Record] +> = [ + [ + 'getMarketplaceListings', + 'query', + 'GET', + '/code/marketplace/listings', + { search: 'slack' }, + { search: 'slack' }, + ], + [ + 'getMarketplaceListing', + 'query', + 'GET', + `/code/marketplace/listings/${LISTING}`, + { listingViewId: LISTING }, + {}, + ], + [ + 'getMarketplaceListingFunctionTokens', + 'query', + 'GET', + `/code/marketplace/listings/${LISTING}/functions/slack.post-message/tokens`, + { listingViewId: LISTING, functionKey: 'slack.post-message' }, + {}, + ], + ['getMarketplaceIntegrations', 'query', 'GET', '/code/marketplace/integrations', {}, {}], + ['getMarketplaceIntegration', 'query', 'GET', I, IDS, {}], + [ + 'saveMarketplaceIntegration', + 'mutation', + 'POST', + '/code/marketplace/integrations', + { integration: { listingViewId: LISTING }, secrets: { botToken: 'xoxb-test' } }, + { integration: { listingViewId: LISTING }, secrets: { botToken: 'xoxb-test' } }, + ], + ['deleteMarketplaceIntegration', 'mutation', 'DELETE', I, IDS, {}], + ['enableMarketplaceIntegration', 'mutation', 'POST', `${I}/enable`, IDS, {}], + ['disableMarketplaceIntegration', 'mutation', 'POST', `${I}/disable`, IDS, {}], + [ + 'replaceMarketplaceIntegrationSecrets', + 'mutation', + 'PUT', + `${I}/secrets`, + { ...IDS, secrets: { botToken: 'xoxb-new' } }, + { secrets: { botToken: 'xoxb-new' } }, + ], + ['revealMarketplaceIntegrationSecrets', 'mutation', 'POST', `${I}/secrets/reveal`, IDS, {}], + [ + 'setMarketplaceIntegrationTokenMappings', + 'mutation', + 'PUT', + `${I}/token-mappings`, + { ...IDS, tokenMappings: [{ key: 'channel', value: '#orders', resolver: 'Custom' }] }, + { tokenMappings: [{ key: 'channel', value: '#orders', resolver: 'Custom' }] }, + ], + ['testMarketplaceIntegration', 'mutation', 'POST', `${I}/test`, IDS, {}], + ['getMarketplaceFunctionCatalog', 'query', 'GET', `${I}/catalog`, IDS, {}], + ['getMarketplaceFunctions', 'query', 'GET', `${I}/functions`, IDS, {}], + ['getMarketplaceFunction', 'query', 'GET', `${I}/functions/${FUNCTION}`, FN_IDS, {}], + [ + 'saveMarketplaceFunction', + 'mutation', + 'POST', + `${I}/functions`, + { ...IDS, function: { functionKey: 'slack.post-message', displayName: 'Post order' } }, + { function: { functionKey: 'slack.post-message', displayName: 'Post order' } }, + ], + ['deleteMarketplaceFunction', 'mutation', 'DELETE', `${I}/functions/${FUNCTION}`, FN_IDS, {}], + [ + 'enableMarketplaceFunction', + 'mutation', + 'POST', + `${I}/functions/${FUNCTION}/enable`, + FN_IDS, + {}, + ], + [ + 'disableMarketplaceFunction', + 'mutation', + 'POST', + `${I}/functions/${FUNCTION}/disable`, + FN_IDS, + {}, + ], + ['getMarketplaceFunctionTokens', 'query', 'GET', `${I}/functions/${FUNCTION}/tokens`, FN_IDS, {}], + [ + 'invokeMarketplaceFunction', + 'mutation', + 'POST', + `/code/marketplace/functions/${FUNCTION}/invoke`, + { functionViewId: FUNCTION, payload: { text: 'Order shipped' } }, + { payload: { text: 'Order shipped' } }, + ], + [ + 'getMarketplaceFunctionRuns', + 'query', + 'GET', + '/code/marketplace/functions/runs', + { triggerId: 'trg_1p4R3gTobGZpD6fXvHT4rd', limit: 5 }, + { triggerId: 'trg_1p4R3gTobGZpD6fXvHT4rd', limit: 5 }, + ], +]; +const NAMES = CASES.map((c) => c[0]); + +function tagsFor(tags: Tags | undefined, arg: unknown): unknown { + return typeof tags === 'function' ? tags(undefined, undefined, arg) : tags; +} + +const endpoints = hubCodeMarketplace(fakeBuilder()) as unknown as Record; + +describe('hubCodeMarketplace', () => { + it('wraps the 23 marketplace methods of the published core SDK', () => { + expect(Object.keys(endpoints).sort()).toEqual([...NAMES].sort()); + }); + + it.each(CASES.map((c) => [c[0], c[1]] as const))( + '%s is a %s and reaches the SDK method of the same name with the caller args', + async (key, kind) => { + expect(endpoints[key]!.__kind).toBe(kind); + const calls: Array<{ method: string; args: unknown }> = []; + const code = { + [key]: (args: unknown) => (calls.push({ method: key, args }), Promise.resolve({})), + }; + const args = { ...FN_IDS, marker: key }; + await endpoints[key]!.query(args)({ hub: { code } }); + expect(calls).toEqual([{ method: key, args }]); + }, + ); + + it('catalog reads provide LISTINGS, the connection list INTEGRATIONS, one connection its id', () => { + for (const key of [ + 'getMarketplaceListings', + 'getMarketplaceListing', + 'getMarketplaceListingFunctionTokens', + ]) { + expect(tagsFor(endpoints[key]!.providesTags, {})).toEqual([ + { type: 'CodeMarketplace', id: 'LISTINGS' }, + ]); + } + expect(tagsFor(endpoints['getMarketplaceIntegrations']!.providesTags, {})).toEqual([ + { type: 'CodeMarketplace', id: 'INTEGRATIONS' }, + ]); + expect(tagsFor(endpoints['getMarketplaceIntegration']!.providesTags, IDS)).toEqual([ + { type: 'CodeMarketplace', id: INTEGRATION }, + ]); + expect(tagsFor(endpoints['getMarketplaceFunctionCatalog']!.providesTags, IDS)).toEqual([ + { type: 'CodeMarketplace', id: `CATALOG:${INTEGRATION}` }, + ]); + }); + + it('connection writes invalidate the list and the connection; delete also its functions', () => { + for (const key of ['enableMarketplaceIntegration', 'disableMarketplaceIntegration']) { + expect(tagsFor(endpoints[key]!.invalidatesTags, IDS)).toEqual([ + { type: 'CodeMarketplace', id: 'INTEGRATIONS' }, + { type: 'CodeMarketplace', id: INTEGRATION }, + ]); + } + expect(tagsFor(endpoints['deleteMarketplaceIntegration']!.invalidatesTags, IDS)).toEqual([ + { type: 'CodeMarketplace', id: 'INTEGRATIONS' }, + { type: 'CodeMarketplace', id: INTEGRATION }, + { type: 'CodeMarketplace', id: `FUNCTIONS:${INTEGRATION}` }, + ]); + expect(tagsFor(endpoints['saveMarketplaceIntegration']!.invalidatesTags, {})).toEqual([ + { type: 'CodeMarketplace', id: 'INTEGRATIONS' }, + ]); + for (const key of [ + 'replaceMarketplaceIntegrationSecrets', + 'setMarketplaceIntegrationTokenMappings', + 'testMarketplaceIntegration', + ]) { + expect(tagsFor(endpoints[key]!.invalidatesTags, IDS)).toEqual([ + { type: 'CodeMarketplace', id: INTEGRATION }, + ]); + } + }); + + it('the last runs provide CodeMarketplace/RUNS', () => { + expect(tagsFor(endpoints['getMarketplaceFunctionRuns']!.providesTags, {})).toEqual([ + { type: 'CodeMarketplace', id: 'RUNS' }, + ]); + }); + + it('reveal and invoke invalidate nothing (reveal is a mutation so clear secrets are never cached)', () => { + expect(endpoints['revealMarketplaceIntegrationSecrets']!.__kind).toBe('mutation'); + expect(endpoints['revealMarketplaceIntegrationSecrets']!.invalidatesTags).toBeUndefined(); + expect(endpoints['invokeMarketplaceFunction']!.invalidatesTags).toBeUndefined(); + }); + + it('function reads provide FUNCTIONS: or the function id; writes invalidate both', () => { + expect(tagsFor(endpoints['getMarketplaceFunctions']!.providesTags, IDS)).toEqual([ + { type: 'CodeMarketplace', id: `FUNCTIONS:${INTEGRATION}` }, + ]); + for (const key of ['getMarketplaceFunction', 'getMarketplaceFunctionTokens']) { + expect(tagsFor(endpoints[key]!.providesTags, FN_IDS)).toEqual([ + { type: 'CodeMarketplace', id: FUNCTION }, + ]); + } + for (const key of [ + 'saveMarketplaceFunction', + 'deleteMarketplaceFunction', + 'enableMarketplaceFunction', + 'disableMarketplaceFunction', + ]) { + expect(tagsFor(endpoints[key]!.invalidatesTags, FN_IDS)).toEqual([ + { type: 'CodeMarketplace', id: `FUNCTIONS:${INTEGRATION}` }, + { type: 'CodeMarketplace', id: FUNCTION }, + ]); + } + }); +}); + +describe('marketplace hooks through the real SDK (fake fetch)', () => { + function run(key: string, args: unknown) { + type FetchFn = typeof globalThis.fetch; + const seen: Array<{ + url: string; + method: string; + body: string | undefined; + headers: InstanceType; + }> = []; + const fetchImpl = vi.fn(async (...[input, init]: Parameters) => { + seen.push({ + url: String(input), + method: (init?.method ?? 'GET').toUpperCase(), + body: typeof init?.body === 'string' ? init.body : undefined, + headers: new globalThis.Headers(init?.headers ?? {}), + }); + return new globalThis.Response(JSON.stringify({ responseStatus: { isSuccess: true } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as unknown as FetchFn; + const norbix = new Norbix({ + bearerToken: 'test-token', + projectId: 'test-project', + apiVersion: 'v2', + hubVersion: 'v2', + baseUrl: { api: 'https://api.norbix.io', hub: 'https://hub.norbix.io' }, + fetch: fetchImpl, + }); + const baseQuery = createNorbixBaseQuery(() => norbix); + const result = baseQuery(endpoints[key]!.query(args) as never, {} as never, {}); + return { seen, result }; + } + + const HUB = 'https://hub.norbix.io/v2'; + + it.each(CASES)('%s (%s) sends %s %s', async (key, _kind, verb, path, args, wire) => { + const { seen, result } = run(key, args); + expect(((await result) as { error?: unknown }).error).toBeUndefined(); + expect(seen[0]!.method).toBe(verb); + const url = new URL(seen[0]!.url); + expect(`${url.origin}${url.pathname}`).toBe(`${HUB}${path}`); + expect(seen[0]!.headers.get('Authorization')).toBe('Bearer test-token'); + expect(seen[0]!.headers.get('norbix-project-id')).toBe('test-project'); + if (verb === 'GET' || verb === 'DELETE') { + const query = Object.fromEntries( + [...url.searchParams.entries()].filter(([k]) => k !== 'env'), + ); + expect(query).toEqual( + Object.fromEntries(Object.entries(wire).map(([k, v]) => [k, String(v)])), + ); + expect(seen[0]!.body).toBeUndefined(); + } else { + expect(url.search).toBe(''); + const body = + seen[0]!.body === undefined ? {} : (JSON.parse(seen[0]!.body) as Record); + expect(body).toEqual(wire); + } + }); +});