diff --git a/src/tools/assets/mockClient.ts b/src/tools/assets/mockClient.ts index 5cd74c3..439f428 100644 --- a/src/tools/assets/mockClient.ts +++ b/src/tools/assets/mockClient.ts @@ -100,6 +100,10 @@ export const mockAsset = { }, }, }, + metadata: { + tags: [], + concepts: [], + }, }; /** @@ -170,3 +174,57 @@ export const mockProcessedAsset = { }, }, }; + +/** + * Mock taxonomy concepts for testing + */ +export const mockTaxonomyConcepts = { + concept1: { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'concept-1', + }, + }, + concept2: { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'concept-2', + }, + }, + existingConcept: { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'existing-concept', + }, + }, +}; + +/** + * Mock tags for testing + */ +export const mockTags = { + tag1: { + sys: { + type: 'Link' as const, + linkType: 'Tag' as const, + id: 'tag-1', + }, + }, + tag2: { + sys: { + type: 'Link' as const, + linkType: 'Tag' as const, + id: 'tag-2', + }, + }, + existingTag: { + sys: { + type: 'Link' as const, + linkType: 'Tag' as const, + id: 'existing-tag', + }, + }, +}; diff --git a/src/tools/assets/updateAsset.test.ts b/src/tools/assets/updateAsset.test.ts index d3585c1..0cd37a6 100644 --- a/src/tools/assets/updateAsset.test.ts +++ b/src/tools/assets/updateAsset.test.ts @@ -73,6 +73,7 @@ describe('updateAsset', () => { }, metadata: { tags: [], + concepts: [], }, }, ); @@ -94,6 +95,7 @@ describe('updateAsset', () => { }, }, ], + concepts: [], }, }; @@ -109,6 +111,7 @@ describe('updateAsset', () => { }, }, ], + concepts: [], }, }; @@ -135,6 +138,7 @@ describe('updateAsset', () => { }, }, ], + concepts: [], }, }; @@ -163,6 +167,7 @@ describe('updateAsset', () => { }, }, ], + concepts: [], }, }), ); @@ -278,6 +283,205 @@ describe('updateAsset', () => { expect.objectContaining({ metadata: { tags: [], + concepts: [], + }, + }), + ); + }); + + it('should update an asset with new taxonomy concepts', async () => { + const testArgs = { + ...mockArgs, + fields: { + title: { 'en-US': 'Asset with Concepts' }, + }, + metadata: { + tags: [], + concepts: [ + { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'concept-1', + }, + }, + ], + }, + }; + + const assetWithExistingConcepts = { + ...mockAsset, + metadata: { + tags: [], + concepts: [ + { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'existing-concept', + }, + }, + ], + }, + }; + + const updatedAsset = { + ...assetWithExistingConcepts, + fields: { + ...assetWithExistingConcepts.fields, + title: { 'en-US': 'Asset with Concepts' }, + }, + metadata: { + tags: [], + concepts: [ + { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'existing-concept', + }, + }, + { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'concept-1', + }, + }, + ], + }, + }; + + mockAssetGet.mockResolvedValue(assetWithExistingConcepts); + mockAssetUpdate.mockResolvedValue(updatedAsset); + + await updateAssetTool(testArgs); + + expect(mockAssetUpdate).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + metadata: { + tags: [], + concepts: [ + { + sys: { + type: 'Link', + linkType: 'TaxonomyConcept', + id: 'existing-concept', + }, + }, + { + sys: { + type: 'Link', + linkType: 'TaxonomyConcept', + id: 'concept-1', + }, + }, + ], + }, + }), + ); + }); + + it('should update an asset with both tags and concepts', async () => { + const testArgs = { + ...mockArgs, + fields: { + title: { 'en-US': 'Asset with Tags and Concepts' }, + }, + metadata: { + tags: [ + { + sys: { + type: 'Link' as const, + linkType: 'Tag' as const, + id: 'new-tag', + }, + }, + ], + concepts: [ + { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'new-concept', + }, + }, + ], + }, + }; + + const assetWithExistingMetadata = { + ...mockAsset, + metadata: { + tags: [ + { + sys: { + type: 'Link' as const, + linkType: 'Tag' as const, + id: 'existing-tag', + }, + }, + ], + concepts: [ + { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'existing-concept', + }, + }, + ], + }, + }; + + mockAssetGet.mockResolvedValue(assetWithExistingMetadata); + mockAssetUpdate.mockResolvedValue({ + ...assetWithExistingMetadata, + fields: { + ...assetWithExistingMetadata.fields, + title: { 'en-US': 'Asset with Tags and Concepts' }, + }, + }); + + await updateAssetTool(testArgs); + + expect(mockAssetUpdate).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + metadata: { + tags: [ + { + sys: { + type: 'Link', + linkType: 'Tag', + id: 'existing-tag', + }, + }, + { + sys: { + type: 'Link', + linkType: 'Tag', + id: 'new-tag', + }, + }, + ], + concepts: [ + { + sys: { + type: 'Link', + linkType: 'TaxonomyConcept', + id: 'existing-concept', + }, + }, + { + sys: { + type: 'Link', + linkType: 'TaxonomyConcept', + id: 'new-concept', + }, + }, + ], }, }), ); diff --git a/src/tools/assets/updateAsset.ts b/src/tools/assets/updateAsset.ts index 5208d71..7b056b8 100644 --- a/src/tools/assets/updateAsset.ts +++ b/src/tools/assets/updateAsset.ts @@ -4,6 +4,7 @@ import { withErrorHandling, } from '../../utils/response.js'; import { BaseToolSchema, createToolClient } from '../../utils/tools.js'; +import { AssetMetadataSchema } from '../../types/taxonomySchema.js'; export const UpdateAssetToolParams = BaseToolSchema.extend({ assetId: z.string().describe('The ID of the asset to update'), @@ -12,19 +13,7 @@ export const UpdateAssetToolParams = BaseToolSchema.extend({ .describe( 'The field values to update. Keys should be field IDs and values should be the field content. Will be merged with existing fields.', ), - metadata: z - .object({ - tags: z.array( - z.object({ - sys: z.object({ - type: z.literal('Link'), - linkType: z.literal('Tag'), - id: z.string(), - }), - }), - ), - }) - .optional(), + metadata: AssetMetadataSchema, }); type Params = z.infer; @@ -40,14 +29,23 @@ async function tool(args: Params) { // Get existing asset, merge fields, and update const existingAsset = await contentfulClient.asset.get(params); + + const allTags = [ + ...(existingAsset.metadata?.tags || []), + ...(args.metadata?.tags || []), + ]; + + const allConcepts = [ + ...(existingAsset.metadata?.concepts || []), + ...(args.metadata?.concepts || []), + ]; + const updatedAsset = await contentfulClient.asset.update(params, { ...existingAsset, fields: { ...existingAsset.fields, ...args.fields }, metadata: { - tags: [ - ...(existingAsset.metadata?.tags || []), - ...(args.metadata?.tags || []), - ], + tags: allTags, + concepts: allConcepts, }, }); diff --git a/src/tools/assets/uploadAsset.test.ts b/src/tools/assets/uploadAsset.test.ts index 7222997..b4100c5 100644 --- a/src/tools/assets/uploadAsset.test.ts +++ b/src/tools/assets/uploadAsset.test.ts @@ -183,4 +183,121 @@ describe('uploadAsset', () => { ], }); }); + + it('should upload an asset with taxonomy concepts metadata', async () => { + const testArgs = { + ...mockArgs, + title: 'Asset with Concepts', + file: mockFile, + metadata: { + tags: [], + concepts: [ + { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'concept1', + }, + }, + ], + }, + }; + + mockAssetCreate.mockResolvedValue(mockAsset); + mockAssetProcessForAllLocales.mockResolvedValue(mockProcessedAsset); + + await uploadAssetTool(testArgs); + + expect(mockAssetCreate).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + metadata: testArgs.metadata, + }), + ); + }); + + it('should upload an asset with both tags and concepts', async () => { + const testArgs = { + ...mockArgs, + title: 'Asset with Tags and Concepts', + file: mockFile, + metadata: { + tags: [ + { + sys: { + type: 'Link' as const, + linkType: 'Tag' as const, + id: 'tag1', + }, + }, + ], + concepts: [ + { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'concept1', + }, + }, + { + sys: { + type: 'Link' as const, + linkType: 'TaxonomyConcept' as const, + id: 'concept2', + }, + }, + ], + }, + }; + + mockAssetCreate.mockResolvedValue(mockAsset); + mockAssetProcessForAllLocales.mockResolvedValue(mockProcessedAsset); + + const result = await uploadAssetTool(testArgs); + + const expectedResponse = formatResponse('Asset uploaded successfully', { + asset: mockProcessedAsset, + }); + expect(result).toEqual({ + content: [ + { + type: 'text', + text: expectedResponse, + }, + ], + }); + + expect(mockAssetCreate).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + metadata: { + tags: [ + { + sys: { + type: 'Link', + linkType: 'Tag', + id: 'tag1', + }, + }, + ], + concepts: [ + { + sys: { + type: 'Link', + linkType: 'TaxonomyConcept', + id: 'concept1', + }, + }, + { + sys: { + type: 'Link', + linkType: 'TaxonomyConcept', + id: 'concept2', + }, + }, + ], + }, + }), + ); + }); }); diff --git a/src/tools/assets/uploadAsset.ts b/src/tools/assets/uploadAsset.ts index c4550e0..ba7bc85 100644 --- a/src/tools/assets/uploadAsset.ts +++ b/src/tools/assets/uploadAsset.ts @@ -4,6 +4,7 @@ import { withErrorHandling, } from '../../utils/response.js'; import { BaseToolSchema, createToolClient } from '../../utils/tools.js'; +import { AssetMetadataSchema } from '../../types/taxonomySchema.js'; const FileSchema = z.object({ fileName: z.string().describe('The name of the file'), @@ -15,19 +16,7 @@ export const UploadAssetToolParams = BaseToolSchema.extend({ title: z.string().describe('The title of the asset'), description: z.string().optional().describe('The description of the asset'), file: FileSchema.describe('The file information for the asset'), - metadata: z - .object({ - tags: z.array( - z.object({ - sys: z.object({ - type: z.literal('Link'), - linkType: z.literal('Tag'), - id: z.string(), - }), - }), - ), - }) - .optional(), + metadata: AssetMetadataSchema, }); type Params = z.infer; diff --git a/src/types/taxonomySchema.ts b/src/types/taxonomySchema.ts index eabe6c3..36ae912 100644 --- a/src/types/taxonomySchema.ts +++ b/src/types/taxonomySchema.ts @@ -73,3 +73,32 @@ export const EntryMetadataSchema = z .optional(), }) .optional(); + +/** + * Schema for asset type metadata + * Matches Contentful's asset metadata structure with tags and taxonomy concepts + */ +export const AssetMetadataSchema = z + .object({ + tags: z.array( + z.object({ + sys: z.object({ + type: z.literal('Link'), + linkType: z.literal('Tag'), + id: z.string(), + }), + }), + ), + concepts: z + .array( + z.object({ + sys: z.object({ + type: z.literal('Link'), + linkType: z.literal('TaxonomyConcept'), + id: z.string(), + }), + }), + ) + .optional(), + }) + .optional();