From 7bb73a267b4629a9d035023c879d33fb4791f4c6 Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Fri, 30 Jan 2026 11:10:53 +0100 Subject: [PATCH 01/15] chore: update package-lock --- package-lock.json | 122 +++++++++++++++++++++++----------------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7fa645332..c21c9591d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/api-harmonization": { "name": "@o2s/api-harmonization", - "version": "1.13.0", + "version": "1.14.0", "license": "MIT", "dependencies": { "@nestjs/axios": "^4.0.1", @@ -515,7 +515,7 @@ }, "apps/docs": { "name": "@o2s/docs", - "version": "1.6.0", + "version": "1.7.0", "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/plugin-google-gtag": "^3.9.2", @@ -597,7 +597,7 @@ }, "apps/frontend": { "name": "@o2s/frontend", - "version": "1.14.0", + "version": "1.15.0", "dependencies": { "@contentful/live-preview": "^4.9.1", "@o2s/blocks.article": "*", @@ -49429,7 +49429,7 @@ }, "packages/blocks/article": { "name": "@o2s/blocks.article", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49469,7 +49469,7 @@ }, "packages/blocks/article-list": { "name": "@o2s/blocks.article-list", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49509,7 +49509,7 @@ }, "packages/blocks/article-search": { "name": "@o2s/blocks.article-search", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49551,7 +49551,7 @@ }, "packages/blocks/bento-grid": { "name": "@o2s/blocks.bento-grid", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49590,7 +49590,7 @@ }, "packages/blocks/category": { "name": "@o2s/blocks.category", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49630,7 +49630,7 @@ }, "packages/blocks/category-list": { "name": "@o2s/blocks.category-list", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49670,7 +49670,7 @@ }, "packages/blocks/cta-section": { "name": "@o2s/blocks.cta-section", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49709,7 +49709,7 @@ }, "packages/blocks/document-list": { "name": "@o2s/blocks.document-list", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49748,7 +49748,7 @@ }, "packages/blocks/faq": { "name": "@o2s/blocks.faq", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49788,7 +49788,7 @@ }, "packages/blocks/feature-section": { "name": "@o2s/blocks.feature-section", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49827,7 +49827,7 @@ }, "packages/blocks/feature-section-grid": { "name": "@o2s/blocks.feature-section-grid", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49866,7 +49866,7 @@ }, "packages/blocks/featured-service-list": { "name": "@o2s/blocks.featured-service-list", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49906,7 +49906,7 @@ }, "packages/blocks/hero-section": { "name": "@o2s/blocks.hero-section", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49945,7 +49945,7 @@ }, "packages/blocks/invoice-list": { "name": "@o2s/blocks.invoice-list", - "version": "1.4.0", + "version": "1.5.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -49985,7 +49985,7 @@ }, "packages/blocks/media-section": { "name": "@o2s/blocks.media-section", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50024,7 +50024,7 @@ }, "packages/blocks/notification-details": { "name": "@o2s/blocks.notification-details", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50064,7 +50064,7 @@ }, "packages/blocks/notification-list": { "name": "@o2s/blocks.notification-list", - "version": "1.4.0", + "version": "1.5.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50104,7 +50104,7 @@ }, "packages/blocks/notification-summary": { "name": "@o2s/blocks.notification-summary", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50143,7 +50143,7 @@ }, "packages/blocks/order-details": { "name": "@o2s/blocks.order-details", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50183,7 +50183,7 @@ }, "packages/blocks/order-list": { "name": "@o2s/blocks.order-list", - "version": "1.4.0", + "version": "1.5.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50223,7 +50223,7 @@ }, "packages/blocks/orders-summary": { "name": "@o2s/blocks.orders-summary", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50263,7 +50263,7 @@ }, "packages/blocks/payments-history": { "name": "@o2s/blocks.payments-history", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50303,7 +50303,7 @@ }, "packages/blocks/payments-summary": { "name": "@o2s/blocks.payments-summary", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50343,7 +50343,7 @@ }, "packages/blocks/pricing-section": { "name": "@o2s/blocks.pricing-section", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50382,7 +50382,7 @@ }, "packages/blocks/product-details": { "name": "@o2s/blocks.product-details", - "version": "0.0.1", + "version": "0.1.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50424,7 +50424,7 @@ }, "packages/blocks/product-list": { "name": "@o2s/blocks.product-list", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50463,7 +50463,7 @@ }, "packages/blocks/quick-links": { "name": "@o2s/blocks.quick-links", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50503,7 +50503,7 @@ }, "packages/blocks/recommended-products": { "name": "@o2s/blocks.recommended-products", - "version": "0.0.1", + "version": "0.1.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50543,7 +50543,7 @@ }, "packages/blocks/service-details": { "name": "@o2s/blocks.service-details", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50583,7 +50583,7 @@ }, "packages/blocks/service-list": { "name": "@o2s/blocks.service-list", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50623,7 +50623,7 @@ }, "packages/blocks/surveyjs-form": { "name": "@o2s/blocks.surveyjs-form", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50666,7 +50666,7 @@ }, "packages/blocks/ticket-details": { "name": "@o2s/blocks.ticket-details", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50706,7 +50706,7 @@ }, "packages/blocks/ticket-list": { "name": "@o2s/blocks.ticket-list", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50746,7 +50746,7 @@ }, "packages/blocks/ticket-recent": { "name": "@o2s/blocks.ticket-recent", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50786,7 +50786,7 @@ }, "packages/blocks/ticket-summary": { "name": "@o2s/blocks.ticket-summary", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50825,7 +50825,7 @@ }, "packages/blocks/user-account": { "name": "@o2s/blocks.user-account", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -50864,10 +50864,10 @@ } }, "packages/cli/create-o2s-app": { - "version": "1.1.4", + "version": "1.2.0", "license": "MIT", "dependencies": { - "@o2s/telemetry": "^1.1.2", + "@o2s/telemetry": "^1.2.0", "@types/prompts": "^2.4.9", "cli-progress": "^3.12.0", "commander": "^14.0.2", @@ -50891,7 +50891,7 @@ }, "packages/configs/eslint-config": { "name": "@o2s/eslint-config", - "version": "1.0.0", + "version": "1.1.0", "devDependencies": { "@eslint/js": "^9.39.2", "@next/eslint-plugin-next": "^16.1.6", @@ -50910,7 +50910,7 @@ }, "packages/configs/integrations": { "name": "@o2s/configs.integrations", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@o2s/framework": "*", @@ -50930,22 +50930,22 @@ }, "packages/configs/lint-staged-config": { "name": "@o2s/lint-staged-config", - "version": "0.0.1", + "version": "0.1.0", "license": "MIT" }, "packages/configs/prettier-config": { "name": "@o2s/prettier-config", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT" }, "packages/configs/typescript-config": { "name": "@o2s/typescript-config", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT" }, "packages/configs/vitest-config": { "name": "@o2s/vitest-config", - "version": "1.0.0", + "version": "1.1.0", "devDependencies": { "unplugin-swc": "^1.5.9", "vitest": "^4.0.18" @@ -50992,7 +50992,7 @@ }, "packages/framework": { "name": "@o2s/framework", - "version": "1.15.0", + "version": "1.16.0", "license": "MIT", "dependencies": { "@o2s/utils.logger": "*", @@ -51052,7 +51052,7 @@ }, "packages/integrations/algolia": { "name": "@o2s/integrations.algolia", - "version": "1.3.2", + "version": "1.4.0", "license": "MIT", "dependencies": { "@o2s/framework": "*", @@ -51080,7 +51080,7 @@ }, "packages/integrations/contentful-cms": { "name": "@o2s/integrations.contentful-cms", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "dependencies": { "@contentful/live-preview": "^4.9.1", @@ -51153,7 +51153,7 @@ }, "packages/integrations/medusajs": { "name": "@o2s/integrations.medusajs", - "version": "1.6.2", + "version": "1.7.0", "license": "MIT", "dependencies": { "@medusajs/js-sdk": "^2.13.1", @@ -51206,7 +51206,7 @@ }, "packages/integrations/mocked": { "name": "@o2s/integrations.mocked", - "version": "1.16.0", + "version": "1.17.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -51287,7 +51287,7 @@ }, "packages/integrations/redis": { "name": "@o2s/integrations.redis", - "version": "1.1.2", + "version": "1.2.0", "license": "MIT", "dependencies": { "@o2s/framework": "*", @@ -51315,7 +51315,7 @@ }, "packages/integrations/strapi-cms": { "name": "@o2s/integrations.strapi-cms", - "version": "2.9.0", + "version": "2.10.0", "license": "MIT", "dependencies": { "@o2s/framework": "*", @@ -51357,7 +51357,7 @@ }, "packages/integrations/zendesk": { "name": "@o2s/integrations.zendesk", - "version": "2.0.2", + "version": "2.1.0", "license": "MIT", "dependencies": { "@nestjs/axios": "^4.0.1", @@ -51382,7 +51382,7 @@ }, "packages/modules/surveyjs": { "name": "@o2s/modules.surveyjs", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@o2s/configs.integrations": "*", @@ -51429,7 +51429,7 @@ }, "packages/telemetry": { "name": "@o2s/telemetry", - "version": "1.1.2", + "version": "1.2.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -51655,7 +51655,7 @@ }, "packages/ui": { "name": "@o2s/ui", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "dependencies": { "@o2s/framework": "*", @@ -51733,7 +51733,7 @@ }, "packages/utils/api-harmonization": { "name": "@o2s/utils.api-harmonization", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@o2s/framework": "*" @@ -51756,7 +51756,7 @@ }, "packages/utils/frontend": { "name": "@o2s/utils.frontend", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@o2s/framework": "*" @@ -51780,7 +51780,7 @@ }, "packages/utils/logger": { "name": "@o2s/utils.logger", - "version": "1.1.3", + "version": "1.2.0", "license": "MIT", "dependencies": { "axios": "^1.13.4", From 4a8489e973021eb91e65e612c983f1d4d48630a5 Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Fri, 30 Jan 2026 14:09:55 +0100 Subject: [PATCH 02/15] chore: add AGENTS.md, CLAUDE.md, and .claude to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 515d7e530..323bc4b94 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ yarn-error.log* .vscode .cursor agent-os +AGENTS.md +CLAUDE.md +.claude # Local history .lh From 3652b34653b3fd5bb50dda9147fae4444c8cfda5 Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Fri, 30 Jan 2026 14:14:11 +0100 Subject: [PATCH 03/15] feat(zendesk): add testing support with Vitest and implement unit tests for ticket and field mappers --- packages/integrations/zendesk/package.json | 5 +- .../tickets/zendesk-field.mapper.spec.ts | 132 ++++++ .../tickets/zendesk-ticket.mapper.spec.ts | 196 +++++++++ .../tickets/zendesk-ticket.service.spec.ts | 383 ++++++++++++++++++ .../integrations/zendesk/vitest.config.mjs | 3 + 5 files changed, 718 insertions(+), 1 deletion(-) create mode 100644 packages/integrations/zendesk/src/modules/tickets/zendesk-field.mapper.spec.ts create mode 100644 packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.mapper.spec.ts create mode 100644 packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.service.spec.ts create mode 100644 packages/integrations/zendesk/vitest.config.mjs diff --git a/packages/integrations/zendesk/package.json b/packages/integrations/zendesk/package.json index f406444f8..35bf424e4 100644 --- a/packages/integrations/zendesk/package.json +++ b/packages/integrations/zendesk/package.json @@ -11,6 +11,7 @@ ], "scripts": { "prepare": "npm run fetch-oas && npm run generate-types", + "test": "vitest run", "build": "tsc --preserveWatchOutput && tsc-alias", "lint": "tsc --noEmit && eslint ./src --max-warnings=0", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"", @@ -28,6 +29,7 @@ "devDependencies": { "@hey-api/openapi-ts": "^0.91.0", "@o2s/eslint-config": "*", + "@o2s/vitest-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", "concurrently": "^9.2.1", @@ -35,6 +37,7 @@ "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" } } diff --git a/packages/integrations/zendesk/src/modules/tickets/zendesk-field.mapper.spec.ts b/packages/integrations/zendesk/src/modules/tickets/zendesk-field.mapper.spec.ts new file mode 100644 index 000000000..d65928d56 --- /dev/null +++ b/packages/integrations/zendesk/src/modules/tickets/zendesk-field.mapper.spec.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { getFieldKeyById, toCustomFields } from './zendesk-field.mapper'; + +describe('zendesk-field.mapper', () => { + const TOPIC_FIELD_ID = 999; + const PREFERRED_DATE_FIELD_ID = 777; + const TERMS_ACCEPTANCE_FIELD_ID = 888; + + beforeEach(() => { + process.env.ZENDESK_TOPIC_FIELD_ID = String(TOPIC_FIELD_ID); + process.env.ZENDESK_MAINTENANCE_PREFERRED_DATE_FIELD_ID = String(PREFERRED_DATE_FIELD_ID); + process.env.ZENDESK_TERMS_ACCEPTANCE_FIELD_ID = String(TERMS_ACCEPTANCE_FIELD_ID); + }); + + afterEach(() => { + delete process.env.ZENDESK_TOPIC_FIELD_ID; + delete process.env.ZENDESK_MAINTENANCE_PREFERRED_DATE_FIELD_ID; + delete process.env.ZENDESK_TERMS_ACCEPTANCE_FIELD_ID; + delete process.env.ZENDESK_EMAIL_FIELD_ID; + delete process.env.ZENDESK_INVOICE_NUMBER_FIELD_ID; + delete process.env.ZENDESK_DEVICE_NAME_FIELD_ID; + delete process.env.ZENDESK_ADDITIONAL_NOTES_FIELD_ID; + delete process.env.ZENDESK_FIRST_NAME_FIELD_ID; + }); + + describe('getFieldKeyById', () => { + it('should return field key for known topic field ID', () => { + const result = getFieldKeyById(TOPIC_FIELD_ID); + expect(result).toBe('topic'); + }); + + it('should return undefined for unknown field ID', () => { + const result = getFieldKeyById(12345678); + expect(result).toBeUndefined(); + }); + + it('should return undefined when no env mapping exists for given ID', () => { + delete process.env.ZENDESK_TOPIC_FIELD_ID; + const result = getFieldKeyById(TOPIC_FIELD_ID); + expect(result).toBeUndefined(); + }); + }); + + describe('toCustomFields', () => { + it('should keep string, number, and boolean values unchanged', () => { + process.env.ZENDESK_EMAIL_FIELD_ID = '100'; + process.env.ZENDESK_INVOICE_NUMBER_FIELD_ID = '104'; + const data = { + topic: 'CONTACT_US', + email: 'user@example.com', + invoiceNumber: 42, + termsAcceptance: true, + }; + const result = toCustomFields(data); + expect(result).toContainEqual({ id: TOPIC_FIELD_ID, value: 'CONTACT_US' }); + expect(result).toContainEqual({ id: 100, value: 'user@example.com' }); + expect(result).toContainEqual({ id: 104, value: 42 }); + expect(result).toContainEqual({ id: TERMS_ACCEPTANCE_FIELD_ID, value: true }); + }); + + it('should convert ISO date string to YYYY-MM-DD format', () => { + const data = { + preferredDate: '2024-03-15T12:00:00.000Z', + }; + const result = toCustomFields(data); + expect(result).toContainEqual({ id: PREFERRED_DATE_FIELD_ID, value: '2024-03-15' }); + }); + + it('should convert consent field non-empty array to true', () => { + const data = { + termsAcceptance: ['accepted'], + }; + const result = toCustomFields(data); + expect(result).toContainEqual({ id: TERMS_ACCEPTANCE_FIELD_ID, value: true }); + }); + + it('should convert consent field empty array to false', () => { + const data = { + termsAcceptance: [], + }; + const result = toCustomFields(data); + expect(result).toContainEqual({ id: TERMS_ACCEPTANCE_FIELD_ID, value: false }); + }); + + it('should convert non-consent array to JSON string', () => { + process.env.ZENDESK_DEVICE_NAME_FIELD_ID = '101'; + const data = { + machineName: ['item1', 'item2'], + }; + const result = toCustomFields(data); + expect(result).toContainEqual({ id: 101, value: '["item1","item2"]' }); + }); + + it('should convert object to JSON string', () => { + process.env.ZENDESK_ADDITIONAL_NOTES_FIELD_ID = '102'; + const data = { + additionalNotes: { key: 'value' }, + }; + const result = toCustomFields(data); + expect(result).toContainEqual({ id: 102, value: '{"key":"value"}' }); + }); + + it('should skip fields with null or undefined values', () => { + process.env.ZENDESK_EMAIL_FIELD_ID = '100'; + process.env.ZENDESK_FIRST_NAME_FIELD_ID = '103'; + const data = { + topic: 'CONTACT_US', + email: null, + firstName: undefined, + }; + const result = toCustomFields(data); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ id: TOPIC_FIELD_ID, value: 'CONTACT_US' }); + }); + + it('should skip keys without env mapping', () => { + const data = { + topic: 'CONTACT_US', + unknownField: 'value', + }; + const result = toCustomFields(data); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe(TOPIC_FIELD_ID); + }); + + it('should return empty array for empty data object', () => { + const result = toCustomFields({}); + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.mapper.spec.ts b/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.mapper.spec.ts new file mode 100644 index 000000000..f075e2794 --- /dev/null +++ b/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.mapper.spec.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { TicketObject } from '@/generated/zendesk'; + +import { mapTicketToModel } from './zendesk-ticket.mapper'; + +// Minimal shapes matching Zendesk API types used by the mapper +const TOPIC_FIELD_ID = 999; + +function createTicket(overrides: Partial = {}): TicketObject { + return { + id: 123, + requester_id: 1, + subject: 'Test subject', + description: 'Test description', + status: 'open', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + custom_fields: [{ id: TOPIC_FIELD_ID, value: 'contact_us' }], + ticket_form_id: undefined, + ...overrides, + } as TicketObject; +} + +function createComment(overrides: Record = {}) { + return { + author_id: 1, + body: 'Comment body', + created_at: '2024-01-02T10:00:00Z', + attachments: undefined, + ...overrides, + }; +} + +describe('zendesk-ticket.mapper', () => { + beforeEach(() => { + process.env.ZENDESK_TOPIC_FIELD_ID = String(TOPIC_FIELD_ID); + }); + + afterEach(() => { + delete process.env.ZENDESK_TOPIC_FIELD_ID; + delete process.env.ZENDESK_CONTACT_FORM_ID; + delete process.env.ZENDESK_TERMS_ACCEPTANCE_FIELD_ID; + }); + + describe('mapTicketToModel', () => { + it('should map complete ticket with all fields to framework model', () => { + const ticket = createTicket(); + const result = mapTicketToModel(ticket); + + expect(result.id).toBe('123'); + expect(result.createdAt).toBe('2024-01-01T00:00:00Z'); + expect(result.updatedAt).toBe('2024-01-02T00:00:00Z'); + expect(result.topic).toBe('CONTACT_US'); + expect(result.status).toBe('OPEN'); + expect(result.properties).toContainEqual({ id: 'subject', value: 'Test subject' }); + expect(result.properties).toContainEqual({ id: 'description', value: 'Test description' }); + }); + + it('should map closed and solved status to CLOSED', () => { + expect(mapTicketToModel(createTicket({ status: 'closed' })).status).toBe('CLOSED'); + expect(mapTicketToModel(createTicket({ status: 'solved' })).status).toBe('CLOSED'); + }); + + it('should map pending and hold status to IN_PROGRESS', () => { + expect(mapTicketToModel(createTicket({ status: 'pending' })).status).toBe('IN_PROGRESS'); + expect(mapTicketToModel(createTicket({ status: 'hold' })).status).toBe('IN_PROGRESS'); + }); + + it('should map new and open status to OPEN', () => { + expect(mapTicketToModel(createTicket({ status: 'new' })).status).toBe('OPEN'); + expect(mapTicketToModel(createTicket({ status: 'open' })).status).toBe('OPEN'); + }); + + it('should map unknown status to OPEN', () => { + const ticket = createTicket(); + (ticket as { status: string }).status = 'unknown'; + const result = mapTicketToModel(ticket); + expect(result.status).toBe('OPEN'); + }); + + it('should throw Error when topic field is missing', () => { + const ticket = createTicket({ custom_fields: [] }); + + expect(() => mapTicketToModel(ticket)).toThrow(/Topic field not found or empty for ticket 123/); + }); + + it('should throw Error when topic field has empty value', () => { + const ticket = createTicket({ + custom_fields: [{ id: TOPIC_FIELD_ID, value: '' }], + }); + + expect(() => mapTicketToModel(ticket)).toThrow(/Topic field not found or empty for ticket 123/); + }); + + it('should throw Error when ZENDESK_TOPIC_FIELD_ID is not set', () => { + delete process.env.ZENDESK_TOPIC_FIELD_ID; + const ticket = createTicket(); + + expect(() => mapTicketToModel(ticket)).toThrow(/Topic field not found or empty for ticket 123/); + }); + + it('should map comments with author from authorMap', () => { + process.env.ZENDESK_CONTACT_FORM_ID = '1'; + const ticket = createTicket(); + const comments = [createComment({ author_id: 10, body: 'First', created_at: '2024-01-02T10:00:00Z' })]; + const authorMap = new Map([ + [ + 10, + { + id: 10, + name: 'Agent One', + email: 'agent@example.com', + }, + ], + ]); + + const result = mapTicketToModel(ticket, comments, authorMap); + + expect(result.comments).toHaveLength(1); + expect(result.comments![0]).toEqual({ + author: { name: 'Agent One', email: 'agent@example.com' }, + date: '2024-01-02T10:00:00Z', + content: 'First', + }); + }); + + it('should use fallback when author not in authorMap', () => { + const ticket = createTicket(); + const comments = [createComment({ author_id: 99, body: 'No author' })]; + + const result = mapTicketToModel(ticket, comments); + + expect(result.comments).toHaveLength(1); + const comment = result.comments?.[0]; + expect(comment).toBeDefined(); + expect(comment!.author.name).toBe('99'); + expect(comment!.author.email).toBe(''); + expect(comment!.content).toBe('No author'); + }); + + it('should map attachments from comments', () => { + const ticket = createTicket(); + const comments = [ + createComment({ + author_id: 1, + attachments: [ + { + file_name: 'doc.pdf', + content_url: 'https://example.com/doc.pdf', + size: 1024, + }, + ], + }), + ]; + const authorMap = new Map([[1, { id: 1, name: 'User', email: 'u@ex.com' }]]); + + const result = mapTicketToModel(ticket, comments, authorMap); + + expect(result.attachments).toHaveLength(1); + expect(result.attachments![0]).toMatchObject({ + name: 'doc.pdf', + url: 'https://example.com/doc.pdf', + size: 1024, + author: { name: 'User', email: 'u@ex.com' }, + ariaLabel: 'doc.pdf', + }); + }); + + it('should have undefined comments and attachments when none provided', () => { + const ticket = createTicket(); + const result = mapTicketToModel(ticket, []); + + expect(result.comments).toBeUndefined(); + expect(result.attachments).toBeUndefined(); + }); + + it('should include consent field false for CONTACT_US form', () => { + process.env.ZENDESK_CONTACT_FORM_ID = '100'; + process.env.ZENDESK_TERMS_ACCEPTANCE_FIELD_ID = '888'; + const ticket = createTicket({ + ticket_form_id: 100, + custom_fields: [ + { id: TOPIC_FIELD_ID, value: 'CONTACT_US' }, + { id: 888, value: false }, + ] as TicketObject['custom_fields'], + }); + + const result = mapTicketToModel(ticket); + + const termsProp = result.properties?.find((p) => p.id === 'termsAcceptance'); + expect(termsProp).toBeDefined(); + expect(termsProp?.value).toBe('false'); + }); + }); +}); diff --git a/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.service.spec.ts b/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.service.spec.ts new file mode 100644 index 000000000..26e70b81f --- /dev/null +++ b/packages/integrations/zendesk/src/modules/tickets/zendesk-ticket.service.spec.ts @@ -0,0 +1,383 @@ +import type { HttpService } from '@nestjs/axios'; +import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { firstValueFrom, of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Users } from '@o2s/framework/modules'; + +import type { TicketObject } from '@/generated/zendesk'; +import { + listSearchResults, + listTicketComments, + createTicket as sdkCreateTicket, + searchUsers, + showTicket, + showUser, +} from '@/generated/zendesk'; + +import { ZendeskTicketService } from './zendesk-ticket.service'; + +// SDK response types require request/response; we only mock data in tests +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const asMock = (v: object): any => v; + +const TOPIC_FIELD_ID = 999; +const CONTACT_FORM_ID = 100; + +const minimalTicket = { + id: 1, + requester_id: 10, + subject: 'Test', + description: 'Desc', + status: 'open' as const, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + custom_fields: [{ id: TOPIC_FIELD_ID, value: 'CONTACT_US' }], +}; + +const minimalUser = { + id: 10, + name: 'User', + email: 'user@example.com', +}; + +/** Returns the first argument of the last listSearchResults call (the query object). */ +function getLastListSearchCallArg(): { query: { query: string; page?: number; per_page?: number } } { + const calls = vi.mocked(listSearchResults).mock.calls; + expect(calls.length).toBeGreaterThan(0); + return calls[calls.length - 1]![0] as { query: { query: string; page?: number; per_page?: number } }; +} + +vi.mock('@/generated/zendesk/client.gen', () => ({ + client: { setConfig: vi.fn() }, +})); + +vi.mock('@/generated/zendesk', () => ({ + showTicket: vi.fn(), + showUser: vi.fn(), + listTicketComments: vi.fn(), + listSearchResults: vi.fn(), + searchUsers: vi.fn(), + createTicket: vi.fn(), +})); + +describe('ZendeskTicketService', () => { + let service: ZendeskTicketService; + let mockUsersService: { getCurrentUser: ReturnType }; + let mockHttpService: { post: ReturnType }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env.ZENDESK_API_URL = 'https://test.zendesk.com'; + process.env.ZENDESK_API_TOKEN = 'test-token'; + process.env.ZENDESK_TOPIC_FIELD_ID = String(TOPIC_FIELD_ID); + process.env.ZENDESK_CONTACT_FORM_ID = String(CONTACT_FORM_ID); + process.env.ZENDESK_COMPLAINT_FORM_ID = '101'; + process.env.ZENDESK_REQUEST_DEVICE_MAINTENANCE_FORM_ID = '102'; + + mockUsersService = { getCurrentUser: vi.fn() }; + mockHttpService = { + post: vi.fn().mockReturnValue(of({ data: { upload: { token: 'upload-token' } } })), + }; + + service = new ZendeskTicketService( + mockUsersService as unknown as Users.Service, + mockHttpService as unknown as HttpService, + ); + }); + + describe('getTicket', () => { + it('should return mapped ticket when user email matches requester', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(showTicket).mockResolvedValue(asMock({ data: { ticket: minimalTicket } })); + vi.mocked(showUser).mockResolvedValue(asMock({ data: { user: minimalUser } })); + vi.mocked(listTicketComments).mockResolvedValue(asMock({ data: { comments: [] } })); + + const result = await firstValueFrom(service.getTicket({ id: '1' }, 'Bearer token')); + + expect(result).toBeDefined(); + expect(result?.topic).toBe('CONTACT_US'); + expect(result?.status).toBe('OPEN'); + expect(result?.id).toBe('1'); + }); + + it('should throw NotFoundException when user has no email', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: undefined })); + + await expect(firstValueFrom(service.getTicket({ id: '1' }, 'Bearer token'))).rejects.toThrow( + NotFoundException, + ); + }); + + it('should return undefined when requester email does not match user email', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(showTicket).mockResolvedValue(asMock({ data: { ticket: minimalTicket } })); + vi.mocked(showUser).mockResolvedValue( + asMock({ + data: { user: { ...minimalUser, email: 'other@example.com' } }, + }), + ); + vi.mocked(listTicketComments).mockResolvedValue(asMock({ data: { comments: [] } })); + + const result = await firstValueFrom(service.getTicket({ id: '1' }, 'Bearer token')); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when ticket has no requester_id', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(showTicket).mockResolvedValue( + asMock({ + data: { + ticket: { ...minimalTicket, requester_id: undefined } as unknown as TicketObject, + }, + }), + ); + vi.mocked(listTicketComments).mockResolvedValue(asMock({ data: { comments: [] } })); + + const result = await firstValueFrom(service.getTicket({ id: '1' }, 'Bearer token')); + + expect(result).toBeUndefined(); + }); + + it('should propagate error when showTicket fails', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(showTicket).mockRejectedValue({ status: 404 }); + + await expect(firstValueFrom(service.getTicket({ id: '999' }, 'Bearer token'))).rejects.toThrow( + /Failed to fetch ticket/, + ); + }); + + it('should map comments with authors when authorMap is built', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(showTicket).mockResolvedValue(asMock({ data: { ticket: minimalTicket } })); + vi.mocked(showUser).mockResolvedValue(asMock({ data: { user: minimalUser } })); + vi.mocked(listTicketComments).mockResolvedValue( + asMock({ + data: { + comments: [ + { + author_id: 5, + body: 'Comment text', + created_at: '2024-01-02T10:00:00Z', + }, + ], + }, + }), + ); + vi.mocked(showUser) + .mockResolvedValueOnce(asMock({ data: { user: minimalUser } })) + .mockResolvedValueOnce( + asMock({ + data: { user: { id: 5, name: 'Agent', email: 'agent@zendesk.com' } }, + }), + ); + + const result = await firstValueFrom(service.getTicket({ id: '1' }, 'Bearer token')); + + expect(result?.comments).toHaveLength(1); + const firstComment = result?.comments?.[0]; + expect(firstComment?.content).toBe('Comment text'); + expect(firstComment?.author.name).toBe('Agent'); + }); + }); + + describe('getTicketList', () => { + it('should return tickets and total when user has email', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(listSearchResults).mockResolvedValue( + asMock({ + data: { + results: [minimalTicket], + count: 1, + }, + }), + ); + + const result = await firstValueFrom(service.getTicketList({ limit: 10, offset: 0 }, 'Bearer token')); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.data[0]!.topic).toBe('CONTACT_US'); + }); + + it('should throw NotFoundException when user has no email', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: undefined })); + + await expect(firstValueFrom(service.getTicketList({ limit: 10 }, 'Bearer token'))).rejects.toThrow( + NotFoundException, + ); + }); + + it('should include status filter in search query for CLOSED', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(listSearchResults).mockClear(); + vi.mocked(listSearchResults).mockResolvedValue(asMock({ data: { results: [], count: 0 } })); + + await firstValueFrom(service.getTicketList({ limit: 10, offset: 0, status: 'CLOSED' }, 'Bearer token')); + + expect(getLastListSearchCallArg().query.query).toContain('status:solved'); + }); + + it('should include topic in search query', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(listSearchResults).mockClear(); + vi.mocked(listSearchResults).mockResolvedValue(asMock({ data: { results: [], count: 0 } })); + + await firstValueFrom(service.getTicketList({ limit: 10, offset: 0, topic: 'BILLING' }, 'Bearer token')); + + expect(getLastListSearchCallArg().query.query).toContain('tags:billing'); + }); + + it('should calculate page from offset and limit', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(listSearchResults).mockClear(); + vi.mocked(listSearchResults).mockResolvedValue(asMock({ data: { results: [], count: 0 } })); + + await firstValueFrom(service.getTicketList({ limit: 5, offset: 10 }, 'Bearer token')); + + const query = getLastListSearchCallArg().query; + expect(query.page).toBe(3); + expect(query.per_page).toBe(5); + }); + + it('should propagate error when listSearchResults fails', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(listSearchResults).mockRejectedValue(new Error('Network error')); + + await expect(firstValueFrom(service.getTicketList({ limit: 10 }, 'Bearer token'))).rejects.toThrow( + /Failed to fetch tickets/, + ); + }); + }); + + describe('createTicket', () => { + it('should return created ticket when description and type are valid', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(searchUsers).mockResolvedValue(asMock({ data: { users: [minimalUser] } })); + vi.mocked(sdkCreateTicket).mockResolvedValue( + asMock({ + data: { + ticket: { + ...minimalTicket, + id: 99, + ticket_form_id: CONTACT_FORM_ID, + }, + }, + }), + ); + + const result = await firstValueFrom( + service.createTicket( + { + description: 'Issue description', + type: CONTACT_FORM_ID, + }, + 'Bearer token', + ), + ); + + expect(result).toBeDefined(); + expect(result.id).toBe('99'); + }); + + it('should throw BadRequestException when description is missing', async () => { + await expect( + firstValueFrom(service.createTicket({ description: '', type: CONTACT_FORM_ID }, 'Bearer token')), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when type is missing', async () => { + await expect( + firstValueFrom(service.createTicket({ description: 'Desc', type: undefined }, 'Bearer token')), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException when user has no email', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: undefined })); + + await expect( + firstValueFrom(service.createTicket({ description: 'Desc', type: CONTACT_FORM_ID }, 'Bearer token')), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when type does not match configured form IDs', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + + await expect( + firstValueFrom(service.createTicket({ description: 'Desc', type: 999 }, 'Bearer token')), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw InternalServerErrorException when ZENDESK_TOPIC_FIELD_ID is not set', async () => { + delete process.env.ZENDESK_TOPIC_FIELD_ID; + service = new ZendeskTicketService( + mockUsersService as unknown as Users.Service, + mockHttpService as unknown as HttpService, + ); + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(searchUsers).mockResolvedValue(asMock({ data: { users: [minimalUser] } })); + + await expect( + firstValueFrom(service.createTicket({ description: 'Desc', type: CONTACT_FORM_ID }, 'Bearer token')), + ).rejects.toThrow(InternalServerErrorException); + }); + + it('should call HttpService.post for attachments and pass upload tokens to createTicket', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(searchUsers).mockResolvedValue(asMock({ data: { users: [minimalUser] } })); + vi.mocked(sdkCreateTicket).mockResolvedValue( + asMock({ + data: { + ticket: { + ...minimalTicket, + id: 100, + ticket_form_id: CONTACT_FORM_ID, + }, + }, + }), + ); + + await firstValueFrom( + service.createTicket( + { + description: 'Desc', + type: CONTACT_FORM_ID, + attachments: [ + { + filename: 'file.pdf', + content: Buffer.from('x'), + contentType: 'application/pdf', + }, + ], + }, + 'Bearer token', + ), + ); + + expect(mockHttpService.post).toHaveBeenCalled(); + expect(sdkCreateTicket).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + ticket: expect.objectContaining({ + comment: expect.objectContaining({ + uploads: ['upload-token'], + }), + }), + }), + }), + ); + }); + + it('should throw InternalServerErrorException when createTicket response has no ticket', async () => { + mockUsersService.getCurrentUser.mockReturnValue(of({ email: 'user@example.com' })); + vi.mocked(searchUsers).mockResolvedValue(asMock({ data: { users: [minimalUser] } })); + vi.mocked(sdkCreateTicket).mockResolvedValue(asMock({ data: {} })); + + await expect( + firstValueFrom(service.createTicket({ description: 'Desc', type: CONTACT_FORM_ID }, 'Bearer token')), + ).rejects.toThrow(InternalServerErrorException); + }); + }); +}); diff --git a/packages/integrations/zendesk/vitest.config.mjs b/packages/integrations/zendesk/vitest.config.mjs new file mode 100644 index 000000000..9660a4ea6 --- /dev/null +++ b/packages/integrations/zendesk/vitest.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/vitest-config/api'; + +export default config; From 4d922a3a7d1a6a1ad20dff1f8d9a84f1ebd5d57b Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Mon, 2 Feb 2026 11:52:09 +0100 Subject: [PATCH 04/15] feat(algolia): add Vitest support and implement unit tests for SearchService and articles mapper --- package-lock.json | 8 +- packages/integrations/algolia/package.json | 5 +- .../search/mappers/articles.mapper.spec.ts | 119 ++++++++ .../modules/search/mappers/articles.mapper.ts | 1 - .../src/modules/search/search.service.spec.ts | 284 ++++++++++++++++++ .../integrations/algolia/vitest.config.mjs | 3 + 6 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 packages/integrations/algolia/src/modules/search/mappers/articles.mapper.spec.ts create mode 100644 packages/integrations/algolia/src/modules/search/search.service.spec.ts create mode 100644 packages/integrations/algolia/vitest.config.mjs diff --git a/package-lock.json b/package-lock.json index 15eb3f769..8a88bae34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50845,11 +50845,13 @@ "@o2s/lint-staged-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "eslint": "^9.39.2", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" }, "peerDependencies": { "@nestjs/axios": "^4.0.1", @@ -51153,12 +51155,14 @@ "@o2s/eslint-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "eslint": "^9.39.2", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" } }, "packages/modules/surveyjs": { diff --git a/packages/integrations/algolia/package.json b/packages/integrations/algolia/package.json index f98d3b0a9..58b6dddf0 100644 --- a/packages/integrations/algolia/package.json +++ b/packages/integrations/algolia/package.json @@ -10,6 +10,7 @@ "dist" ], "scripts": { + "test": "vitest run", "build": "tsc --preserveWatchOutput && tsc-alias", "lint": "tsc --noEmit && eslint . --max-warnings=0", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" @@ -24,11 +25,13 @@ "@o2s/lint-staged-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "eslint": "^9.39.2", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" }, "peerDependencies": { "@nestjs/axios": "^4.0.1", diff --git a/packages/integrations/algolia/src/modules/search/mappers/articles.mapper.spec.ts b/packages/integrations/algolia/src/modules/search/mappers/articles.mapper.spec.ts new file mode 100644 index 000000000..581c409b2 --- /dev/null +++ b/packages/integrations/algolia/src/modules/search/mappers/articles.mapper.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +import { Search } from '@o2s/framework/modules'; + +import type { Model } from '../models'; + +import { mapArticlesFromSearch } from './articles.mapper'; + +function createHit(overrides: Partial = {}): Model.SearchEngineArticleModel { + return { + id: 'hit-1', + documentId: 'doc-1', + slug: 'article-slug', + updatedAt: '2024-01-02T10:00:00Z', + createdAt: '2024-01-01T00:00:00Z', + publishedAt: '2024-01-01T12:00:00Z', + SEO: { + title: 'Article title', + description: 'Article lead', + noIndex: false, + noFollow: false, + }, + ...overrides, + }; +} + +describe('articles.mapper', () => { + describe('mapArticlesFromSearch', () => { + it('should return empty data and total 0 when hits is empty', () => { + const searchResult: Search.Model.SearchResult = { + hits: [], + total: 0, + }; + + const result = mapArticlesFromSearch(searchResult); + + expect(result.data).toEqual([]); + expect(result.total).toBe(0); + }); + + it('should map single hit to article with correct fields', () => { + const hit = createHit(); + const searchResult: Search.Model.SearchResult = { + hits: [hit], + total: 1, + }; + + const result = mapArticlesFromSearch(searchResult); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.data[0]).toEqual({ + id: hit.documentId, + slug: hit.slug, + roles: [], + createdAt: hit.updatedAt, + updatedAt: hit.updatedAt, + title: hit.SEO.title, + lead: hit.SEO.description, + tags: [], + sections: [], + }); + }); + + it('should always set roles, tags, and sections to empty arrays', () => { + const hit = createHit(); + const searchResult: Search.Model.SearchResult = { + hits: [hit], + total: 1, + }; + + const result = mapArticlesFromSearch(searchResult); + + expect(result.data[0]?.roles).toEqual([]); + expect(result.data[0]?.tags).toEqual([]); + expect((result.data[0] as Record)?.sections).toEqual([]); + }); + + it('should map multiple hits with correct total', () => { + const hits = [ + createHit({ documentId: 'doc-1', slug: 'slug-1' }), + createHit({ documentId: 'doc-2', slug: 'slug-2' }), + createHit({ documentId: 'doc-3', slug: 'slug-3' }), + ]; + const searchResult: Search.Model.SearchResult = { + hits, + total: 3, + }; + + const result = mapArticlesFromSearch(searchResult); + + expect(result.data).toHaveLength(3); + expect(result.total).toBe(3); + expect(result.data[0]?.id).toBe('doc-1'); + expect(result.data[1]?.id).toBe('doc-2'); + expect(result.data[2]?.id).toBe('doc-3'); + }); + + it('should map hit with missing SEO.description to lead undefined', () => { + const hit = createHit({ + SEO: { + title: 'Title only', + description: undefined as unknown as string, + noIndex: false, + noFollow: false, + }, + }); + const searchResult: Search.Model.SearchResult = { + hits: [hit], + total: 1, + }; + + const result = mapArticlesFromSearch(searchResult); + + expect(result.data[0]?.title).toBe('Title only'); + expect(result.data[0]?.lead).toBeUndefined(); + }); + }); +}); diff --git a/packages/integrations/algolia/src/modules/search/mappers/articles.mapper.ts b/packages/integrations/algolia/src/modules/search/mappers/articles.mapper.ts index e278c72ff..d3cfb3356 100644 --- a/packages/integrations/algolia/src/modules/search/mappers/articles.mapper.ts +++ b/packages/integrations/algolia/src/modules/search/mappers/articles.mapper.ts @@ -2,7 +2,6 @@ import { Articles, Search } from '@o2s/framework/modules'; import { Model } from '../models'; -//TODO: add tests export const mapArticlesFromSearch = ( searchResult: Search.Model.SearchResult, ): Articles.Model.Articles => { diff --git a/packages/integrations/algolia/src/modules/search/search.service.spec.ts b/packages/integrations/algolia/src/modules/search/search.service.spec.ts new file mode 100644 index 000000000..b17bfd22c --- /dev/null +++ b/packages/integrations/algolia/src/modules/search/search.service.spec.ts @@ -0,0 +1,284 @@ +import { ConfigService } from '@nestjs/config'; +import { algoliasearch } from 'algoliasearch'; +import { firstValueFrom } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SearchService } from './search.service'; + +vi.mock('algoliasearch', () => ({ + algoliasearch: vi.fn(), +})); + +describe('SearchService', () => { + let service: SearchService; + let mockSearch: ReturnType; + let mockConfig: { get: ReturnType }; + let mockLogger: { debug: ReturnType; error: ReturnType }; + + beforeEach(() => { + vi.restoreAllMocks(); + mockSearch = vi.fn(); + vi.mocked(algoliasearch).mockReturnValue({ search: mockSearch } as unknown as ReturnType); + + mockConfig = { + get: vi.fn((key: string) => { + if (key === 'ALGOLIA_APP_ID') return 'test-app-id'; + if (key === 'ALGOLIA_API_KEY') return 'test-api-key'; + return undefined; + }), + }; + mockLogger = { + debug: vi.fn(), + error: vi.fn(), + }; + + service = new SearchService( + mockConfig as unknown as ConfigService, + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + ); + }); + + describe('constructor', () => { + it('should throw when ALGOLIA_APP_ID is missing', () => { + vi.mocked(mockConfig.get).mockImplementation((key: string) => { + if (key === 'ALGOLIA_APP_ID') return undefined; + if (key === 'ALGOLIA_API_KEY') return 'key'; + return undefined; + }); + + expect( + () => + new SearchService( + mockConfig as unknown as ConfigService, + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + ), + ).toThrow('Please provide a valid Algolia app ID'); + }); + + it('should throw when ALGOLIA_API_KEY is missing', () => { + vi.mocked(mockConfig.get).mockImplementation((key: string) => { + if (key === 'ALGOLIA_APP_ID') return 'app-id'; + if (key === 'ALGOLIA_API_KEY') return undefined; + return undefined; + }); + + expect( + () => + new SearchService( + mockConfig as unknown as ConfigService, + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + ), + ).toThrow('Please provide a valid Algolia API key'); + }); + + it('should create instance when both app id and api key are set', () => { + expect(service).toBeDefined(); + }); + }); + + describe('search', () => { + it('should throw when indexName is empty', () => { + expect(() => service.search('', {})).toThrow('Index name is required'); + }); + + it('should call searchClient.search with indexName and query from buildQuery', async () => { + mockSearch.mockResolvedValue({ + results: [ + { + hits: [{ id: '1' }], + nbHits: 1, + }, + ], + }); + + await firstValueFrom(service.search('articles', { query: 'test' })); + + expect(mockSearch).toHaveBeenCalledTimes(1); + const callArg = mockSearch.mock.calls[0]![0] as { requests: Array<{ indexName: string }> }; + expect(callArg.requests).toHaveLength(1); + expect(callArg.requests[0]!.indexName).toBe('articles'); + expect(callArg.requests[0]).toMatchObject({ + query: 'test', + facets: ['*'], + }); + }); + + it('should return hits and total from Algolia response', async () => { + const hits = [{ documentId: 'doc-1', slug: 'slug-1' }]; + mockSearch.mockResolvedValue({ + results: [ + { + hits, + nbHits: 1, + page: 0, + nbPages: 1, + processingTimeMS: 2, + }, + ], + }); + + const result = await firstValueFrom(service.search<{ documentId: string }>('articles', {})); + + expect(result.hits).toEqual(hits); + expect(result.total).toBe(1); + expect(result.page).toBe(0); + expect(result.nbPages).toBe(1); + expect(result.processingTimeMS).toBe(2); + }); + + it('should return empty result when results[0] is missing', async () => { + mockSearch.mockResolvedValue({ results: [] }); + + const result = await firstValueFrom(service.search('articles', {})); + + expect(result.hits).toEqual([]); + expect(result.total).toBe(0); + }); + + it('should return empty result when results[0] has facetHits', async () => { + mockSearch.mockResolvedValue({ + results: [{ facetHits: [], nbHits: 0 }], + }); + + const result = await firstValueFrom(service.search('articles', {})); + + expect(result.hits).toEqual([]); + expect(result.total).toBe(0); + }); + + it('should return empty result on ApiError 404', async () => { + mockSearch.mockRejectedValue({ name: 'ApiError', status: 404 }); + + const result = await firstValueFrom(service.search('articles', {})); + + expect(result.hits).toEqual([]); + expect(result.total).toBe(0); + }); + + it('should rethrow on non-ApiError', async () => { + mockSearch.mockRejectedValue(new Error('Network error')); + + await expect(firstValueFrom(service.search('articles', {}))).rejects.toThrow('Network error'); + }); + + it('should return empty result on ApiError with status other than 404', async () => { + mockSearch.mockRejectedValue({ name: 'ApiError', status: 500 }); + + const result = await firstValueFrom(service.search('articles', {})); + + expect(result.hits).toEqual([]); + expect(result.total).toBe(0); + }); + }); + + describe('search – buildQuery via search call', () => { + it('should pass query in request', async () => { + mockSearch.mockResolvedValue({ results: [{ hits: [], nbHits: 0 }] }); + + await firstValueFrom(service.search('idx', { query: 'hello' })); + + const req = (mockSearch.mock.calls[0]![0] as { requests: Array> }).requests[0]; + expect(req?.query).toBe('hello'); + }); + + it('should pass locale as facetFilters', async () => { + mockSearch.mockResolvedValue({ results: [{ hits: [], nbHits: 0 }] }); + + await firstValueFrom(service.search('idx', { locale: 'en' })); + + const req = (mockSearch.mock.calls[0]![0] as { requests: Array> }).requests[0]; + expect(req?.facetFilters).toContainEqual('locale:en'); + }); + + it('should pass exact as facetFilters (single value)', async () => { + mockSearch.mockResolvedValue({ results: [{ hits: [], nbHits: 0 }] }); + + await firstValueFrom(service.search('idx', { exact: { category: 'news' } })); + + const req = (mockSearch.mock.calls[0]![0] as { requests: Array> }).requests[0]; + expect(req?.facetFilters).toContainEqual('category:news'); + }); + + it('should pass exact as facetFilters (array value)', async () => { + mockSearch.mockResolvedValue({ results: [{ hits: [], nbHits: 0 }] }); + + await firstValueFrom(service.search('idx', { exact: { tags: ['a', 'b'] } })); + + const req = (mockSearch.mock.calls[0]![0] as { requests: Array> }).requests[0]; + expect(req?.facetFilters).toContainEqual(['tags:a', 'tags:b']); + }); + + it('should pass range as numericFilters', async () => { + mockSearch.mockResolvedValue({ results: [{ hits: [], nbHits: 0 }] }); + + await firstValueFrom( + service.search('idx', { + range: { year: { min: 2020, max: 2024 } }, + }), + ); + + const req = (mockSearch.mock.calls[0]![0] as { requests: Array> }).requests[0]; + expect(req?.numericFilters).toContainEqual('year >= 2020'); + expect(req?.numericFilters).toContainEqual('year <= 2024'); + }); + + it('should pass pagination limit and offset as hitsPerPage and page', async () => { + mockSearch.mockResolvedValue({ results: [{ hits: [], nbHits: 0 }] }); + + await firstValueFrom( + service.search('idx', { + pagination: { limit: 10, offset: 20 }, + }), + ); + + const req = (mockSearch.mock.calls[0]![0] as { requests: Array> }).requests[0]; + expect(req?.hitsPerPage).toBe(10); + expect(req?.page).toBe(2); + }); + + it('should append sort field and order to indexName', async () => { + mockSearch.mockResolvedValue({ results: [{ hits: [], nbHits: 0 }] }); + + await firstValueFrom( + service.search('articles', { + sort: [{ field: 'updatedAt', order: 'desc' }], + }), + ); + + const callArg = mockSearch.mock.calls[0]![0] as { requests: Array<{ indexName: string }> }; + expect(callArg.requests[0]!.indexName).toBe('articles_updatedAt_desc'); + }); + }); + + describe('searchArticles', () => { + it('should return mapped articles from search result', async () => { + const hits = [ + { + documentId: 'doc-1', + slug: 'article-1', + updatedAt: '2024-01-02T10:00:00Z', + SEO: { title: 'Title 1', description: 'Lead 1', noIndex: false, noFollow: false }, + }, + ]; + mockSearch.mockResolvedValue({ + results: [{ hits, nbHits: 1 }], + }); + + const result = await firstValueFrom(service.searchArticles('articles', { query: 'test' })); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.data[0]).toMatchObject({ + id: 'doc-1', + slug: 'article-1', + title: 'Title 1', + lead: 'Lead 1', + createdAt: '2024-01-02T10:00:00Z', + updatedAt: '2024-01-02T10:00:00Z', + }); + expect(result.data[0]?.roles).toEqual([]); + expect(result.data[0]?.tags).toEqual([]); + expect((result.data[0] as Record)?.sections).toEqual([]); + }); + }); +}); diff --git a/packages/integrations/algolia/vitest.config.mjs b/packages/integrations/algolia/vitest.config.mjs new file mode 100644 index 000000000..9660a4ea6 --- /dev/null +++ b/packages/integrations/algolia/vitest.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/vitest-config/api'; + +export default config; From 62e094866535470ffa51f9463f63469d70e300d2 Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Mon, 2 Feb 2026 13:13:35 +0100 Subject: [PATCH 05/15] feat(redis): add Vitest support and implement unit tests for RedisCacheService --- packages/integrations/redis/package.json | 5 +- .../src/modules/cache/cache.service.spec.ts | 224 ++++++++++++++++++ .../redis/src/modules/cache/cache.service.ts | 2 +- packages/integrations/redis/vitest.config.mjs | 3 + 4 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 packages/integrations/redis/src/modules/cache/cache.service.spec.ts create mode 100644 packages/integrations/redis/vitest.config.mjs diff --git a/packages/integrations/redis/package.json b/packages/integrations/redis/package.json index 355a6e683..fd733b743 100644 --- a/packages/integrations/redis/package.json +++ b/packages/integrations/redis/package.json @@ -11,6 +11,7 @@ "dist" ], "scripts": { + "test": "vitest run", "build": "tsc --preserveWatchOutput && tsc-alias", "lint": "tsc --noEmit && eslint . --max-warnings=0", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" @@ -25,11 +26,13 @@ "@o2s/lint-staged-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "eslint": "^9.39.2", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" }, "peerDependencies": { "@nestjs/axios": "^4.0.1", diff --git a/packages/integrations/redis/src/modules/cache/cache.service.spec.ts b/packages/integrations/redis/src/modules/cache/cache.service.spec.ts new file mode 100644 index 000000000..e030491d2 --- /dev/null +++ b/packages/integrations/redis/src/modules/cache/cache.service.spec.ts @@ -0,0 +1,224 @@ +import { ConfigService } from '@nestjs/config'; +import { createClient } from 'redis'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RedisCacheService } from './cache.service'; + +vi.mock('redis', () => ({ + createClient: vi.fn(), +})); + +function defaultConfig(key: string): string | number | undefined { + if (key === 'CACHE_ENABLED') return 'true'; + if (key === 'CACHE_TTL') return 300; + if (key === 'CACHE_REDIS_HOST') return 'localhost'; + if (key === 'CACHE_REDIS_PORT') return '6379'; + if (key === 'CACHE_REDIS_PASS') return undefined; + return undefined; +} + +describe('RedisCacheService', () => { + let service: RedisCacheService; + let mockClient: { + connect: ReturnType; + on: ReturnType; + get: ReturnType; + set: ReturnType; + del: ReturnType; + isReady: boolean; + }; + let mockConfig: { get: ReturnType }; + let mockLogger: { log: ReturnType }; + + beforeEach(() => { + vi.restoreAllMocks(); + mockClient = { + connect: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + isReady: true, + }; + vi.mocked(createClient).mockReturnValue(mockClient as unknown as ReturnType); + + mockConfig = { + get: vi.fn((key: string) => defaultConfig(key)), + }; + mockLogger = { + log: vi.fn(), + }; + + service = new RedisCacheService( + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockConfig as unknown as ConfigService, + ); + }); + + describe('constructor', () => { + it('should not call createClient when CACHE_ENABLED is not "true"', () => { + vi.mocked(mockConfig.get).mockImplementation((key: string) => + key === 'CACHE_ENABLED' ? 'false' : defaultConfig(key), + ); + vi.mocked(createClient).mockClear(); + + new RedisCacheService( + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockConfig as unknown as ConfigService, + ); + + expect(createClient).not.toHaveBeenCalled(); + }); + + it('should call createClient with url, password, and socket when CACHE_ENABLED is "true"', () => { + vi.mocked(createClient).mockClear(); + vi.mocked(mockConfig.get).mockImplementation((key: string) => defaultConfig(key)); + + new RedisCacheService( + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockConfig as unknown as ConfigService, + ); + + expect(createClient).toHaveBeenCalledTimes(1); + expect(createClient).toHaveBeenCalledWith({ + url: 'redis://localhost:6379', + password: undefined, + socket: { + reconnectStrategy: expect.any(Function), + }, + }); + }); + + it('should register on("error"), on("connect"), on("ready") and call connect when enabled', () => { + vi.mocked(createClient).mockClear(); + vi.mocked(mockConfig.get).mockImplementation((key: string) => defaultConfig(key)); + + new RedisCacheService( + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockConfig as unknown as ConfigService, + ); + + expect(mockClient.on).toHaveBeenCalledWith('error', expect.any(Function)); + expect(mockClient.on).toHaveBeenCalledWith('connect', expect.any(Function)); + expect(mockClient.on).toHaveBeenCalledWith('ready', expect.any(Function)); + expect(mockClient.connect).toHaveBeenCalled(); + }); + }); + + describe('get', () => { + it('should return undefined and not call client.get when cache is disabled', async () => { + vi.mocked(mockConfig.get).mockImplementation((key: string) => + key === 'CACHE_ENABLED' ? 'false' : defaultConfig(key), + ); + const disabledService = new RedisCacheService( + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockConfig as unknown as ConfigService, + ); + + const result = await disabledService.get('key'); + + expect(result).toBeUndefined(); + expect(mockClient.get).not.toHaveBeenCalled(); + }); + + it('should return undefined and not call client.get when client is not ready', async () => { + mockClient.isReady = false; + + const result = await service.get('key'); + + expect(result).toBeUndefined(); + expect(mockClient.get).not.toHaveBeenCalled(); + }); + + it('should call client.get and return value when enabled and ready', async () => { + mockClient.get.mockResolvedValue('cached-value'); + + const result = await service.get('my-key'); + + expect(mockClient.get).toHaveBeenCalledWith('my-key'); + expect(result).toBe('cached-value'); + }); + + it('should return undefined when client.get returns null', async () => { + mockClient.get.mockResolvedValue(null); + + const result = await service.get('key'); + + expect(result).toBeUndefined(); + }); + }); + + describe('set', () => { + it('should not call client.set when cache is disabled', async () => { + vi.mocked(mockConfig.get).mockImplementation((key: string) => + key === 'CACHE_ENABLED' ? 'false' : defaultConfig(key), + ); + const disabledService = new RedisCacheService( + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockConfig as unknown as ConfigService, + ); + + await disabledService.set('key', 'value'); + + expect(mockClient.set).not.toHaveBeenCalled(); + }); + + it('should not call client.set when client is not ready', async () => { + mockClient.isReady = false; + + await service.set('key', 'value'); + + expect(mockClient.set).not.toHaveBeenCalled(); + }); + + it('should call client.set with key, value and EX when enabled and ready', async () => { + await service.set('key', 'value'); + + expect(mockClient.set).toHaveBeenCalledWith('key', 'value', { EX: 300 }); + }); + + it('should use CACHE_TTL from config for EX', async () => { + vi.mocked(mockConfig.get).mockImplementation((key: string) => + key === 'CACHE_TTL' ? 600 : defaultConfig(key), + ); + const serviceWithTtl = new RedisCacheService( + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockConfig as unknown as ConfigService, + ); + + await serviceWithTtl.set('k', 'v'); + + expect(mockClient.set).toHaveBeenCalledWith('k', 'v', { EX: 600 }); + }); + }); + + describe('del', () => { + it('should not call client.del when cache is disabled', async () => { + vi.mocked(mockConfig.get).mockImplementation((key: string) => + key === 'CACHE_ENABLED' ? 'false' : defaultConfig(key), + ); + const disabledService = new RedisCacheService( + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockConfig as unknown as ConfigService, + ); + + await disabledService.del('key'); + + expect(mockClient.del).not.toHaveBeenCalled(); + }); + + it('should not call client.del when client is not ready', async () => { + mockClient.isReady = false; + + await service.del('key'); + + expect(mockClient.del).not.toHaveBeenCalled(); + }); + + it('should call client.del with key when enabled and ready', async () => { + await service.del('key-to-delete'); + + expect(mockClient.del).toHaveBeenCalledWith('key-to-delete'); + }); + }); +}); diff --git a/packages/integrations/redis/src/modules/cache/cache.service.ts b/packages/integrations/redis/src/modules/cache/cache.service.ts index 14cdfb1ac..38fd24be5 100644 --- a/packages/integrations/redis/src/modules/cache/cache.service.ts +++ b/packages/integrations/redis/src/modules/cache/cache.service.ts @@ -77,7 +77,7 @@ export class RedisCacheService implements Cache.Service { async get(key: string): Promise { if (this.isEnabled && this.client.isReady) { const result = await this.client.get(key); - return result as string | undefined; + return result ?? undefined; } return undefined; } diff --git a/packages/integrations/redis/vitest.config.mjs b/packages/integrations/redis/vitest.config.mjs new file mode 100644 index 000000000..9660a4ea6 --- /dev/null +++ b/packages/integrations/redis/vitest.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@o2s/vitest-config/api'; + +export default config; From 3222fc8bb39601b943062f56c3a54e6253b0b80f Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 2 Feb 2026 15:56:49 +0100 Subject: [PATCH 06/15] chore(deps): updated lock file --- package-lock.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index e41b0e983..c19622756 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51092,11 +51092,13 @@ "@o2s/lint-staged-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "eslint": "^9.39.2", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" }, "peerDependencies": { "@nestjs/axios": "^4.0.1", From 0e06bf205cddb9dc30cb3d63f09c5f73ea238f72 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 2 Feb 2026 16:44:02 +0100 Subject: [PATCH 07/15] chore(deps): enable test coverage and update lock file with new dependencies --- .github/actions/test/action.yaml | 41 + package-lock.json | 953 +++++++++++++++++- package.json | 1 + packages/configs/vitest-config/api.js | 4 + packages/configs/vitest-config/block.js | 4 + packages/configs/vitest-config/package.json | 14 +- .../scripts/collect-json-outputs.mjs | 87 ++ packages/configs/vitest-config/turbo.json | 22 + turbo.json | 2 +- vitest.config.ts | 4 + 10 files changed, 1118 insertions(+), 14 deletions(-) create mode 100644 packages/configs/vitest-config/scripts/collect-json-outputs.mjs create mode 100644 packages/configs/vitest-config/turbo.json diff --git a/.github/actions/test/action.yaml b/.github/actions/test/action.yaml index c82b235b1..aa30c3ba6 100644 --- a/.github/actions/test/action.yaml +++ b/.github/actions/test/action.yaml @@ -44,3 +44,44 @@ runs: - name: Test shell: bash run: npm run test + + - name: Collect coverage + shell: bash + run: npx turbo run collect-json-reports --filter=@o2s/vitest-config + + - name: Check for coverage + id: check-coverage + shell: bash + run: | + if ls packages/configs/vitest-config/coverage/raw/*.json 1>/dev/null 2>&1; then + echo "has_coverage=true" >> $GITHUB_OUTPUT + else + echo "has_coverage=false" >> $GITHUB_OUTPUT + fi + + - name: Merge coverage and generate report + if: steps.check-coverage.outputs.has_coverage == 'true' + shell: bash + run: npx turbo run merge-json-reports report --filter=@o2s/vitest-config + + - name: Report summary + if: steps.check-coverage.outputs.has_coverage == 'true' + shell: bash + run: npm run report:summary --workspace=@o2s/vitest-config + + - name: Upload coverage report + if: steps.check-coverage.outputs.has_coverage == 'true' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: packages/configs/vitest-config/coverage/report + retention-days: 14 + + - name: Vitest coverage report + if: steps.check-coverage.outputs.has_coverage == 'true' + uses: davelosert/vitest-coverage-report-action@v2 + with: + working-directory: packages/configs/vitest-config + json-summary-path: coverage/coverage-summary.json + json-final-path: coverage/merged/merged-coverage.json + github-token: ${{ inputs.repo-token }} diff --git a/package-lock.json b/package-lock.json index c19622756..eef0bc853 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21396,6 +21396,19 @@ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -21686,6 +21699,19 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-require-extensions": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/arch": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/arch/-/arch-3.0.0.tgz", @@ -21707,6 +21733,13 @@ ], "license": "MIT" }, + "node_modules/archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true, + "license": "MIT" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -23152,6 +23185,61 @@ "node": ">=14.16" } }, + "node_modules/caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/caching-transform/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caching-transform/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/caching-transform/node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -24234,6 +24322,13 @@ "node": ">=4.0.0" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -26122,6 +26217,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/default-require-extensions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", + "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "strip-bom": "^4.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-require-extensions/node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -27095,6 +27216,13 @@ "benchmarks" ] }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT" + }, "node_modules/es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", @@ -28071,6 +28199,16 @@ "node": ">= 0.8" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -28796,6 +28934,50 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-cache-dir/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -29181,6 +29363,27 @@ "node": ">= 0.8" } }, + "node_modules/fromentries": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", + "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/fs-extra": { "version": "11.3.3", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", @@ -29346,6 +29549,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stdin": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-9.0.0.tgz", + "integrity": "sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -29929,6 +30145,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasha/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -31823,6 +32066,19 @@ "node": ">=8" } }, + "node_modules/istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "append-transform": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/istanbul-lib-instrument": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", @@ -31840,6 +32096,41 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-processinfo": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", + "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", + "dev": true, + "license": "ISC", + "dependencies": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.3", + "istanbul-lib-coverage": "^3.2.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -34829,6 +35120,13 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, + "node_modules/lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -38309,6 +38607,19 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "process-on-spawn": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -38402,20 +38713,249 @@ "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", "dev": true }, - "node_modules/nypm": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", - "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", - "license": "MIT", + "node_modules/nyc": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.1.0.tgz", + "integrity": "sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==", + "dev": true, + "license": "ISC", "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.2", - "pathe": "^2.0.3", - "pkg-types": "^2.3.0", - "tinyexec": "^1.0.1" + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^3.3.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^6.0.2", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" }, "bin": { - "nypm": "dist/cli.mjs" + "nyc": "bin/nyc.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nyc/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/nyc/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/nyc/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nyc/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nyc/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/nyc/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nyc/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nypm": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" }, "engines": { "node": "^14.16.0 || >=16.10.0" @@ -38644,6 +39184,115 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open-cli": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/open-cli/-/open-cli-8.0.0.tgz", + "integrity": "sha512-3muD3BbfLyzl+aMVSEfn2FfOqGdPYR0O4KNnxXsLEPE2q9OSjBfJAaB6XKbrUzLgymoSMejvb5jpXJfru/Ko2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^18.7.0", + "get-stdin": "^9.0.0", + "meow": "^12.1.1", + "open": "^10.0.0", + "tempy": "^3.1.0" + }, + "bin": { + "open-cli": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open-cli/node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open-cli/node_modules/file-type": { + "version": "18.7.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.7.0.tgz", + "integrity": "sha512-ihHtXRzXEziMrQ56VSgU7wkxh55iNchFkosu7Y9/S+tXHdKyrGjVK0ujbqNnsxzea+78MaLhN6PGmfYSAv1ACw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.2", + "strtok3": "^7.0.0", + "token-types": "^5.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/open-cli/node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open-cli/node_modules/strtok3": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.1.1.tgz", + "integrity": "sha512-mKX8HA/cdBqMKUr0MMZAFssCkIGoZeSCMXgnt79yKxNFguMLVFgRe6wB+fsL0NmoHDbeyZXczy7vEPSoo3rkzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^5.1.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/open-cli/node_modules/token-types": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz", + "integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -38897,6 +39546,22 @@ "node": ">= 14" } }, + "node_modules/package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/package-json": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz", @@ -39237,6 +39902,20 @@ "node": ">= 14.16" } }, + "node_modules/peek-readable": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.4.2.tgz", + "integrity": "sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -41049,6 +41728,19 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/process-on-spawn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.1.0.tgz", + "integrity": "sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fromentries": "^1.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/promise": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", @@ -41780,6 +42472,65 @@ "node": ">= 6" } }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -42157,6 +42908,19 @@ "invariant": "^2.2.4" } }, + "node_modules/release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", + "dev": true, + "license": "ISC", + "dependencies": { + "es6-error": "^4.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/remark-directive": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.0.tgz", @@ -44041,6 +44805,81 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/spawn-wrap/node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/spawn-wrap/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/spawn-wrap/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/spawn-wrap/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/spawndamnit": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", @@ -45218,6 +46057,61 @@ "streamx": "^2.15.0" } }, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tempy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.2.tgz", + "integrity": "sha512-pD3+21EbFZFBKDnVztX32wU6IBwkalOduWdx1OKvB5y6y1f2Xn8HU/U6o9EmlfdSyUYe9IybirmYPj/7rilA6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -50738,10 +51632,47 @@ "name": "@o2s/vitest-config", "version": "1.1.0", "devDependencies": { + "glob": "13.0.0", + "nyc": "^17.1.0", + "open-cli": "^8.0.0", "unplugin-swc": "^1.5.9", "vitest": "^4.0.18" } }, + "packages/configs/vitest-config/node_modules/glob": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/configs/vitest-config/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "packages/create-o2s-app": { "version": "1.0.4", "extraneous": true, diff --git a/package.json b/package.json index b43d98755..daff1ca9e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "format": "turbo format", "lint": "turbo lint -- --max-warnings=0", "test": "turbo test && vitest run --project=storybook", + "view-report": "turbo run view-report", "generate": "turbo gen", "eject-block": "ts-node ./scripts/cli.ts eject-block", "docs": "turbo start --filter=@o2s/docs", diff --git a/packages/configs/vitest-config/api.js b/packages/configs/vitest-config/api.js index 82a7c7b61..b7137f64d 100644 --- a/packages/configs/vitest-config/api.js +++ b/packages/configs/vitest-config/api.js @@ -8,6 +8,10 @@ export const config = defineConfig({ environment: 'node', passWithNoTests: true, include: ['src/**/*.spec.ts'], + coverage: { + enabled: true, + provider: 'v8', // or 'istanbul' + }, }, plugins: [ swc.vite({ diff --git a/packages/configs/vitest-config/block.js b/packages/configs/vitest-config/block.js index 24cc20c6e..b99d1f507 100644 --- a/packages/configs/vitest-config/block.js +++ b/packages/configs/vitest-config/block.js @@ -11,6 +11,10 @@ export const config = defineConfig({ environment: 'node', passWithNoTests: true, exclude: ['**/node_modules/**', '**/dist/**', '**/.{idea,git,cache,output,temp}/**'], + coverage: { + enabled: true, + provider: 'v8', // or 'istanbul' + }, }, plugins: [ swc.vite({ diff --git a/packages/configs/vitest-config/package.json b/packages/configs/vitest-config/package.json index b5376819f..f0c064e44 100644 --- a/packages/configs/vitest-config/package.json +++ b/packages/configs/vitest-config/package.json @@ -7,8 +7,18 @@ "./block": "./block.js", "./api": "./api.js" }, + "scripts": { + "collect-json-reports": "tsx scripts/collect-json-outputs.mjs", + "merge-json-reports": "nyc merge coverage/raw coverage/merged/merged-coverage.json", + "report": "nyc report -t coverage/merged --report-dir coverage/report --reporter=html --exclude-after-remap false", + "report:summary": "nyc report -t coverage/merged --reporter=json-summary --report-dir coverage", + "view-report": "open-cli coverage/report/index.html" + }, "devDependencies": { - "vitest": "^4.0.18", - "unplugin-swc": "^1.5.9" + "glob": "13.0.0", + "nyc": "^17.1.0", + "open-cli": "^8.0.0", + "unplugin-swc": "^1.5.9", + "vitest": "^4.0.18" } } diff --git a/packages/configs/vitest-config/scripts/collect-json-outputs.mjs b/packages/configs/vitest-config/scripts/collect-json-outputs.mjs new file mode 100644 index 000000000..ae7ee2c0d --- /dev/null +++ b/packages/configs/vitest-config/scripts/collect-json-outputs.mjs @@ -0,0 +1,87 @@ +import { glob } from 'glob'; +import path from 'path'; + +import fs from 'fs/promises'; + +async function collectCoverageFiles() { + try { + // Define the patterns to search + const patterns = ['../../../apps/**', '../../../packages/**']; + + // Define the destination directory (you can change this as needed) + const destinationDir = path.join(process.cwd(), 'coverage/raw'); + + // Create the destination directory if it doesn't exist + await fs.mkdir(destinationDir, { recursive: true }); + + // Arrays to collect all directories and directories with coverage + const allDirectories = []; + const directoriesWithCoverage = []; + + // Vitest coverage provider: + // - istanbul provider often writes: /coverage.json + // - v8 provider typically writes: /coverage/coverage-final.json + const candidateCoverageFiles = [ + // v8 provider (preferred) + path.join('coverage', 'coverage-final.json'), + // fallback for older/other setups + 'coverage.json', + ]; + + // Process each pattern + for (const pattern of patterns) { + // Find all paths matching the pattern + const matches = await glob(pattern); + + // Filter to only include directories + for (const match of matches) { + const stats = await fs.stat(match); + + if (stats.isDirectory()) { + allDirectories.push(match); + + // Pick the first existing coverage file (v8 first, then fallback) + let coverageFilePath = null; + for (const relPath of candidateCoverageFiles) { + const fullPath = path.join(match, relPath); + try { + await fs.access(fullPath); + coverageFilePath = fullPath; + break; + } catch { + // try next candidate + } + } + + if (!coverageFilePath) continue; + + // File exists, add to list of directories with coverage + directoriesWithCoverage.push(match); + + // Copy it to the destination with a unique name + const directoryName = path.basename(match); + const destinationFile = path.join(destinationDir, `${directoryName}.json`); + + await fs.copyFile(coverageFilePath, destinationFile); + } + } + } + + // Create clean patterns for display (without any "../" prefixes) + const replaceDotPatterns = (str) => str.replace(/\.\.\//g, ''); + + if (directoriesWithCoverage.length > 0) { + console.log(`Found coverage in: ${directoriesWithCoverage.map(replaceDotPatterns).join(', ')}`); + } else { + console.log('No coverage files found (looked for coverage/coverage-final.json and coverage.json).'); + } + + console.log(`Coverage collected into: ${destinationDir}`); + } catch (error) { + console.error('Error collecting coverage files:', error); + process.exitCode = 1; + } +} + +// Run the function +collectCoverageFiles(); diff --git a/packages/configs/vitest-config/turbo.json b/packages/configs/vitest-config/turbo.json new file mode 100644 index 000000000..9cb524bde --- /dev/null +++ b/packages/configs/vitest-config/turbo.json @@ -0,0 +1,22 @@ +{ + "extends": ["//"], + "tasks": { + "collect-json-reports": { + "cache": false + }, + "merge-json-reports": { + "dependsOn": ["collect-json-reports"], + "inputs": ["coverage/raw/**"], + "outputs": ["coverage/merged/**"] + }, + "report": { + "dependsOn": ["merge-json-reports"], + "inputs": ["coverage/merge"], + "outputs": ["coverage/report/**"] + }, + "view-report": { + "dependsOn": ["report"], + "cache": false + } + } +} diff --git a/turbo.json b/turbo.json index 83eada09c..8ad43ea66 100644 --- a/turbo.json +++ b/turbo.json @@ -22,7 +22,7 @@ "dependsOn": ["^lint"] }, "test": { - "cache": true, + "cache": false, "outputLogs": "new-only", "dependsOn": ["^test"] }, diff --git a/vitest.config.ts b/vitest.config.ts index cbe40a494..31ed55351 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,10 @@ const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(file // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ test: { + coverage: { + enabled: true, + provider: 'v8', // or 'istanbul' + }, projects: [ { extends: true, From c3b647de1076315c38ee9a04e3964c6ee348218d Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 2 Feb 2026 17:27:16 +0100 Subject: [PATCH 08/15] chore(ci): configure additional coverage reporters and optimize coverage collection script --- .github/actions/test/action.yaml | 1 + packages/configs/vitest-config/api.js | 3 +- packages/configs/vitest-config/block.js | 3 +- .../scripts/collect-json-outputs.mjs | 122 +++++++++++------- turbo.json | 2 +- vitest.config.ts | 3 +- 6 files changed, 84 insertions(+), 50 deletions(-) diff --git a/.github/actions/test/action.yaml b/.github/actions/test/action.yaml index aa30c3ba6..1f14f8b0c 100644 --- a/.github/actions/test/action.yaml +++ b/.github/actions/test/action.yaml @@ -82,6 +82,7 @@ runs: uses: davelosert/vitest-coverage-report-action@v2 with: working-directory: packages/configs/vitest-config + vite-config-path: api.js json-summary-path: coverage/coverage-summary.json json-final-path: coverage/merged/merged-coverage.json github-token: ${{ inputs.repo-token }} diff --git a/packages/configs/vitest-config/api.js b/packages/configs/vitest-config/api.js index b7137f64d..21bb93e0d 100644 --- a/packages/configs/vitest-config/api.js +++ b/packages/configs/vitest-config/api.js @@ -10,7 +10,8 @@ export const config = defineConfig({ include: ['src/**/*.spec.ts'], coverage: { enabled: true, - provider: 'v8', // or 'istanbul' + provider: 'v8', + reporter: ['text-summary', 'html', 'json-summary', 'json'], }, }, plugins: [ diff --git a/packages/configs/vitest-config/block.js b/packages/configs/vitest-config/block.js index b99d1f507..3f2ecbbc3 100644 --- a/packages/configs/vitest-config/block.js +++ b/packages/configs/vitest-config/block.js @@ -13,7 +13,8 @@ export const config = defineConfig({ exclude: ['**/node_modules/**', '**/dist/**', '**/.{idea,git,cache,output,temp}/**'], coverage: { enabled: true, - provider: 'v8', // or 'istanbul' + provider: 'v8', + reporter: ['text-summary', 'html', 'json-summary', 'json'], }, }, plugins: [ diff --git a/packages/configs/vitest-config/scripts/collect-json-outputs.mjs b/packages/configs/vitest-config/scripts/collect-json-outputs.mjs index ae7ee2c0d..ab60a432b 100644 --- a/packages/configs/vitest-config/scripts/collect-json-outputs.mjs +++ b/packages/configs/vitest-config/scripts/collect-json-outputs.mjs @@ -3,85 +3,115 @@ import path from 'path'; import fs from 'fs/promises'; +const CANDIDATE_COVERAGE_FINAL = [ + path.join('coverage', 'coverage-final.json'), + 'coverage.json', +]; + +const COVERAGE_SUMMARY_PATH = path.join('coverage', 'coverage-summary.json'); + +const METRIC_KEYS = ['lines', 'statements', 'functions', 'branches', 'branchesTrue']; + +function aggregateTotals(summaries) { + const total = {}; + for (const key of METRIC_KEYS) { + total[key] = { total: 0, covered: 0, skipped: 0 }; + } + for (const summary of summaries) { + const t = summary.total; + if (!t) continue; + for (const key of METRIC_KEYS) { + const m = t[key]; + if (m) { + total[key].total += m.total ?? 0; + total[key].covered += m.covered ?? 0; + total[key].skipped += m.skipped ?? 0; + } + } + } + for (const key of METRIC_KEYS) { + const m = total[key]; + m.pct = m.total > 0 ? Math.round((m.covered / m.total) * 100 * 100) / 100 : 100; + } + return total; +} + async function collectCoverageFiles() { try { - // Define the patterns to search const patterns = ['../../../apps/**', '../../../packages/**']; - - // Define the destination directory (you can change this as needed) const destinationDir = path.join(process.cwd(), 'coverage/raw'); + const summaryOutPath = path.join(process.cwd(), 'coverage/coverage-summary.json'); - // Create the destination directory if it doesn't exist await fs.mkdir(destinationDir, { recursive: true }); + await fs.mkdir(path.dirname(summaryOutPath), { recursive: true }); - // Arrays to collect all directories and directories with coverage - const allDirectories = []; const directoriesWithCoverage = []; + const summaryFiles = []; - // Vitest coverage provider: - // - istanbul provider often writes: /coverage.json - // - v8 provider typically writes: /coverage/coverage-final.json - const candidateCoverageFiles = [ - // v8 provider (preferred) - path.join('coverage', 'coverage-final.json'), - // fallback for older/other setups - 'coverage.json', - ]; - - // Process each pattern for (const pattern of patterns) { - // Find all paths matching the pattern const matches = await glob(pattern); - - // Filter to only include directories for (const match of matches) { const stats = await fs.stat(match); - - if (stats.isDirectory()) { - allDirectories.push(match); - - // Pick the first existing coverage file (v8 first, then fallback) - let coverageFilePath = null; - for (const relPath of candidateCoverageFiles) { - const fullPath = path.join(match, relPath); - try { - await fs.access(fullPath); - coverageFilePath = fullPath; - break; - } catch { - // try next candidate - } + if (!stats.isDirectory()) continue; + + let coverageFilePath = null; + for (const relPath of CANDIDATE_COVERAGE_FINAL) { + const fullPath = path.join(match, relPath); + try { + await fs.access(fullPath); + coverageFilePath = fullPath; + break; + } catch { + // try next candidate } + } - if (!coverageFilePath) continue; + if (!coverageFilePath) continue; - // File exists, add to list of directories with coverage - directoriesWithCoverage.push(match); + directoriesWithCoverage.push(match); - // Copy it to the destination with a unique name - const directoryName = path.basename(match); - const destinationFile = path.join(destinationDir, `${directoryName}.json`); + const directoryName = path.basename(match); + const destinationFile = path.join(destinationDir, `${directoryName}.json`); + await fs.copyFile(coverageFilePath, destinationFile); - await fs.copyFile(coverageFilePath, destinationFile); + const summaryPath = path.join(match, COVERAGE_SUMMARY_PATH); + try { + await fs.access(summaryPath); + summaryFiles.push(summaryPath); + } catch { + // no summary in this package } } } - // Create clean patterns for display (without any "../" prefixes) const replaceDotPatterns = (str) => str.replace(/\.\.\//g, ''); - if (directoriesWithCoverage.length > 0) { console.log(`Found coverage in: ${directoriesWithCoverage.map(replaceDotPatterns).join(', ')}`); } else { console.log('No coverage files found (looked for coverage/coverage-final.json and coverage.json).'); } - console.log(`Coverage collected into: ${destinationDir}`); + + if (summaryFiles.length > 0) { + const summaries = await Promise.all( + summaryFiles.map((p) => fs.readFile(p, 'utf8').then(JSON.parse)), + ); + const merged = { total: aggregateTotals(summaries) }; + for (const summary of summaries) { + for (const [key, value] of Object.entries(summary)) { + if (key === 'total') continue; + if (typeof value === 'object' && value !== null && value.lines) { + merged[key] = value; + } + } + } + await fs.writeFile(summaryOutPath, JSON.stringify(merged, null, 2), 'utf8'); + console.log(`Merged ${summaryFiles.length} coverage-summary file(s) into ${path.relative(process.cwd(), summaryOutPath)}`); + } } catch (error) { console.error('Error collecting coverage files:', error); process.exitCode = 1; } } -// Run the function collectCoverageFiles(); diff --git a/turbo.json b/turbo.json index 8ad43ea66..83eada09c 100644 --- a/turbo.json +++ b/turbo.json @@ -22,7 +22,7 @@ "dependsOn": ["^lint"] }, "test": { - "cache": false, + "cache": true, "outputLogs": "new-only", "dependsOn": ["^test"] }, diff --git a/vitest.config.ts b/vitest.config.ts index 31ed55351..575972026 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,8 @@ export default defineConfig({ test: { coverage: { enabled: true, - provider: 'v8', // or 'istanbul' + provider: 'v8', + reporter: ['text-summary', 'html', 'json-summary', 'json'], }, projects: [ { From 276926220da2d6682aa9e9d9411cf83a2733a2a6 Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Tue, 3 Feb 2026 08:23:07 +0100 Subject: [PATCH 09/15] feat(medusajs): add Vitest configuration and implement unit tests for Medusa integration services --- package-lock.json | 4 +- packages/integrations/medusajs/package.json | 5 +- .../modules/medusajs/medusajs.service.spec.ts | 124 ++++++++++++ .../src/modules/orders/orders.mapper.spec.ts | 142 ++++++++++++++ .../src/modules/orders/orders.service.spec.ts | 149 ++++++++++++++ .../modules/products/products.mapper.spec.ts | 185 ++++++++++++++++++ .../modules/products/products.service.spec.ts | 170 ++++++++++++++++ .../resources/resources.mapper.spec.ts | 174 ++++++++++++++++ .../resources/resources.service.spec.ts | 165 ++++++++++++++++ .../modules/utils/handle-http-error.spec.ts | 36 ++++ .../integrations/medusajs/vitest.config.mjs | 12 ++ 11 files changed, 1164 insertions(+), 2 deletions(-) create mode 100644 packages/integrations/medusajs/src/modules/medusajs/medusajs.service.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/products/products.mapper.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/products/products.service.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/resources/resources.service.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/utils/handle-http-error.spec.ts create mode 100644 packages/integrations/medusajs/vitest.config.mjs diff --git a/package-lock.json b/package-lock.json index eef0bc853..62376fa3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51890,11 +51890,13 @@ "@o2s/lint-staged-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "eslint": "^9.39.2", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" }, "peerDependencies": { "@nestjs/axios": "^4.0.1", diff --git a/packages/integrations/medusajs/package.json b/packages/integrations/medusajs/package.json index 570aaaa66..cdf188510 100644 --- a/packages/integrations/medusajs/package.json +++ b/packages/integrations/medusajs/package.json @@ -10,6 +10,7 @@ "dist" ], "scripts": { + "test": "vitest run", "build": "tsc --preserveWatchOutput && tsc-alias", "lint": "tsc --noEmit && eslint . --max-warnings=0", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"" @@ -25,11 +26,13 @@ "@o2s/lint-staged-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "eslint": "^9.39.2", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" }, "peerDependencies": { "@nestjs/axios": "^4.0.1", diff --git a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.spec.ts b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.spec.ts new file mode 100644 index 000000000..274a28a61 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.spec.ts @@ -0,0 +1,124 @@ +import Medusa from '@medusajs/js-sdk'; +import { ConfigService } from '@nestjs/config'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MedusaJsService } from './medusajs.service'; + +const mockSdk = {}; + +vi.mock('@medusajs/js-sdk', () => ({ + default: vi.fn().mockImplementation(function (this: unknown) { + return mockSdk; + }), +})); + +describe('MedusaJsService', () => { + let mockConfig: { get: ReturnType }; + + function defaultConfig(key: string): string { + if (key === 'MEDUSAJS_BASE_URL') return 'https://api.medusa.test'; + if (key === 'MEDUSAJS_PUBLISHABLE_API_KEY') return 'pk_test'; + if (key === 'MEDUSAJS_ADMIN_API_KEY') return 'admin_secret'; + if (key === 'LOG_LEVEL') return ''; + return ''; + } + + beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(Medusa).mockClear(); + mockConfig = { + get: vi.fn((key: string) => defaultConfig(key)), + }; + }); + + describe('constructor', () => { + it('should throw when MEDUSAJS_BASE_URL is not defined', () => { + vi.mocked(mockConfig.get).mockImplementation((key: string) => + key === 'MEDUSAJS_BASE_URL' ? '' : defaultConfig(key), + ); + + expect(() => new MedusaJsService(mockConfig as unknown as ConfigService)).toThrow( + 'MEDUSAJS_BASE_URL is not defined', + ); + }); + + it('should throw when MEDUSAJS_PUBLISHABLE_API_KEY is not defined', () => { + vi.mocked(mockConfig.get).mockImplementation((key: string) => + key === 'MEDUSAJS_PUBLISHABLE_API_KEY' ? '' : defaultConfig(key), + ); + + expect(() => new MedusaJsService(mockConfig as unknown as ConfigService)).toThrow( + 'MEDUSAJS_PUBLISHABLE_API_KEY is not defined', + ); + }); + + it('should throw when MEDUSAJS_ADMIN_API_KEY is not defined', () => { + vi.mocked(mockConfig.get).mockImplementation((key: string) => + key === 'MEDUSAJS_ADMIN_API_KEY' ? '' : defaultConfig(key), + ); + + expect(() => new MedusaJsService(mockConfig as unknown as ConfigService)).toThrow( + 'MEDUSAJS_ADMIN_API_KEY is not defined', + ); + }); + + it('should create Medusa SDK with config and debug false when LOG_LEVEL is not debug', () => { + new MedusaJsService(mockConfig as unknown as ConfigService); + + expect(Medusa).toHaveBeenCalledWith({ + baseUrl: 'https://api.medusa.test', + debug: false, + publishableKey: 'pk_test', + apiKey: 'admin_secret', + }); + }); + + it('should create Medusa SDK with debug true when LOG_LEVEL is debug', () => { + vi.mocked(mockConfig.get).mockImplementation((key: string) => + key === 'LOG_LEVEL' ? 'debug' : defaultConfig(key), + ); + + new MedusaJsService(mockConfig as unknown as ConfigService); + + expect(Medusa).toHaveBeenCalledWith( + expect.objectContaining({ + debug: true, + }), + ); + }); + }); + + describe('getters', () => { + it('getSdk should return SDK instance', () => { + const service = new MedusaJsService(mockConfig as unknown as ConfigService); + expect(service.getSdk()).toBe(mockSdk); + }); + + it('getBaseUrl should return base URL', () => { + const service = new MedusaJsService(mockConfig as unknown as ConfigService); + expect(service.getBaseUrl()).toBe('https://api.medusa.test'); + }); + + it('getPublishableKey should return publishable key', () => { + const service = new MedusaJsService(mockConfig as unknown as ConfigService); + expect(service.getPublishableKey()).toBe('pk_test'); + }); + + it('getAdminKey should return admin key', () => { + const service = new MedusaJsService(mockConfig as unknown as ConfigService); + expect(service.getAdminKey()).toBe('admin_secret'); + }); + + it('getAdminKeyEncoded should return base64 of admin key', () => { + const service = new MedusaJsService(mockConfig as unknown as ConfigService); + expect(service.getAdminKeyEncoded()).toBe(Buffer.from('admin_secret').toString('base64')); + }); + + it('getMedusaAdminApiHeaders should return headers with publishable key and Basic auth', () => { + const service = new MedusaJsService(mockConfig as unknown as ConfigService); + const headers = service.getMedusaAdminApiHeaders(); + expect(headers['x-publishable-api-key']).toBe('pk_test'); + expect(headers.Authorization).toBe(`Basic ${Buffer.from('admin_secret').toString('base64')}`); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts b/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts new file mode 100644 index 000000000..5b756afc8 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts @@ -0,0 +1,142 @@ +import { HttpTypes } from '@medusajs/types'; +import { describe, expect, it } from 'vitest'; + +import { mapOrder, mapOrders } from './orders.mapper'; + +const defaultCurrency = 'EUR'; + +function minimalOrder(overrides: Record = {}): HttpTypes.AdminOrder { + return { + id: 'order_1', + customer_id: 'cust_1', + currency_code: 'eur', + total: 10000, + subtotal: 9000, + tax_total: 1000, + discount_total: 0, + shipping_total: 0, + payment_status: 'captured', + status: 'completed', + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-02'), + items: [], + shipping_address: null, + billing_address: null, + shipping_methods: [], + ...overrides, + } as unknown as HttpTypes.AdminOrder; +} + +describe('orders.mapper', () => { + describe('mapOrders', () => { + it('should map list response with data and total from count', () => { + const response = { + orders: [minimalOrder(), minimalOrder({ id: 'order_2' })], + count: 2, + limit: 10, + offset: 0, + } as HttpTypes.AdminOrderListResponse; + const result = mapOrders(response, defaultCurrency); + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.data[0]?.id).toBe('order_1'); + expect(result.data[1]?.id).toBe('order_2'); + }); + }); + + describe('mapOrder', () => { + it('should map order id, dates, customerId, currency and status', () => { + const order = minimalOrder(); + const result = mapOrder(order, defaultCurrency); + expect(result.id).toBe('order_1'); + expect(result.customerId).toBe('cust_1'); + expect(result.currency).toBe('eur'); + expect(result.status).toBe('COMPLETED'); + expect(result.paymentStatus).toBe('CAPTURED'); + expect(result.createdAt).toBe(order.created_at.toString()); + expect(result.updatedAt).toBe(order.updated_at.toString()); + }); + + it('should map price fields with currency', () => { + const order = minimalOrder(); + const result = mapOrder(order, defaultCurrency); + expect(result.total?.value).toBe(10000); + expect(result.subtotal?.value).toBe(9000); + expect(result.tax?.value).toBe(1000); + }); + + it('should map pending status to PENDING', () => { + const result = mapOrder(minimalOrder({ status: 'pending' }), defaultCurrency); + expect(result.status).toBe('PENDING'); + }); + + it('should map unknown status to UNKNOWN', () => { + const result = mapOrder(minimalOrder({ status: 'unknown' }), defaultCurrency); + expect(result.status).toBe('UNKNOWN'); + }); + + it('should map payment_status awaiting to PENDING', () => { + const result = mapOrder(minimalOrder({ payment_status: 'awaiting' }), defaultCurrency); + expect(result.paymentStatus).toBe('PENDING'); + }); + + it('should set customerId undefined when customer_id is empty', () => { + const result = mapOrder(minimalOrder({ customer_id: '' }), defaultCurrency); + expect(result.customerId).toBeUndefined(); + }); + + it('should map items and shipping methods', () => { + const order = minimalOrder({ + items: [ + { + id: 'item_1', + variant_id: 'var_1', + quantity: 2, + unit_price: 500, + total: 1000, + subtotal: 1000, + product_id: 'prod_1', + product_title: 'Product', + title: 'Product', + variant_sku: 'SKU1', + product_description: '', + product_subtitle: '', + thumbnail: null, + product: { categories: [{ name: 'Cat' }] }, + }, + ], + shipping_methods: [{ id: 'sm_1', name: 'Express', description: '', total: 500, subtotal: 500 }], + }); + const result = mapOrder(order, defaultCurrency); + expect(result.items.data).toHaveLength(1); + expect(result.items.data[0]?.id).toBe('item_1'); + expect(result.items.data[0]?.quantity).toBe(2); + expect(result.shippingMethods).toHaveLength(1); + expect(result.shippingMethods[0]?.id).toBe('sm_1'); + }); + + it('should set shippingAddress and billingAddress undefined when null', () => { + const result = mapOrder(minimalOrder(), defaultCurrency); + expect(result.shippingAddress).toBeUndefined(); + expect(result.billingAddress).toBeUndefined(); + }); + + it('should map address when present', () => { + const order = minimalOrder({ + shipping_address: { + country_code: 'PL', + province: 'Mazovia', + address_1: 'Street', + address_2: '1', + city: 'Warsaw', + postal_code: '00-001', + phone: '+48', + }, + }); + const result = mapOrder(order, defaultCurrency); + expect(result.shippingAddress).toBeDefined(); + expect(result.shippingAddress?.country).toBe('PL'); + expect(result.shippingAddress?.city).toBe('Warsaw'); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts new file mode 100644 index 000000000..616fe250c --- /dev/null +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts @@ -0,0 +1,149 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Auth } from '@o2s/framework/modules'; + +import { OrdersService } from './orders.service'; + +const DEFAULT_CURRENCY = 'EUR'; + +const minimalOrder = { + id: 'order_1', + customer_id: 'cust_1', + currency_code: 'eur', + total: 10000, + subtotal: 9000, + tax_total: 1000, + discount_total: 0, + shipping_total: 0, + payment_status: 'captured', + status: 'completed', + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-02'), + items: [], + shipping_address: null, + billing_address: null, + shipping_methods: [], +}; + +describe('OrdersService', () => { + let service: OrdersService; + let mockSdk: { admin: { order: { retrieve: ReturnType; list: ReturnType } } }; + let mockMedusaJsService: { getSdk: ReturnType }; + let mockAuthService: { getCustomerId: ReturnType }; + let mockConfig: { get: ReturnType }; + let mockLogger: { debug: ReturnType }; + let mockHttpClient: Record>; + + beforeEach(() => { + vi.restoreAllMocks(); + mockSdk = { + admin: { + order: { + retrieve: vi.fn(), + list: vi.fn(), + }, + }, + }; + mockMedusaJsService = { getSdk: vi.fn(() => mockSdk) }; + mockAuthService = { getCustomerId: vi.fn() }; + mockConfig = { + get: vi.fn((key: string) => (key === 'DEFAULT_CURRENCY' ? DEFAULT_CURRENCY : '')), + }; + mockLogger = { debug: vi.fn() }; + mockHttpClient = {}; + + service = new OrdersService( + mockConfig as unknown as ConfigService, + mockHttpClient as unknown as import('@nestjs/axios').HttpService, + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockMedusaJsService as unknown as import('@/modules/medusajs').Service, + mockAuthService as unknown as Auth.Service, + ); + }); + + describe('constructor', () => { + it('should throw when DEFAULT_CURRENCY is not defined', () => { + vi.mocked(mockConfig.get).mockReturnValue(''); + + expect( + () => + new OrdersService( + mockConfig as unknown as ConfigService, + mockHttpClient as unknown as import('@nestjs/axios').HttpService, + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockMedusaJsService as unknown as import('@/modules/medusajs').Service, + mockAuthService as unknown as Auth.Service, + ), + ).toThrow('DEFAULT_CURRENCY is not defined'); + }); + }); + + describe('getOrder', () => { + it('should throw UnauthorizedException when authorization is missing', () => { + expect(() => service.getOrder({ id: 'order_1' }, undefined)).toThrow(UnauthorizedException); + expect(mockLogger.debug).toHaveBeenCalledWith('Authorization token not found'); + }); + + it('should call sdk.admin.order.retrieve and return mapped order', async () => { + mockSdk.admin.order.retrieve.mockResolvedValue({ order: minimalOrder }); + + const result = await firstValueFrom(service.getOrder({ id: 'order_1' }, 'Bearer token')); + + expect(mockSdk.admin.order.retrieve).toHaveBeenCalledWith( + 'order_1', + expect.objectContaining({ fields: 'items.product.*' }), + ); + expect(result).toBeDefined(); + expect(result?.id).toBe('order_1'); + expect(result?.status).toBe('COMPLETED'); + expect(result?.paymentStatus).toBe('CAPTURED'); + }); + + it('should throw NotFoundException when SDK returns 404', async () => { + mockSdk.admin.order.retrieve.mockRejectedValue({ status: 404 }); + + await expect(firstValueFrom(service.getOrder({ id: 'missing' }, 'Bearer token'))).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('getOrderList', () => { + it('should throw UnauthorizedException when authorization is missing', () => { + expect(() => service.getOrderList({ limit: 10, offset: 0 }, undefined)).toThrow(UnauthorizedException); + expect(mockLogger.debug).toHaveBeenCalledWith('Authorization token not found'); + }); + + it('should throw UnauthorizedException when getCustomerId returns undefined', () => { + mockAuthService.getCustomerId.mockReturnValue(undefined); + expect(() => service.getOrderList({ limit: 10, offset: 0 }, 'Bearer token')).toThrow(UnauthorizedException); + expect(mockLogger.debug).toHaveBeenCalledWith('Customer ID not found in authorization token'); + }); + + it('should call sdk.admin.order.list with customerId and return mapped orders', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.admin.order.list.mockResolvedValue({ + orders: [minimalOrder], + count: 1, + }); + + const result = await firstValueFrom(service.getOrderList({ limit: 10, offset: 0 }, 'Bearer token')); + + expect(mockSdk.admin.order.list).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 10, + offset: 0, + customer_id: 'cust_1', + fields: expect.any(String), + }), + ); + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.data[0]?.id).toBe('order_1'); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/products/products.mapper.spec.ts b/packages/integrations/medusajs/src/modules/products/products.mapper.spec.ts new file mode 100644 index 000000000..8fae6c2e6 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/products/products.mapper.spec.ts @@ -0,0 +1,185 @@ +import { HttpTypes } from '@medusajs/types'; +import { describe, expect, it } from 'vitest'; + +import type { CompatibleServicesResponse, FeaturedServicesResponse } from '../resources/response.types'; + +import { + mapCompatibleServices, + mapFeaturedServices, + mapProduct, + mapProductType, + mapProducts, + mapRelatedProducts, +} from './products.mapper'; +import type { RelatedProductsResponse } from './response.types'; + +const defaultCurrency = 'EUR'; + +describe('products.mapper', () => { + describe('mapProduct', () => { + it('should map variant with price in defaultCurrency', () => { + const variant = { + id: 'var_1', + sku: 'SKU1', + product_id: 'prod_1', + product: { + id: 'prod_1', + title: 'Product 1', + description: 'Desc', + subtitle: 'Sub', + thumbnail: null, + type: undefined, + categories: [{ name: 'Category' }], + }, + prices: [{ currency_code: 'eur', amount: 1999 }], + }; + const result = mapProduct(variant as unknown as HttpTypes.AdminProductVariant, defaultCurrency); + expect(result.id).toBe('prod_1'); + expect(result.sku).toBe('SKU1'); + expect(result.variantId).toBe('var_1'); + expect(result.name).toBe('Product 1'); + expect(result.price.value).toBe(1999); + expect(result.price.currency).toBe('EUR'); + expect(result.category).toBe('Category'); + }); + + it('should use value 0 and defaultCurrency when no matching price', () => { + const variant = { + id: 'var_1', + sku: '', + product: { + id: 'p1', + title: 'P', + description: '', + subtitle: '', + thumbnail: null, + type: undefined, + categories: [], + }, + prices: [{ currency_code: 'usd', amount: 100 }], + }; + const result = mapProduct(variant as unknown as HttpTypes.AdminProductVariant, defaultCurrency); + expect(result.price.value).toBe(0); + expect(result.price.currency).toBe(defaultCurrency); + }); + }); + + describe('mapProducts', () => { + it('should map product list with total from count', () => { + const data = { + products: [ + { id: 'p1', title: 'P1', description: '', thumbnail: null, categories: [], type: undefined }, + { id: 'p2', title: 'P2', description: '', thumbnail: null, categories: [], type: undefined }, + ], + count: 2, + limit: 10, + offset: 0, + } as unknown as HttpTypes.AdminProductListResponse; + const result = mapProducts(data, defaultCurrency); + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.data[0]?.id).toBe('p1'); + expect(result.data[0]?.price.value).toBe(0); + expect(result.data[0]?.price.currency).toBe(defaultCurrency); + }); + }); + + describe('mapRelatedProducts', () => { + it('should map productReferences to products with total', () => { + const data = { + productReferences: [ + { + targetProduct: { + id: 'p1', + sku: '', + title: 'Related', + product: { description: '', thumbnail: '', categories: [], type: undefined }, + }, + }, + ], + count: 1, + offset: 0, + limit: 10, + } as unknown as RelatedProductsResponse; + const result = mapRelatedProducts(data, defaultCurrency); + expect(result.data).toHaveLength(1); + expect(result.data[0]?.id).toBe('p1'); + expect(result.data[0]?.name).toBe('Related'); + expect(result.total).toBe(1); + }); + }); + + describe('mapCompatibleServices', () => { + it('should delegate to mapProduct for each and set total to count', () => { + const variant = { + id: 'v1', + sku: '', + product: { + id: 'p1', + title: 'S', + description: '', + subtitle: '', + thumbnail: null, + type: undefined, + categories: [], + }, + prices: [{ currency_code: 'eur', amount: 100 }], + }; + const data = { + compatibleServices: [variant], + count: 1, + offset: 0, + limit: 10, + } as unknown as CompatibleServicesResponse; + const result = mapCompatibleServices(data, defaultCurrency); + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + }); + }); + + describe('mapFeaturedServices', () => { + it('should delegate to mapProduct for each and set total to count', () => { + const variant = { + id: 'v1', + sku: '', + product: { + id: 'p1', + title: 'F', + description: '', + subtitle: '', + thumbnail: null, + type: undefined, + categories: [], + }, + prices: [], + }; + const data = { + featuredServices: [variant], + count: 1, + offset: 0, + limit: 10, + } as unknown as FeaturedServicesResponse; + const result = mapFeaturedServices(data, defaultCurrency); + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + }); + }); + + describe('mapProductType', () => { + it('should return PHYSICAL when type is undefined', () => { + expect(mapProductType(undefined)).toBe('PHYSICAL'); + }); + + it('should return PHYSICAL for value PHYSICAL', () => { + expect(mapProductType({ value: 'PHYSICAL' } as HttpTypes.AdminProductType)).toBe('PHYSICAL'); + }); + + it('should return VIRTUAL for value VIRTUAL', () => { + expect(mapProductType({ value: 'VIRTUAL' } as HttpTypes.AdminProductType)).toBe('VIRTUAL'); + }); + + it('should return PHYSICAL for unknown value', () => { + expect(mapProductType({ value: 'OTHER' } as HttpTypes.AdminProductType)).toBe('PHYSICAL'); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/products/products.service.spec.ts b/packages/integrations/medusajs/src/modules/products/products.service.spec.ts new file mode 100644 index 000000000..691cb53f7 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/products/products.service.spec.ts @@ -0,0 +1,170 @@ +import { NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom, of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProductsService } from './products.service'; + +const BASE_URL = 'https://api.medusa.test'; +const DEFAULT_CURRENCY = 'EUR'; + +const mockProductListResponse = { + products: [ + { + id: 'prod_1', + title: 'Product 1', + description: 'Desc 1', + thumbnail: null, + categories: [], + type: null, + }, + ], + count: 1, +}; + +const mockVariantResponse = { + variant: { + id: 'var_1', + sku: 'SKU1', + product_id: 'prod_1', + product: { + id: 'prod_1', + title: 'Product 1', + description: 'Desc', + subtitle: 'Sub', + thumbnail: null, + type: null, + categories: [], + }, + prices: [{ currency_code: 'eur', amount: 1999 }], + }, +}; + +describe('ProductsService', () => { + let service: ProductsService; + let mockHttpClient: { get: ReturnType }; + let mockMedusaJsService: { + getSdk: ReturnType; + getBaseUrl: ReturnType; + getMedusaAdminApiHeaders: ReturnType; + }; + let mockConfig: { get: ReturnType }; + let mockLogger: { debug: ReturnType }; + + beforeEach(() => { + vi.restoreAllMocks(); + mockHttpClient = { get: vi.fn() }; + mockMedusaJsService = { + getSdk: vi.fn(() => ({})), + getBaseUrl: vi.fn(() => BASE_URL), + getMedusaAdminApiHeaders: vi.fn(() => ({ + 'x-publishable-api-key': 'pk', + Authorization: 'Basic xxx', + })), + }; + mockConfig = { + get: vi.fn((key: string) => (key === 'DEFAULT_CURRENCY' ? DEFAULT_CURRENCY : '')), + }; + mockLogger = { debug: vi.fn() }; + + service = new ProductsService( + mockConfig as unknown as ConfigService, + mockHttpClient as unknown as import('@nestjs/axios').HttpService, + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockMedusaJsService as unknown as import('@/modules/medusajs').Service, + ); + }); + + describe('constructor', () => { + it('should throw when DEFAULT_CURRENCY is not defined', () => { + vi.mocked(mockConfig.get).mockReturnValue(''); + + expect( + () => + new ProductsService( + mockConfig as unknown as ConfigService, + mockHttpClient as unknown as import('@nestjs/axios').HttpService, + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockMedusaJsService as unknown as import('@/modules/medusajs').Service, + ), + ).toThrow('DEFAULT_CURRENCY is not defined'); + }); + }); + + describe('getProductList', () => { + it('should call httpClient.get with baseUrl and params and return mapped products', async () => { + mockHttpClient.get.mockReturnValue(of({ data: mockProductListResponse })); + + const result = await firstValueFrom(service.getProductList({ limit: 10, offset: 0 })); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + `${BASE_URL}/admin/products`, + expect.objectContaining({ + headers: expect.any(Object), + params: { limit: 10, offset: 0 }, + }), + ); + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.data[0]?.id).toBe('prod_1'); + expect(result.data[0]?.name).toBe('Product 1'); + }); + + it('should throw NotFoundException when HTTP returns 404', async () => { + const { throwError } = await import('rxjs'); + mockHttpClient.get.mockReturnValue(throwError(() => ({ status: 404 }))); + + await expect(firstValueFrom(service.getProductList({ limit: 10, offset: 0 }))).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('getProduct', () => { + it('should call httpClient.get for variant URL and return mapped product', async () => { + mockHttpClient.get.mockReturnValue(of({ data: mockVariantResponse })); + + const result = await firstValueFrom(service.getProduct({ id: 'prod_1', variantId: 'var_1' })); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + `${BASE_URL}/admin/products/prod_1/variants/var_1`, + expect.objectContaining({ + params: { fields: 'product.*' }, + }), + ); + expect(result.id).toBe('prod_1'); + expect(result.variantId).toBe('var_1'); + expect(result.price.value).toBe(1999); + }); + }); + + describe('getRelatedProductList', () => { + it('should call httpClient.get for references and return mapped products', async () => { + mockHttpClient.get.mockReturnValue( + of({ + data: { + productReferences: [], + count: 0, + }, + }), + ); + + const result = await firstValueFrom( + service.getRelatedProductList({ + productId: 'p1', + productVariantId: 'v1', + type: 'COMPATIBLE_SERVICE', + }), + ); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + `${BASE_URL}/admin/products/p1/variants/v1/references`, + expect.objectContaining({ + params: { referenceType: 'COMPATIBLE_SERVICE' }, + }), + ); + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts b/packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts new file mode 100644 index 000000000..791678d91 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { Products } from '@o2s/framework/modules'; + +import { mapAsset, mapAssets, mapService, mapServices } from './resources.mapper'; +import type { Asset, AssetsResponse, ServiceInstance, ServiceInstancesResponse } from './response.types'; + +const defaultCurrency = 'EUR'; + +const minimalProduct: Products.Model.Product = { + id: 'prod_1', + sku: 'SKU1', + name: 'Product', + description: '', + shortDescription: '', + variantId: 'var_1', + image: undefined, + price: { value: 999, currency: 'EUR' }, + link: '', + type: 'PHYSICAL', + category: '', + tags: [], +}; + +function minimalAsset(overrides: Record = {}): Asset { + return { + id: 'asset_1', + name: 'Model X', + description: 'Desc', + serial_number: 'SN1', + end_of_warranty_date: '2025-12-31', + address: null, + product_variant: { product_id: 'prod_1' }, + thumbnail: '', + service_item_id: '', + created_at: '', + updated_at: '', + totals: { currency: 'eur', total_price: { value: 0, precision: 2 } }, + ...overrides, + } as unknown as Asset; +} + +function minimalServiceInstance(overrides: Record = {}): ServiceInstance { + return { + id: 'svc_1', + name: 'Service 1', + start_date: '2024-01-01', + end_date: '2024-12-31', + purchase_date: '2024-01-01', + payment_type: 'monthly', + status: 'active', + customer: { email: '' }, + assets: [], + product_variant: { product_id: 'prod_1' }, + totals: { currency: 'eur', total_price: { value: 999, precision: 2 } }, + ...overrides, + } as unknown as ServiceInstance; +} + +describe('resources.mapper', () => { + describe('mapAsset', () => { + it('should map asset fields and product', () => { + const asset = minimalAsset(); + const result = mapAsset(asset, minimalProduct); + expect(result.id).toBe('asset_1'); + expect(result.model).toBe('Model X'); + expect(result.serialNo).toBe('SN1'); + expect(result.description).toBe('Desc'); + expect(result.product).toBe(minimalProduct); + expect(result.endOfWarranty).toBe('2025-12-31'); + }); + + it('should map address when present', () => { + const asset = minimalAsset({ + address: { + country_code: 'PL', + province: 'Mazovia', + address_1: 'Street', + address_2: '1', + city: 'Warsaw', + postal_code: '00-001', + phone: '+48', + }, + }); + const result = mapAsset(asset, minimalProduct); + expect(result.address).toBeDefined(); + expect(result.address?.country).toBe('PL'); + expect(result.address?.city).toBe('Warsaw'); + }); + }); + + describe('mapAssets', () => { + it('should map assets when products match', () => { + const data = { + assets: [minimalAsset(), minimalAsset({ id: 'asset_2' })], + count: 2, + offset: 0, + limit: 10, + } as AssetsResponse; + const products = [minimalProduct]; + const result = mapAssets(data, products); + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter out assets without matching product', () => { + const data = { + assets: [ + minimalAsset({ product_variant: { product_id: 'prod_1' } }), + minimalAsset({ id: 'asset_2', product_variant: { product_id: 'prod_2' } }), + ], + count: 2, + offset: 0, + limit: 10, + } as AssetsResponse; + const products = [minimalProduct]; + const result = mapAssets(data, products); + expect(result.data).toHaveLength(1); + expect(result.data[0]?.id).toBe('asset_1'); + expect(result.total).toBe(2); + }); + }); + + describe('mapService', () => { + it('should map service with contract and product', () => { + const service = minimalServiceInstance(); + const result = mapService(service, minimalProduct, defaultCurrency); + expect(result.id).toBe('svc_1'); + expect(result.product).toBe(minimalProduct); + expect(result.contract.status).toBe('active'); + expect(result.contract.paymentPeriod).toBe('monthly'); + expect(result.contract.price.value).toBe(999); + }); + + it('should map currency from totals', () => { + const service = minimalServiceInstance({ + totals: { currency: 'pln', total_price: { value: 100, precision: 2 } }, + }); + const result = mapService(service, minimalProduct, defaultCurrency); + expect(result.contract.price.currency).toBe('PLN'); + }); + }); + + describe('mapServices', () => { + it('should map services when products match', () => { + const data = { + serviceInstances: [minimalServiceInstance(), minimalServiceInstance({ id: 'svc_2' })], + count: 2, + offset: 0, + limit: 10, + } as ServiceInstancesResponse; + const products = [minimalProduct]; + const result = mapServices(data, products, defaultCurrency); + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter out services without matching product', () => { + const data = { + serviceInstances: [ + minimalServiceInstance({ product_variant: { product_id: 'prod_1' } }), + minimalServiceInstance({ id: 'svc_2', product_variant: { product_id: 'prod_2' } }), + ], + count: 2, + offset: 0, + limit: 10, + } as ServiceInstancesResponse; + const products = [minimalProduct]; + const result = mapServices(data, products, defaultCurrency); + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/resources/resources.service.spec.ts b/packages/integrations/medusajs/src/modules/resources/resources.service.spec.ts new file mode 100644 index 000000000..b20fe83b8 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/resources/resources.service.spec.ts @@ -0,0 +1,165 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom, of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Auth } from '@o2s/framework/modules'; +import { Products } from '@o2s/framework/modules'; + +import { ResourcesService } from './resources.service'; + +const BASE_URL = 'https://api.medusa.test'; +const DEFAULT_CURRENCY = 'EUR'; + +const minimalServiceInstance = { + id: 'svc_1', + name: 'Service 1', + start_date: '2024-01-01', + end_date: '2024-12-31', + purchase_date: '2024-01-01', + payment_type: 'monthly', + status: 'active', + customer: { email: 'c@test.com' }, + assets: [], + product_variant: { id: 'var_1', product_id: 'prod_1' }, + totals: { currency: 'eur', total_price: { value: 999, precision: 2 } }, +}; + +const minimalProduct = { + id: 'prod_1', + sku: 'SKU1', + name: 'Product 1', + description: '', + shortDescription: '', + variantId: 'var_1', + image: undefined, + price: { value: 999, currency: 'EUR' as const }, + link: '', + type: 'PHYSICAL' as const, + category: '', + tags: [], +}; + +describe('ResourcesService', () => { + let service: ResourcesService; + let mockHttpClient: { get: ReturnType }; + let mockMedusaJsService: { + getSdk: ReturnType; + getBaseUrl: ReturnType; + getMedusaAdminApiHeaders: ReturnType; + }; + let mockAuthService: { getCustomerId: ReturnType }; + let mockConfig: { get: ReturnType }; + let mockLogger: { debug: ReturnType }; + let mockProductService: { getProduct: ReturnType }; + + beforeEach(() => { + vi.restoreAllMocks(); + mockHttpClient = { get: vi.fn() }; + mockMedusaJsService = { + getSdk: vi.fn(() => ({})), + getBaseUrl: vi.fn(() => BASE_URL), + getMedusaAdminApiHeaders: vi.fn(() => ({ Authorization: 'Basic xxx' })), + }; + mockAuthService = { getCustomerId: vi.fn() }; + mockConfig = { + get: vi.fn((key: string) => (key === 'DEFAULT_CURRENCY' ? DEFAULT_CURRENCY : '')), + }; + mockLogger = { debug: vi.fn() }; + mockProductService = { getProduct: vi.fn().mockReturnValue(of(minimalProduct)) }; + + service = new ResourcesService( + mockHttpClient as unknown as import('@nestjs/axios').HttpService, + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockMedusaJsService as unknown as import('@/modules/medusajs').Service, + mockAuthService as unknown as Auth.Service, + mockConfig as unknown as ConfigService, + mockProductService as unknown as Products.Service, + ); + }); + + describe('purchaseOrActivateResource', () => { + it('should throw Method not implemented', () => { + expect(() => service.purchaseOrActivateResource({ id: 'x' })).toThrow('Method not implemented'); + }); + }); + + describe('purchaseOrActivateService', () => { + it('should throw Method not implemented', () => { + expect(() => service.purchaseOrActivateService({ id: 'x' })).toThrow('Method not implemented.'); + }); + }); + + describe('getServiceList', () => { + it('should throw UnauthorizedException when getCustomerId returns undefined', () => { + mockAuthService.getCustomerId.mockReturnValue(undefined); + expect(() => service.getServiceList({ limit: 10, offset: 0, locale: 'en' }, 'Bearer token')).toThrow( + UnauthorizedException, + ); + expect(mockLogger.debug).toHaveBeenCalledWith('Customer ID not found in authorization token'); + }); + + it('should call httpClient.get and productService.getProduct and return mapped services', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockHttpClient.get.mockReturnValue( + of({ + data: { + serviceInstances: [minimalServiceInstance], + count: 1, + offset: 0, + limit: 10, + }, + }), + ); + + const result = await firstValueFrom( + service.getServiceList({ limit: 10, offset: 0, locale: 'en' }, 'Bearer token'), + ); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + `${BASE_URL}/admin/service-instances`, + expect.objectContaining({ + params: { customerId: 'cust_1', limit: 10, offset: 0 }, + }), + ); + expect(mockProductService.getProduct).toHaveBeenCalledWith({ + id: 'prod_1', + variantId: 'var_1', + locale: 'en', + }); + expect(result.data).toHaveLength(1); + expect(result.data[0]?.id).toBe('svc_1'); + }); + }); + + describe('getService', () => { + it('should call httpClient.get and productService.getProduct and return mapped service', async () => { + mockHttpClient.get.mockReturnValue(of({ data: { serviceInstance: minimalServiceInstance } })); + + const result = await firstValueFrom(service.getService({ id: 'svc_1', locale: 'en' })); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + `${BASE_URL}/admin/service-instances/svc_1`, + expect.any(Object), + ); + expect(mockProductService.getProduct).toHaveBeenCalledWith({ + id: 'prod_1', + variantId: 'var_1', + locale: 'en', + }); + expect(result.id).toBe('svc_1'); + }); + + it('should throw when serviceInstance has no product_variant.product_id', async () => { + const instanceWithoutProduct = { + ...minimalServiceInstance, + product_variant: { id: 'var_1', product_id: undefined }, + }; + mockHttpClient.get.mockReturnValue(of({ data: { serviceInstance: instanceWithoutProduct } })); + + await expect(firstValueFrom(service.getService({ id: 'svc_1', locale: 'en' }))).rejects.toThrow( + 'Product ID not found', + ); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/utils/handle-http-error.spec.ts b/packages/integrations/medusajs/src/modules/utils/handle-http-error.spec.ts new file mode 100644 index 000000000..76faf0781 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/utils/handle-http-error.spec.ts @@ -0,0 +1,36 @@ +import { + ForbiddenException, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; +import { describe, expect, it } from 'vitest'; + +import { handleHttpError } from './handle-http-error'; + +describe('handleHttpError', () => { + it('should throw NotFoundException when error.status is 404', () => { + expect(() => handleHttpError({ status: 404 })).toThrow(NotFoundException); + expect(() => handleHttpError({ status: 404 })).toThrow('Not found'); + }); + + it('should throw ForbiddenException when error.status is 403', () => { + expect(() => handleHttpError({ status: 403 })).toThrow(ForbiddenException); + expect(() => handleHttpError({ status: 403 })).toThrow('Forbidden'); + }); + + it('should throw UnauthorizedException when error.status is 401', () => { + expect(() => handleHttpError({ status: 401 })).toThrow(UnauthorizedException); + expect(() => handleHttpError({ status: 401 })).toThrow('Unauthorized'); + }); + + it('should return observable that errors with InternalServerErrorException for other status', async () => { + const obs = handleHttpError({ status: 500, message: 'Server error' }); + + await expect(firstValueFrom(obs)).rejects.toThrow(InternalServerErrorException); + await expect(firstValueFrom(handleHttpError({ status: 500, message: 'Server error' }))).rejects.toThrow( + 'Server error', + ); + }); +}); diff --git a/packages/integrations/medusajs/vitest.config.mjs b/packages/integrations/medusajs/vitest.config.mjs new file mode 100644 index 000000000..66e460883 --- /dev/null +++ b/packages/integrations/medusajs/vitest.config.mjs @@ -0,0 +1,12 @@ +import { config } from '@o2s/vitest-config/api'; +import { resolve } from 'node:path'; +import { mergeConfig } from 'vite'; + +export default mergeConfig(config, { + resolve: { + alias: { + // eslint-disable-next-line no-undef + '@': resolve(process.cwd(), './src'), + }, + }, +}); From 6d44cd919db2332402162a905adc6dd4993ea3f4 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Tue, 3 Feb 2026 08:51:17 +0100 Subject: [PATCH 10/15] chore(ci): add script for merging coverage-summary files and update related workflows --- .github/actions/test/action.yaml | 7 +- packages/configs/vitest-config/package.json | 6 +- .../scripts/collect-json-outputs.mjs | 68 ++++------------ .../scripts/merge-summary-outputs.mjs | 78 +++++++++++++++++++ packages/configs/vitest-config/turbo.json | 7 +- 5 files changed, 103 insertions(+), 63 deletions(-) create mode 100644 packages/configs/vitest-config/scripts/merge-summary-outputs.mjs diff --git a/.github/actions/test/action.yaml b/.github/actions/test/action.yaml index 1f14f8b0c..706c253e7 100644 --- a/.github/actions/test/action.yaml +++ b/.github/actions/test/action.yaml @@ -62,12 +62,7 @@ runs: - name: Merge coverage and generate report if: steps.check-coverage.outputs.has_coverage == 'true' shell: bash - run: npx turbo run merge-json-reports report --filter=@o2s/vitest-config - - - name: Report summary - if: steps.check-coverage.outputs.has_coverage == 'true' - shell: bash - run: npm run report:summary --workspace=@o2s/vitest-config + run: npx turbo run merge-json-reports merge-summary-reports report --filter=@o2s/vitest-config - name: Upload coverage report if: steps.check-coverage.outputs.has_coverage == 'true' diff --git a/packages/configs/vitest-config/package.json b/packages/configs/vitest-config/package.json index f0c064e44..6e07a773b 100644 --- a/packages/configs/vitest-config/package.json +++ b/packages/configs/vitest-config/package.json @@ -8,14 +8,14 @@ "./api": "./api.js" }, "scripts": { - "collect-json-reports": "tsx scripts/collect-json-outputs.mjs", + "collect-json-reports": "node scripts/collect-json-outputs.mjs", "merge-json-reports": "nyc merge coverage/raw coverage/merged/merged-coverage.json", + "merge-summary-reports": "node scripts/merge-summary-outputs.mjs", "report": "nyc report -t coverage/merged --report-dir coverage/report --reporter=html --exclude-after-remap false", - "report:summary": "nyc report -t coverage/merged --reporter=json-summary --report-dir coverage", "view-report": "open-cli coverage/report/index.html" }, "devDependencies": { - "glob": "13.0.0", + "glob": "^13.0.0", "nyc": "^17.1.0", "open-cli": "^8.0.0", "unplugin-swc": "^1.5.9", diff --git a/packages/configs/vitest-config/scripts/collect-json-outputs.mjs b/packages/configs/vitest-config/scripts/collect-json-outputs.mjs index ab60a432b..966ed02ad 100644 --- a/packages/configs/vitest-config/scripts/collect-json-outputs.mjs +++ b/packages/configs/vitest-config/scripts/collect-json-outputs.mjs @@ -3,50 +3,19 @@ import path from 'path'; import fs from 'fs/promises'; -const CANDIDATE_COVERAGE_FINAL = [ - path.join('coverage', 'coverage-final.json'), - 'coverage.json', -]; - +const CANDIDATE_COVERAGE_FINAL = [path.join('coverage', 'coverage-final.json'), 'coverage.json']; const COVERAGE_SUMMARY_PATH = path.join('coverage', 'coverage-summary.json'); -const METRIC_KEYS = ['lines', 'statements', 'functions', 'branches', 'branchesTrue']; - -function aggregateTotals(summaries) { - const total = {}; - for (const key of METRIC_KEYS) { - total[key] = { total: 0, covered: 0, skipped: 0 }; - } - for (const summary of summaries) { - const t = summary.total; - if (!t) continue; - for (const key of METRIC_KEYS) { - const m = t[key]; - if (m) { - total[key].total += m.total ?? 0; - total[key].covered += m.covered ?? 0; - total[key].skipped += m.skipped ?? 0; - } - } - } - for (const key of METRIC_KEYS) { - const m = total[key]; - m.pct = m.total > 0 ? Math.round((m.covered / m.total) * 100 * 100) / 100 : 100; - } - return total; -} - async function collectCoverageFiles() { try { const patterns = ['../../../apps/**', '../../../packages/**']; - const destinationDir = path.join(process.cwd(), 'coverage/raw'); - const summaryOutPath = path.join(process.cwd(), 'coverage/coverage-summary.json'); + const rawFinalDir = path.join(process.cwd(), 'coverage/raw'); + const rawSummaryDir = path.join(process.cwd(), 'coverage/raw-summary'); - await fs.mkdir(destinationDir, { recursive: true }); - await fs.mkdir(path.dirname(summaryOutPath), { recursive: true }); + await fs.mkdir(rawFinalDir, { recursive: true }); + await fs.mkdir(rawSummaryDir, { recursive: true }); const directoriesWithCoverage = []; - const summaryFiles = []; for (const pattern of patterns) { const matches = await glob(pattern); @@ -71,13 +40,17 @@ async function collectCoverageFiles() { directoriesWithCoverage.push(match); const directoryName = path.basename(match); - const destinationFile = path.join(destinationDir, `${directoryName}.json`); + + // Collect coverage-final.json + const destinationFile = path.join(rawFinalDir, `${directoryName}.json`); await fs.copyFile(coverageFilePath, destinationFile); + // Collect coverage-summary.json if it exists const summaryPath = path.join(match, COVERAGE_SUMMARY_PATH); try { await fs.access(summaryPath); - summaryFiles.push(summaryPath); + const summaryDestinationFile = path.join(rawSummaryDir, `${directoryName}.json`); + await fs.copyFile(summaryPath, summaryDestinationFile); } catch { // no summary in this package } @@ -85,28 +58,17 @@ async function collectCoverageFiles() { } const replaceDotPatterns = (str) => str.replace(/\.\.\//g, ''); + if (directoriesWithCoverage.length > 0) { console.log(`Found coverage in: ${directoriesWithCoverage.map(replaceDotPatterns).join(', ')}`); } else { console.log('No coverage files found (looked for coverage/coverage-final.json and coverage.json).'); } - console.log(`Coverage collected into: ${destinationDir}`); + console.log(`Coverage-final collected into: ${rawFinalDir}`); + const summaryFiles = await fs.readdir(rawSummaryDir).catch(() => []); if (summaryFiles.length > 0) { - const summaries = await Promise.all( - summaryFiles.map((p) => fs.readFile(p, 'utf8').then(JSON.parse)), - ); - const merged = { total: aggregateTotals(summaries) }; - for (const summary of summaries) { - for (const [key, value] of Object.entries(summary)) { - if (key === 'total') continue; - if (typeof value === 'object' && value !== null && value.lines) { - merged[key] = value; - } - } - } - await fs.writeFile(summaryOutPath, JSON.stringify(merged, null, 2), 'utf8'); - console.log(`Merged ${summaryFiles.length} coverage-summary file(s) into ${path.relative(process.cwd(), summaryOutPath)}`); + console.log(`Coverage-summary collected into: ${rawSummaryDir} (${summaryFiles.length} file(s))`); } } catch (error) { console.error('Error collecting coverage files:', error); diff --git a/packages/configs/vitest-config/scripts/merge-summary-outputs.mjs b/packages/configs/vitest-config/scripts/merge-summary-outputs.mjs new file mode 100644 index 000000000..80d257d6f --- /dev/null +++ b/packages/configs/vitest-config/scripts/merge-summary-outputs.mjs @@ -0,0 +1,78 @@ +import path from 'path'; +import fs from 'fs/promises'; + +const METRIC_KEYS = ['lines', 'statements', 'functions', 'branches', 'branchesTrue']; + +function aggregateTotals(summaries) { + const total = {}; + for (const key of METRIC_KEYS) { + total[key] = { total: 0, covered: 0, skipped: 0 }; + } + for (const summary of summaries) { + const t = summary.total; + if (!t) continue; + for (const key of METRIC_KEYS) { + const m = t[key]; + if (m) { + total[key].total += m.total ?? 0; + total[key].covered += m.covered ?? 0; + total[key].skipped += m.skipped ?? 0; + } + } + } + for (const key of METRIC_KEYS) { + const m = total[key]; + m.pct = m.total > 0 ? Math.round((m.covered / m.total) * 100 * 100) / 100 : 100; + } + return total; +} + +async function mergeSummaryFiles() { + try { + const rawSummaryDir = path.join(process.cwd(), 'coverage/raw-summary'); + const mergedSummaryPath = path.join(process.cwd(), 'coverage/coverage-summary.json'); + + await fs.mkdir(path.dirname(mergedSummaryPath), { recursive: true }); + + let summaryFiles; + try { + summaryFiles = await fs.readdir(rawSummaryDir); + } catch { + console.log('No coverage-summary files found to merge.'); + return; + } + + if (summaryFiles.length === 0) { + console.log('No coverage-summary files found to merge.'); + return; + } + + const summaryPaths = summaryFiles + .filter((f) => f.endsWith('.json')) + .map((f) => path.join(rawSummaryDir, f)); + + const summaries = await Promise.all( + summaryPaths.map((p) => fs.readFile(p, 'utf8').then(JSON.parse)), + ); + + const merged = { total: aggregateTotals(summaries) }; + for (const summary of summaries) { + for (const [key, value] of Object.entries(summary)) { + if (key === 'total') continue; + if (typeof value === 'object' && value !== null && value.lines) { + merged[key] = value; + } + } + } + + await fs.writeFile(mergedSummaryPath, JSON.stringify(merged, null, 2), 'utf8'); + console.log( + `Merged ${summaryFiles.length} coverage-summary file(s) into ${path.relative(process.cwd(), mergedSummaryPath)}`, + ); + } catch (error) { + console.error('Error merging coverage-summary files:', error); + process.exitCode = 1; + } +} + +mergeSummaryFiles(); diff --git a/packages/configs/vitest-config/turbo.json b/packages/configs/vitest-config/turbo.json index 9cb524bde..4045451f0 100644 --- a/packages/configs/vitest-config/turbo.json +++ b/packages/configs/vitest-config/turbo.json @@ -9,9 +9,14 @@ "inputs": ["coverage/raw/**"], "outputs": ["coverage/merged/**"] }, + "merge-summary-reports": { + "dependsOn": ["collect-json-reports"], + "inputs": ["coverage/raw-summary/**"], + "outputs": ["coverage/coverage-summary.json"] + }, "report": { "dependsOn": ["merge-json-reports"], - "inputs": ["coverage/merge"], + "inputs": ["coverage/merged/**"], "outputs": ["coverage/report/**"] }, "view-report": { From 8ff44b3d5e6c3c4e9f4dc8efd2cffbc21b6f5b32 Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Tue, 3 Feb 2026 12:53:03 +0100 Subject: [PATCH 11/15] fix(medusajs): correct total count in mapServices and update error message --- .../medusajs/src/modules/resources/resources.mapper.spec.ts | 2 +- .../medusajs/src/modules/resources/resources.mapper.ts | 2 +- .../medusajs/src/modules/resources/resources.service.spec.ts | 2 +- .../medusajs/src/modules/resources/resources.service.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts b/packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts index 791678d91..ecc04327c 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts @@ -168,7 +168,7 @@ describe('resources.mapper', () => { const products = [minimalProduct]; const result = mapServices(data, products, defaultCurrency); expect(result.data).toHaveLength(1); - expect(result.total).toBe(1); + expect(result.total).toBe(2); }); }); }); diff --git a/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts index 0bdd64ab4..edf04d698 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts @@ -50,7 +50,7 @@ export const mapServices = ( return { data: services, - total: services.length, + total: data?.count ?? services.length, }; }; diff --git a/packages/integrations/medusajs/src/modules/resources/resources.service.spec.ts b/packages/integrations/medusajs/src/modules/resources/resources.service.spec.ts index b20fe83b8..7293a4b7b 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.service.spec.ts @@ -86,7 +86,7 @@ describe('ResourcesService', () => { describe('purchaseOrActivateService', () => { it('should throw Method not implemented', () => { - expect(() => service.purchaseOrActivateService({ id: 'x' })).toThrow('Method not implemented.'); + expect(() => service.purchaseOrActivateService({ id: 'x' })).toThrow('Method not implemented'); }); }); diff --git a/packages/integrations/medusajs/src/modules/resources/resources.service.ts b/packages/integrations/medusajs/src/modules/resources/resources.service.ts index 18afbd807..42e1041f1 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.service.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.service.ts @@ -46,7 +46,7 @@ export class ResourcesService extends Resources.Service { } purchaseOrActivateService(_params: Resources.Request.GetServiceParams): Observable { - throw new Error('Method not implemented.'); + throw new Error('Method not implemented'); } getServiceList( From 9dc1b31403b13ff0655e46cc4fba7f524d4a3a6e Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Tue, 3 Feb 2026 15:12:32 +0100 Subject: [PATCH 12/15] feat(tests): add Vitest configuration and unit tests for Contentful and Strapi integrations --- .../integrations/contentful-cms/package.json | 5 +- .../modules/graphql/graphql.service.spec.ts | 109 ++++++++++++++ .../rest-delivery/delivery.service.spec.ts | 123 ++++++++++++++++ .../contentful-cms/vitest.config.mjs | 15 ++ packages/integrations/strapi-cms/package.json | 7 +- .../modules/graphql/graphql.service.spec.ts | 139 ++++++++++++++++++ .../src/modules/graphql/graphql.service.ts | 2 +- .../integrations/strapi-cms/vitest.config.mjs | 15 ++ 8 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 packages/integrations/contentful-cms/src/modules/graphql/graphql.service.spec.ts create mode 100644 packages/integrations/contentful-cms/src/modules/rest-delivery/delivery.service.spec.ts create mode 100644 packages/integrations/contentful-cms/vitest.config.mjs create mode 100644 packages/integrations/strapi-cms/src/modules/graphql/graphql.service.spec.ts create mode 100644 packages/integrations/strapi-cms/vitest.config.mjs diff --git a/packages/integrations/contentful-cms/package.json b/packages/integrations/contentful-cms/package.json index 8beec6807..66cedeaff 100644 --- a/packages/integrations/contentful-cms/package.json +++ b/packages/integrations/contentful-cms/package.json @@ -11,6 +11,7 @@ "dist" ], "scripts": { + "test": "vitest run", "build": "tsc --preserveWatchOutput && tsc-alias", "lint": "tsc --noEmit && eslint . --max-warnings=0", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"", @@ -38,11 +39,13 @@ "@o2s/lint-staged-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "eslint": "^9.39.2", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.17" }, "peerDependencies": { "@nestjs/axios": "^4.0.1", diff --git a/packages/integrations/contentful-cms/src/modules/graphql/graphql.service.spec.ts b/packages/integrations/contentful-cms/src/modules/graphql/graphql.service.spec.ts new file mode 100644 index 000000000..aaa9ed08c --- /dev/null +++ b/packages/integrations/contentful-cms/src/modules/graphql/graphql.service.spec.ts @@ -0,0 +1,109 @@ +import { ConfigService } from '@nestjs/config'; +import { GraphQLClient } from 'graphql-request'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GraphqlService } from './graphql.service'; + +vi.mock('@/generated/contentful', () => { + return { + getSdk: (client: { request: (vars: unknown) => unknown }) => ({ + getPage: (vars: unknown) => client.request(vars), + getPages: (vars: unknown) => client.request(vars), + getComponent: (vars: unknown) => client.request(vars), + getHeader: (vars: unknown) => client.request(vars), + getAppConfig: (vars: unknown) => client.request(vars), + getFooter: (vars: unknown) => client.request(vars), + }), + }; +}); + +vi.mock('graphql-request', () => ({ + GraphQLClient: vi.fn(), +})); + +describe('GraphqlService', () => { + const originalEnv = process.env; + const mockedGraphQLClient = GraphQLClient as unknown as ReturnType; + + let mockConfig: { get: ReturnType }; + let deliveryClientMock: { request: (vars: unknown) => unknown }; + let previewClientMock: { request: (vars: unknown) => unknown }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + process.env.CF_SPACE_ID = 'space-id'; + process.env.CF_ENV = 'env-id'; + process.env.CF_PREVIEW_TOKEN = 'preview-token'; + + mockConfig = { + get: vi.fn((key: string) => { + if (key === 'CF_TOKEN') { + return 'delivery-token'; + } + return undefined; + }), + }; + + deliveryClientMock = { request: vi.fn() }; + previewClientMock = { request: vi.fn() }; + + mockedGraphQLClient.mockReset(); + mockedGraphQLClient + .mockImplementationOnce(function GraphQLClient(this: unknown) { + return deliveryClientMock; + }) + .mockImplementationOnce(function GraphQLClient(this: unknown) { + return previewClientMock; + }); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should configure GraphQL clients with correct base URL and headers', () => { + new GraphqlService(mockConfig as unknown as ConfigService); + + const calls = mockedGraphQLClient.mock.calls; + expect(calls).toHaveLength(2); + + const expectedBaseUrl = 'https://graphql.contentful.com/content/v1/spaces/space-id/environments/env-id'; + + const [deliveryUrl, deliveryOptions] = calls[0] as [string, unknown]; + expect(deliveryUrl).toBe(expectedBaseUrl); + expect(deliveryOptions).toMatchObject({ + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer delivery-token', + }, + }); + + const [previewUrl, previewOptions] = calls[1] as [string, unknown]; + expect(previewUrl).toBe(expectedBaseUrl); + expect(previewOptions).toMatchObject({ + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer preview-token', + }, + }); + }); + + it('should use preview client when preview flag is true', () => { + const service = new GraphqlService(mockConfig as unknown as ConfigService); + + service.getPage({ preview: true } as never); + expect(previewClientMock.request).toHaveBeenCalled(); + expect(deliveryClientMock.request).not.toHaveBeenCalled(); + }); + + it('should use delivery client when preview flag is false or undefined', () => { + const service = new GraphqlService(mockConfig as unknown as ConfigService); + + service.getPage({ preview: false } as never); + service.getFooter({ preview: undefined } as never); + + expect(previewClientMock.request).not.toHaveBeenCalled(); + expect(deliveryClientMock.request).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/integrations/contentful-cms/src/modules/rest-delivery/delivery.service.spec.ts b/packages/integrations/contentful-cms/src/modules/rest-delivery/delivery.service.spec.ts new file mode 100644 index 000000000..e9459af2e --- /dev/null +++ b/packages/integrations/contentful-cms/src/modules/rest-delivery/delivery.service.spec.ts @@ -0,0 +1,123 @@ +import { ConfigService } from '@nestjs/config'; +import { createClient } from 'contentful'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RestDeliveryService } from './delivery.service'; + +vi.mock('contentful', () => ({ + createClient: vi.fn(), +})); + +describe('RestDeliveryService', () => { + let mockConfig: { get: ReturnType }; + let mockClient: { getLocales: ReturnType }; + + beforeEach(() => { + vi.restoreAllMocks(); + mockClient = { + getLocales: vi.fn(), + }; + mockConfig = { + get: vi.fn((key: string) => { + switch (key) { + case 'CF_SPACE_ID': + return 'space-from-config'; + case 'CF_ENV': + return 'env-from-config'; + case 'CF_TOKEN': + return 'token-from-config'; + default: + return undefined; + } + }), + }; + + vi.mocked(createClient).mockReturnValue(mockClient as unknown as ReturnType); + }); + + describe('constructor', () => { + it('should create contentful client with values from ConfigService when set', () => { + new RestDeliveryService(mockConfig as unknown as ConfigService); + + expect(createClient).toHaveBeenCalledWith({ + space: 'space-from-config', + environment: 'env-from-config', + accessToken: 'token-from-config', + }); + }); + + it('should fall back to environment variables when ConfigService returns undefined', () => { + vi.mocked(mockConfig.get).mockReturnValue(undefined); + process.env.CF_SPACE_ID = 'space-from-env'; + process.env.CF_ENV = 'env-from-env'; + process.env.CF_TOKEN = 'token-from-env'; + + try { + new RestDeliveryService(mockConfig as unknown as ConfigService); + + expect(createClient).toHaveBeenCalledWith({ + space: 'space-from-env', + environment: 'env-from-env', + accessToken: 'token-from-env', + }); + } finally { + delete process.env.CF_SPACE_ID; + delete process.env.CF_ENV; + delete process.env.CF_TOKEN; + } + }); + }); + + describe('getLocales', () => { + it('should map locales from contentful response to value/label pairs', async () => { + mockClient.getLocales.mockResolvedValue({ + items: [ + { code: 'en-US', name: 'English (US)' }, + { code: 'pl-PL', name: 'Polski' }, + ], + }); + + const service = new RestDeliveryService(mockConfig as unknown as ConfigService); + + const result = await service.getLocales(); + + expect(mockClient.getLocales).toHaveBeenCalled(); + expect(result).toEqual([ + { value: 'en-US', label: 'English (US)' }, + { value: 'pl-PL', label: 'Polski' }, + ]); + }); + + it('should return empty array when items is empty', async () => { + mockClient.getLocales.mockResolvedValue({ + items: [], + }); + + const service = new RestDeliveryService(mockConfig as unknown as ConfigService); + + const result = await service.getLocales(); + + expect(result).toEqual([]); + }); + + it('should return empty array when items is undefined', async () => { + mockClient.getLocales.mockResolvedValue({} as never); + + const service = new RestDeliveryService(mockConfig as unknown as ConfigService); + + const result = await service.getLocales(); + + expect(result).toEqual([]); + }); + + it('should return empty array when getLocales throws', async () => { + mockClient.getLocales.mockRejectedValue(new Error('network error')); + + const service = new RestDeliveryService(mockConfig as unknown as ConfigService); + + const result = await service.getLocales(); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/integrations/contentful-cms/vitest.config.mjs b/packages/integrations/contentful-cms/vitest.config.mjs new file mode 100644 index 000000000..6c23bc401 --- /dev/null +++ b/packages/integrations/contentful-cms/vitest.config.mjs @@ -0,0 +1,15 @@ +import { config } from '@o2s/vitest-config/api'; +import { resolve } from 'node:path'; +import { mergeConfig } from 'vite'; + +export default mergeConfig(config, { + resolve: { + alias: { + // More specific first so '@/generated/*' resolves to ./generated, not ./src/generated + // eslint-disable-next-line no-undef + '@/generated': resolve(process.cwd(), './generated'), + // eslint-disable-next-line no-undef + '@': resolve(process.cwd(), './src'), + }, + }, +}); diff --git a/packages/integrations/strapi-cms/package.json b/packages/integrations/strapi-cms/package.json index 9aea3238c..5a07e550a 100644 --- a/packages/integrations/strapi-cms/package.json +++ b/packages/integrations/strapi-cms/package.json @@ -14,7 +14,8 @@ "build": "tsc --preserveWatchOutput && tsc-alias", "lint": "tsc --noEmit && eslint . --max-warnings=0", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,json}\"", - "generate": "graphql-codegen && prettier --write \"generated/**/*.{ts,tsx}\"" + "generate": "graphql-codegen && prettier --write \"generated/**/*.{ts,tsx}\"", + "test": "vitest run" }, "dependencies": { "@o2s/framework": "*", @@ -36,11 +37,13 @@ "@o2s/lint-staged-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "eslint": "^9.39.2", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.17" }, "peerDependencies": { "@nestjs/axios": "^4.0.1", diff --git a/packages/integrations/strapi-cms/src/modules/graphql/graphql.service.spec.ts b/packages/integrations/strapi-cms/src/modules/graphql/graphql.service.spec.ts new file mode 100644 index 000000000..d1c9249a0 --- /dev/null +++ b/packages/integrations/strapi-cms/src/modules/graphql/graphql.service.spec.ts @@ -0,0 +1,139 @@ +import { ConfigService } from '@nestjs/config'; +import { GraphQLClient } from 'graphql-request'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GraphqlService } from './graphql.service'; + +interface StrapiSdkMock { + getAppConfig: Mock; + getLocales: Mock; + getPage: Mock; + getPages: Mock; + getLoginPage: Mock; + getNotFoundPage: Mock; + getHeader: Mock; + getFooter: Mock; + getComponent: Mock; + getOrganizationList: Mock; + getSurvey: Mock; + getCategories: Mock; + getArticle: Mock; + getArticles: Mock; +} + +vi.mock('graphql-request', () => { + const GraphQLClientMock = vi.fn().mockImplementation(function GraphQLClient(this: unknown) { + return {} as GraphQLClient; + }); + + return { + GraphQLClient: GraphQLClientMock, + }; +}); + +vi.mock('../../../generated/strapi', () => { + const sdkMock: StrapiSdkMock = { + getAppConfig: vi.fn(), + getLocales: vi.fn(), + getPage: vi.fn(), + getPages: vi.fn(), + getLoginPage: vi.fn(), + getNotFoundPage: vi.fn(), + getHeader: vi.fn(), + getFooter: vi.fn(), + getComponent: vi.fn(), + getOrganizationList: vi.fn(), + getSurvey: vi.fn(), + getCategories: vi.fn(), + getArticle: vi.fn(), + getArticles: vi.fn(), + }; + + (globalThis as { __strapiSdkMock?: StrapiSdkMock }).__strapiSdkMock = sdkMock; + + return { + getSdk: () => sdkMock, + }; +}); + +describe('GraphqlService', () => { + const mockedGraphQLClient = GraphQLClient as unknown as Mock; + + const getSdkMock = () => (globalThis as { __strapiSdkMock?: StrapiSdkMock }).__strapiSdkMock as StrapiSdkMock; + + let configService: ConfigService; + + beforeEach(() => { + mockedGraphQLClient.mockReset(); + + const sdkMock = getSdkMock(); + Object.values(sdkMock).forEach((fn) => fn.mockReset()); + + configService = { + get: vi.fn((key: string) => { + if (key === 'CMS_STRAPI_BASE_URL') { + return 'https://strapi.test'; + } + + return undefined; + }), + } as unknown as ConfigService; + }); + + it('should create GraphQLClient with URL from config', () => { + new GraphqlService(configService); + + // Assert + expect(mockedGraphQLClient).toHaveBeenCalledTimes(1); + expect(mockedGraphQLClient).toHaveBeenCalledWith('https://strapi.test/graphql'); + }); + + it('should delegate methods to sdk', () => { + const service = new GraphqlService(configService); + const sdkMock = getSdkMock(); + + const appConfigParams = {} as unknown; + const pageParams = {} as unknown; + const pagesParams = {} as unknown; + const loginPageParams = {} as unknown; + const notFoundPageParams = {} as unknown; + const headerParams = {} as unknown; + const footerParams = {} as unknown; + const componentParams = {} as unknown; + const organizationListParams = {} as unknown; + const surveyParams = {} as unknown; + const categoriesParams = {} as unknown; + const articleParams = {} as unknown; + const articlesParams = {} as unknown; + + service.getAppConfig(appConfigParams as never); + service.getLocales(); + service.getPage(pageParams as never); + service.getPages(pagesParams as never); + service.getLoginPage(loginPageParams as never); + service.getNotFoundPage(notFoundPageParams as never); + service.getHeader(headerParams as never); + service.getFooter(footerParams as never); + service.getComponent(componentParams as never); + service.getOrganizationList(organizationListParams as never); + service.getSurvey(surveyParams as never); + service.getCategories(categoriesParams as never); + service.getArticle(articleParams as never); + service.getArticles(articlesParams as never); + + expect(sdkMock.getAppConfig).toHaveBeenCalledWith(appConfigParams); + expect(sdkMock.getLocales).toHaveBeenCalled(); + expect(sdkMock.getPage).toHaveBeenCalledWith(pageParams); + expect(sdkMock.getPages).toHaveBeenCalledWith(pagesParams); + expect(sdkMock.getLoginPage).toHaveBeenCalledWith(loginPageParams); + expect(sdkMock.getNotFoundPage).toHaveBeenCalledWith(notFoundPageParams); + expect(sdkMock.getHeader).toHaveBeenCalledWith(headerParams); + expect(sdkMock.getFooter).toHaveBeenCalledWith(footerParams); + expect(sdkMock.getComponent).toHaveBeenCalledWith(componentParams); + expect(sdkMock.getOrganizationList).toHaveBeenCalledWith(organizationListParams); + expect(sdkMock.getSurvey).toHaveBeenCalledWith(surveyParams); + expect(sdkMock.getCategories).toHaveBeenCalledWith(categoriesParams); + expect(sdkMock.getArticle).toHaveBeenCalledWith(articleParams); + expect(sdkMock.getArticles).toHaveBeenCalledWith(articlesParams); + }); +}); diff --git a/packages/integrations/strapi-cms/src/modules/graphql/graphql.service.ts b/packages/integrations/strapi-cms/src/modules/graphql/graphql.service.ts index ef85ea1ba..dc0dd08d6 100644 --- a/packages/integrations/strapi-cms/src/modules/graphql/graphql.service.ts +++ b/packages/integrations/strapi-cms/src/modules/graphql/graphql.service.ts @@ -18,7 +18,7 @@ import { GetSurveyQueryVariables, Sdk, getSdk, -} from '@/generated/strapi'; +} from '../../../generated/strapi'; @Global() @Injectable() diff --git a/packages/integrations/strapi-cms/vitest.config.mjs b/packages/integrations/strapi-cms/vitest.config.mjs new file mode 100644 index 000000000..6c23bc401 --- /dev/null +++ b/packages/integrations/strapi-cms/vitest.config.mjs @@ -0,0 +1,15 @@ +import { config } from '@o2s/vitest-config/api'; +import { resolve } from 'node:path'; +import { mergeConfig } from 'vite'; + +export default mergeConfig(config, { + resolve: { + alias: { + // More specific first so '@/generated/*' resolves to ./generated, not ./src/generated + // eslint-disable-next-line no-undef + '@/generated': resolve(process.cwd(), './generated'), + // eslint-disable-next-line no-undef + '@': resolve(process.cwd(), './src'), + }, + }, +}); From f11a0f6b7a12672ad21fd6b88cbbdfdc9fc5a9a2 Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Wed, 4 Feb 2026 09:18:48 +0100 Subject: [PATCH 13/15] fix(graphql): update import path for Strapi generated types --- .../strapi-cms/src/modules/graphql/graphql.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integrations/strapi-cms/src/modules/graphql/graphql.service.ts b/packages/integrations/strapi-cms/src/modules/graphql/graphql.service.ts index dc0dd08d6..ef85ea1ba 100644 --- a/packages/integrations/strapi-cms/src/modules/graphql/graphql.service.ts +++ b/packages/integrations/strapi-cms/src/modules/graphql/graphql.service.ts @@ -18,7 +18,7 @@ import { GetSurveyQueryVariables, Sdk, getSdk, -} from '../../../generated/strapi'; +} from '@/generated/strapi'; @Global() @Injectable() From 174b88ce6c7f53ee870fab93cdf9052da813ee6e Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Wed, 4 Feb 2026 09:25:39 +0100 Subject: [PATCH 14/15] chore(integration-tests): add changeset --- .changeset/gold-dragons-thank.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/gold-dragons-thank.md diff --git a/.changeset/gold-dragons-thank.md b/.changeset/gold-dragons-thank.md new file mode 100644 index 000000000..cdbf9fa8f --- /dev/null +++ b/.changeset/gold-dragons-thank.md @@ -0,0 +1,10 @@ +--- +'@o2s/integrations.contentful-cms': minor +'@o2s/integrations.strapi-cms': minor +'@o2s/integrations.medusajs': minor +'@o2s/integrations.algolia': minor +'@o2s/integrations.zendesk': minor +'@o2s/integrations.redis': minor +--- + +Across the integrations (Contentful, Strapi, Algolia, Medusa, Redis, Zendesk) tests cover primarily the service and mapper layers (including error handling), verifying configuration, request shaping and delegation to SDK/clients. From f4adc8cd0a9468dfa32047d0e2eaf34762e7403c Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Wed, 4 Feb 2026 15:40:41 +0100 Subject: [PATCH 15/15] chore(deps): update package-lock file --- package-lock.json | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 358d5c54f..beb25ad23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51662,7 +51662,7 @@ "name": "@o2s/vitest-config", "version": "1.1.0", "devDependencies": { - "glob": "13.0.0", + "glob": "^13.0.0", "nyc": "^17.1.0", "open-cli": "^8.0.0", "unplugin-swc": "^1.5.9", @@ -51858,11 +51858,13 @@ "@o2s/lint-staged-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "eslint": "^9.39.2", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.17" }, "peerDependencies": { "@nestjs/axios": "^4.0.1", @@ -52095,11 +52097,13 @@ "@o2s/lint-staged-config": "*", "@o2s/prettier-config": "*", "@o2s/typescript-config": "*", + "@o2s/vitest-config": "*", "concurrently": "^9.2.1", "eslint": "^9.39.2", "prettier": "^3.8.1", "tsc-alias": "^1.8.16", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.17" }, "peerDependencies": { "@nestjs/axios": "^4.0.1",