diff --git a/.github/workflows/cd-main.yml b/.github/workflows/cd-main.yml index f8f5236..5633e39 100644 --- a/.github/workflows/cd-main.yml +++ b/.github/workflows/cd-main.yml @@ -10,7 +10,7 @@ name: CD Production # If someone merges to main without going through preprod, # this step fails and the deployment is blocked. # -# Flow: quality-gate → verify-image → promote → deploy → smoke-tests → notify +# Flow: quality-gate → verify-image → promote → deploy → notify # # Secrets required: # REGISTRY_TOKEN — Scaleway registry (read/write) @@ -231,47 +231,11 @@ jobs: kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=120s echo "Rollback complete. Previous version is live." - # ── 5. Smoke Tests ─────────────────────────────────────────────────── - # kubectl rollout status already verified pod readiness. - # These smoke tests validate the full network path: - # Cloudflare → Hetzner LB → Traefik → pod. - smoke-tests: - name: Smoke Tests - runs-on: ubuntu-latest - needs: deploy - steps: - - name: Wait for LB propagation - run: sleep 30 - - - name: Health — Backend - run: | - for i in {1..12}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ - "${{ secrets.PROD_BACKEND_URL }}/api/v1/health" 2>/dev/null || echo "000") - echo " Attempt $i: HTTP $STATUS" - if [ "$STATUS" = "200" ]; then echo "Backend OK."; exit 0; fi - sleep 15 - done - echo "CRITICAL: Backend unreachable after 12 attempts." - exit 1 - - - name: Health — Frontend - run: | - for i in {1..12}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ - "${{ secrets.PROD_FRONTEND_URL }}" 2>/dev/null || echo "000") - echo " Attempt $i: HTTP $STATUS" - if [ "$STATUS" = "200" ]; then echo "Frontend OK."; exit 0; fi - sleep 15 - done - echo "CRITICAL: Frontend unreachable after 12 attempts." - exit 1 - # ── Notifications ──────────────────────────────────────────────────── notify-success: name: Notify Success runs-on: ubuntu-latest - needs: [verify-image, smoke-tests] + needs: [verify-image, deploy] if: success() steps: - run: | @@ -292,7 +256,7 @@ jobs: notify-failure: name: Notify Failure runs-on: ubuntu-latest - needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy, smoke-tests] + needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy] if: failure() steps: - run: | diff --git a/.github/workflows/cd-preprod.yml b/.github/workflows/cd-preprod.yml index 830f19c..9da16bf 100644 --- a/.github/workflows/cd-preprod.yml +++ b/.github/workflows/cd-preprod.yml @@ -1,7 +1,7 @@ name: CD Preprod # Full pipeline triggered on every push to preprod. -# Flow: lint → unit tests → integration tests → docker build → deploy → smoke tests → notify +# Flow: lint → unit tests → integration tests → docker build → deploy → notify # # Secrets required: # REGISTRY_TOKEN — Scaleway registry (read/write) @@ -217,60 +217,68 @@ jobs: NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }} + build-log-exporter: + name: Build Log Exporter + runs-on: ubuntu-latest + needs: integration-tests + outputs: + sha: ${{ steps.sha.outputs.short }} + steps: + - uses: actions/checkout@v4 + - name: Short SHA + id: sha + run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: nologin + password: ${{ secrets.REGISTRY_TOKEN }} + - uses: docker/build-push-action@v5 + with: + context: ./apps/log-exporter + file: ./apps/log-exporter/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/xpeditis-log-exporter:preprod + ${{ env.REGISTRY }}/xpeditis-log-exporter:preprod-${{ steps.sha.outputs.short }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-log-exporter:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-log-exporter:buildcache,mode=max + platforms: linux/amd64,linux/arm64 + # ── 5. Deploy via Portainer ────────────────────────────────────────── deploy: name: Deploy to Preprod runs-on: ubuntu-latest - needs: [build-backend, build-frontend] + needs: [build-backend, build-frontend, build-log-exporter] environment: preprod steps: - name: Deploy backend run: | - curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${{ secrets.PORTAINER_WEBHOOK_BACKEND }}") + echo "Portainer response: HTTP $HTTP_CODE" + if [[ "$HTTP_CODE" != "2"* ]]; then + echo "ERROR: Portainer webhook failed with HTTP $HTTP_CODE" + exit 1 + fi echo "Backend webhook triggered." - name: Wait for backend startup run: sleep 20 - name: Deploy frontend run: | - curl -sf -X POST "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${{ secrets.PORTAINER_WEBHOOK_FRONTEND }}") + echo "Portainer response: HTTP $HTTP_CODE" + if [[ "$HTTP_CODE" != "2"* ]]; then + echo "ERROR: Portainer webhook failed with HTTP $HTTP_CODE" + exit 1 + fi echo "Frontend webhook triggered." - # ── 6. Smoke Tests ─────────────────────────────────────────────────── - smoke-tests: - name: Smoke Tests - runs-on: ubuntu-latest - needs: deploy - steps: - - name: Wait for services - run: sleep 40 - - name: Health — Backend - run: | - for i in {1..12}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ - "${{ secrets.PREPROD_BACKEND_URL }}/api/v1/health" 2>/dev/null || echo "000") - echo " Attempt $i: HTTP $STATUS" - if [ "$STATUS" = "200" ]; then echo "Backend OK."; exit 0; fi - sleep 15 - done - echo "Backend unreachable after 12 attempts." - exit 1 - - name: Health — Frontend - run: | - for i in {1..12}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ - "${{ secrets.PREPROD_FRONTEND_URL }}" 2>/dev/null || echo "000") - echo " Attempt $i: HTTP $STATUS" - if [ "$STATUS" = "200" ]; then echo "Frontend OK."; exit 0; fi - sleep 15 - done - echo "Frontend unreachable after 12 attempts." - exit 1 - # ── Notifications ──────────────────────────────────────────────────── notify-success: name: Notify Success runs-on: ubuntu-latest - needs: [build-backend, build-frontend, smoke-tests] + needs: [build-backend, build-frontend, deploy] if: success() steps: - run: | @@ -290,7 +298,7 @@ jobs: notify-failure: name: Notify Failure runs-on: ubuntu-latest - needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, integration-tests, build-backend, build-frontend, deploy, smoke-tests] + needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, integration-tests, build-backend, build-frontend, deploy] if: failure() steps: - run: | diff --git a/.gitignore b/.gitignore index d8748a3..d74b04b 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ lerna-debug.log* # Docker docker-compose.override.yml +stack-portainer.yaml +tmp.stack-portainer.yaml # Uploads uploads/ diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 7e7ada3..0ac03c6 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -19,6 +19,7 @@ import { WebhooksModule } from './application/webhooks/webhooks.module'; import { GDPRModule } from './application/gdpr/gdpr.module'; import { CsvBookingsModule } from './application/csv-bookings.module'; import { AdminModule } from './application/admin/admin.module'; +import { LogsModule } from './application/logs/logs.module'; import { SubscriptionsModule } from './application/subscriptions/subscriptions.module'; import { ApiKeysModule } from './application/api-keys/api-keys.module'; import { CacheModule } from './infrastructure/cache/cache.module'; @@ -67,6 +68,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; STRIPE_GOLD_YEARLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(), + LOG_EXPORTER_URL: Joi.string().uri().default('http://xpeditis-log-exporter:3200'), }), }), @@ -147,6 +149,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; AdminModule, SubscriptionsModule, ApiKeysModule, + LogsModule, ], controllers: [], providers: [ diff --git a/apps/backend/src/application/controllers/health.controller.ts b/apps/backend/src/application/controllers/health.controller.ts index 1952eec..67991b5 100644 --- a/apps/backend/src/application/controllers/health.controller.ts +++ b/apps/backend/src/application/controllers/health.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Public } from '../decorators/public.decorator'; +@Public() @ApiTags('health') @Controller('health') export class HealthController { diff --git a/apps/backend/src/application/logs/logs.controller.ts b/apps/backend/src/application/logs/logs.controller.ts new file mode 100644 index 0000000..1926c1e --- /dev/null +++ b/apps/backend/src/application/logs/logs.controller.ts @@ -0,0 +1,98 @@ +import { + Controller, + Get, + Query, + Res, + UseGuards, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { Response } from 'express'; +import { ConfigService } from '@nestjs/config'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RolesGuard } from '../guards/roles.guard'; +import { Roles } from '../decorators/roles.decorator'; + +@Controller('logs') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin') +export class LogsController { + private readonly logExporterUrl: string; + + constructor(private readonly configService: ConfigService) { + this.logExporterUrl = this.configService.get( + 'LOG_EXPORTER_URL', + 'http://xpeditis-log-exporter:3200', + ); + } + + /** + * GET /api/v1/logs/services + * Proxy → log-exporter /api/logs/services + */ + @Get('services') + async getServices() { + try { + const res = await fetch(`${this.logExporterUrl}/api/logs/services`, { + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`log-exporter error: ${res.status}`); + return res.json(); + } catch (err: any) { + throw new HttpException( + { error: err.message }, + HttpStatus.BAD_GATEWAY, + ); + } + } + + /** + * GET /api/v1/logs/export + * Proxy → log-exporter /api/logs/export (JSON or CSV) + */ + @Get('export') + async exportLogs( + @Query('service') service: string, + @Query('level') level: string, + @Query('search') search: string, + @Query('start') start: string, + @Query('end') end: string, + @Query('limit') limit: string, + @Query('format') format: string = 'json', + @Res() res: Response, + ) { + try { + const params = new URLSearchParams(); + if (service) params.set('service', service); + if (level) params.set('level', level); + if (search) params.set('search', search); + if (start) params.set('start', start); + if (end) params.set('end', end); + if (limit) params.set('limit', limit); + params.set('format', format); + + const upstream = await fetch( + `${this.logExporterUrl}/api/logs/export?${params}`, + { signal: AbortSignal.timeout(30000) }, + ); + + if (!upstream.ok) { + const body = await upstream.json().catch(() => ({})); + throw new HttpException(body, upstream.status); + } + + res.status(upstream.status); + upstream.headers.forEach((value, key) => { + if (['content-type', 'content-disposition'].includes(key.toLowerCase())) { + res.setHeader(key, value); + } + }); + + const buffer = await upstream.arrayBuffer(); + res.send(Buffer.from(buffer)); + } catch (err: any) { + if (err instanceof HttpException) throw err; + throw new HttpException({ error: err.message }, HttpStatus.BAD_GATEWAY); + } + } +} diff --git a/apps/backend/src/application/logs/logs.module.ts b/apps/backend/src/application/logs/logs.module.ts new file mode 100644 index 0000000..ca12157 --- /dev/null +++ b/apps/backend/src/application/logs/logs.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { LogsController } from './logs.controller'; + +@Module({ + imports: [ConfigModule], + controllers: [LogsController], +}) +export class LogsModule {} diff --git a/apps/backend/src/domain/entities/subscription.entity.spec.ts b/apps/backend/src/domain/entities/subscription.entity.spec.ts index 4e93f08..190c521 100644 --- a/apps/backend/src/domain/entities/subscription.entity.spec.ts +++ b/apps/backend/src/domain/entities/subscription.entity.spec.ts @@ -21,12 +21,12 @@ describe('Subscription Entity', () => { }; describe('create', () => { - it('should create a subscription with default FREE plan', () => { + it('should create a subscription with default BRONZE plan', () => { const subscription = createValidSubscription(); expect(subscription.id).toBe('sub-123'); expect(subscription.organizationId).toBe('org-123'); - expect(subscription.plan.value).toBe('FREE'); + expect(subscription.plan.value).toBe('BRONZE'); expect(subscription.status.value).toBe('ACTIVE'); expect(subscription.cancelAtPeriodEnd).toBe(false); }); @@ -35,10 +35,10 @@ describe('Subscription Entity', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); - expect(subscription.plan.value).toBe('STARTER'); + expect(subscription.plan.value).toBe('SILVER'); }); it('should create a subscription with Stripe IDs', () => { @@ -59,7 +59,7 @@ describe('Subscription Entity', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'PRO', + plan: 'GOLD', status: 'ACTIVE', stripeCustomerId: 'cus_123', stripeSubscriptionId: 'sub_stripe_123', @@ -71,57 +71,57 @@ describe('Subscription Entity', () => { }); expect(subscription.id).toBe('sub-123'); - expect(subscription.plan.value).toBe('PRO'); + expect(subscription.plan.value).toBe('GOLD'); expect(subscription.status.value).toBe('ACTIVE'); expect(subscription.cancelAtPeriodEnd).toBe(true); }); }); describe('maxLicenses', () => { - it('should return correct limits for FREE plan', () => { + it('should return correct limits for BRONZE plan', () => { const subscription = createValidSubscription(); - expect(subscription.maxLicenses).toBe(2); + expect(subscription.maxLicenses).toBe(1); }); - it('should return correct limits for STARTER plan', () => { + it('should return correct limits for SILVER plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); expect(subscription.maxLicenses).toBe(5); }); - it('should return correct limits for PRO plan', () => { + it('should return correct limits for GOLD plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.pro(), + plan: SubscriptionPlan.gold(), }); expect(subscription.maxLicenses).toBe(20); }); - it('should return -1 for ENTERPRISE plan (unlimited)', () => { + it('should return -1 for PLATINIUM plan (unlimited)', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.enterprise(), + plan: SubscriptionPlan.platinium(), }); expect(subscription.maxLicenses).toBe(-1); }); }); describe('isUnlimited', () => { - it('should return false for FREE plan', () => { + it('should return false for BRONZE plan', () => { const subscription = createValidSubscription(); expect(subscription.isUnlimited()).toBe(false); }); - it('should return true for ENTERPRISE plan', () => { + it('should return true for PLATINIUM plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.enterprise(), + plan: SubscriptionPlan.platinium(), }); expect(subscription.isUnlimited()).toBe(true); }); @@ -137,7 +137,7 @@ describe('Subscription Entity', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'FREE', + plan: 'BRONZE', status: 'TRIALING', stripeCustomerId: null, stripeSubscriptionId: null, @@ -154,7 +154,7 @@ describe('Subscription Entity', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'FREE', + plan: 'BRONZE', status: 'CANCELED', stripeCustomerId: null, stripeSubscriptionId: null, @@ -170,21 +170,20 @@ describe('Subscription Entity', () => { describe('canAllocateLicenses', () => { it('should return true when licenses are available', () => { - const subscription = createValidSubscription(); + const subscription = createValidSubscription(); // BRONZE = 1 license expect(subscription.canAllocateLicenses(0, 1)).toBe(true); - expect(subscription.canAllocateLicenses(1, 1)).toBe(true); }); it('should return false when no licenses available', () => { const subscription = createValidSubscription(); - expect(subscription.canAllocateLicenses(2, 1)).toBe(false); // FREE has 2 licenses + expect(subscription.canAllocateLicenses(1, 1)).toBe(false); // BRONZE has 1 license max }); - it('should always return true for ENTERPRISE plan', () => { + it('should always return true for PLATINIUM plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.enterprise(), + plan: SubscriptionPlan.platinium(), }); expect(subscription.canAllocateLicenses(1000, 100)).toBe(true); }); @@ -193,7 +192,7 @@ describe('Subscription Entity', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'FREE', + plan: 'BRONZE', status: 'CANCELED', stripeCustomerId: null, stripeSubscriptionId: null, @@ -208,23 +207,23 @@ describe('Subscription Entity', () => { }); describe('canUpgradeTo', () => { - it('should allow upgrade from FREE to STARTER', () => { + it('should allow upgrade from BRONZE to SILVER', () => { const subscription = createValidSubscription(); - expect(subscription.canUpgradeTo(SubscriptionPlan.starter())).toBe(true); + expect(subscription.canUpgradeTo(SubscriptionPlan.silver())).toBe(true); }); - it('should allow upgrade from FREE to PRO', () => { + it('should allow upgrade from BRONZE to GOLD', () => { const subscription = createValidSubscription(); - expect(subscription.canUpgradeTo(SubscriptionPlan.pro())).toBe(true); + expect(subscription.canUpgradeTo(SubscriptionPlan.gold())).toBe(true); }); it('should not allow downgrade via canUpgradeTo', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); - expect(subscription.canUpgradeTo(SubscriptionPlan.free())).toBe(false); + expect(subscription.canUpgradeTo(SubscriptionPlan.bronze())).toBe(false); }); }); @@ -233,34 +232,34 @@ describe('Subscription Entity', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); - expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true); + expect(subscription.canDowngradeTo(SubscriptionPlan.bronze(), 1)).toBe(true); }); it('should prevent downgrade when user count exceeds new plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); - expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false); + expect(subscription.canDowngradeTo(SubscriptionPlan.bronze(), 5)).toBe(false); }); }); describe('updatePlan', () => { it('should update to new plan when valid', () => { const subscription = createValidSubscription(); - const updated = subscription.updatePlan(SubscriptionPlan.starter(), 1); + const updated = subscription.updatePlan(SubscriptionPlan.silver(), 1); - expect(updated.plan.value).toBe('STARTER'); + expect(updated.plan.value).toBe('SILVER'); }); it('should throw when subscription is not active', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'FREE', + plan: 'BRONZE', status: 'CANCELED', stripeCustomerId: null, stripeSubscriptionId: null, @@ -271,7 +270,7 @@ describe('Subscription Entity', () => { updatedAt: new Date(), }); - expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow( + expect(() => subscription.updatePlan(SubscriptionPlan.silver(), 0)).toThrow( SubscriptionNotActiveException ); }); @@ -280,10 +279,10 @@ describe('Subscription Entity', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.pro(), + plan: SubscriptionPlan.gold(), }); - expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow( + expect(() => subscription.updatePlan(SubscriptionPlan.silver(), 10)).toThrow( InvalidSubscriptionDowngradeException ); }); @@ -341,7 +340,7 @@ describe('Subscription Entity', () => { const subscription = Subscription.fromPersistence({ id: 'sub-123', organizationId: 'org-123', - plan: 'STARTER', + plan: 'SILVER', status: 'ACTIVE', stripeCustomerId: 'cus_123', stripeSubscriptionId: 'sub_123', @@ -368,17 +367,17 @@ describe('Subscription Entity', () => { }); describe('isFree and isPaid', () => { - it('should return true for isFree when FREE plan', () => { + it('should return true for isFree when BRONZE plan', () => { const subscription = createValidSubscription(); expect(subscription.isFree()).toBe(true); expect(subscription.isPaid()).toBe(false); }); - it('should return true for isPaid when STARTER plan', () => { + it('should return true for isPaid when SILVER plan', () => { const subscription = Subscription.create({ id: 'sub-123', organizationId: 'org-123', - plan: SubscriptionPlan.starter(), + plan: SubscriptionPlan.silver(), }); expect(subscription.isFree()).toBe(false); expect(subscription.isPaid()).toBe(true); @@ -397,7 +396,7 @@ describe('Subscription Entity', () => { expect(obj.id).toBe('sub-123'); expect(obj.organizationId).toBe('org-123'); - expect(obj.plan).toBe('FREE'); + expect(obj.plan).toBe('BRONZE'); expect(obj.status).toBe('ACTIVE'); expect(obj.stripeCustomerId).toBe('cus_123'); }); diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts index 81564a3..ddbcaa2 100644 --- a/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.spec.ts @@ -8,31 +8,56 @@ import { SubscriptionPlan } from './subscription-plan.vo'; describe('SubscriptionPlan Value Object', () => { describe('static factory methods', () => { - it('should create FREE plan', () => { + it('should create BRONZE plan via bronze()', () => { + const plan = SubscriptionPlan.bronze(); + expect(plan.value).toBe('BRONZE'); + }); + + it('should create SILVER plan via silver()', () => { + const plan = SubscriptionPlan.silver(); + expect(plan.value).toBe('SILVER'); + }); + + it('should create GOLD plan via gold()', () => { + const plan = SubscriptionPlan.gold(); + expect(plan.value).toBe('GOLD'); + }); + + it('should create PLATINIUM plan via platinium()', () => { + const plan = SubscriptionPlan.platinium(); + expect(plan.value).toBe('PLATINIUM'); + }); + + it('should create BRONZE plan via free() alias', () => { const plan = SubscriptionPlan.free(); - expect(plan.value).toBe('FREE'); + expect(plan.value).toBe('BRONZE'); }); - it('should create STARTER plan', () => { + it('should create SILVER plan via starter() alias', () => { const plan = SubscriptionPlan.starter(); - expect(plan.value).toBe('STARTER'); + expect(plan.value).toBe('SILVER'); }); - it('should create PRO plan', () => { + it('should create GOLD plan via pro() alias', () => { const plan = SubscriptionPlan.pro(); - expect(plan.value).toBe('PRO'); + expect(plan.value).toBe('GOLD'); }); - it('should create ENTERPRISE plan', () => { + it('should create PLATINIUM plan via enterprise() alias', () => { const plan = SubscriptionPlan.enterprise(); - expect(plan.value).toBe('ENTERPRISE'); + expect(plan.value).toBe('PLATINIUM'); }); }); describe('create', () => { - it('should create plan from valid type', () => { - const plan = SubscriptionPlan.create('STARTER'); - expect(plan.value).toBe('STARTER'); + it('should create plan from valid type SILVER', () => { + const plan = SubscriptionPlan.create('SILVER'); + expect(plan.value).toBe('SILVER'); + }); + + it('should create plan from valid type BRONZE', () => { + const plan = SubscriptionPlan.create('BRONZE'); + expect(plan.value).toBe('BRONZE'); }); it('should throw for invalid plan type', () => { @@ -41,9 +66,29 @@ describe('SubscriptionPlan Value Object', () => { }); describe('fromString', () => { - it('should create plan from lowercase string', () => { + it('should create SILVER from lowercase "silver"', () => { + const plan = SubscriptionPlan.fromString('silver'); + expect(plan.value).toBe('SILVER'); + }); + + it('should map legacy "starter" to SILVER', () => { const plan = SubscriptionPlan.fromString('starter'); - expect(plan.value).toBe('STARTER'); + expect(plan.value).toBe('SILVER'); + }); + + it('should map legacy "free" to BRONZE', () => { + const plan = SubscriptionPlan.fromString('free'); + expect(plan.value).toBe('BRONZE'); + }); + + it('should map legacy "pro" to GOLD', () => { + const plan = SubscriptionPlan.fromString('pro'); + expect(plan.value).toBe('GOLD'); + }); + + it('should map legacy "enterprise" to PLATINIUM', () => { + const plan = SubscriptionPlan.fromString('enterprise'); + expect(plan.value).toBe('PLATINIUM'); }); it('should throw for invalid string', () => { @@ -52,146 +97,150 @@ describe('SubscriptionPlan Value Object', () => { }); describe('maxLicenses', () => { - it('should return 2 for FREE plan', () => { - const plan = SubscriptionPlan.free(); - expect(plan.maxLicenses).toBe(2); + it('should return 1 for BRONZE plan', () => { + const plan = SubscriptionPlan.bronze(); + expect(plan.maxLicenses).toBe(1); }); - it('should return 5 for STARTER plan', () => { - const plan = SubscriptionPlan.starter(); + it('should return 5 for SILVER plan', () => { + const plan = SubscriptionPlan.silver(); expect(plan.maxLicenses).toBe(5); }); - it('should return 20 for PRO plan', () => { - const plan = SubscriptionPlan.pro(); + it('should return 20 for GOLD plan', () => { + const plan = SubscriptionPlan.gold(); expect(plan.maxLicenses).toBe(20); }); - it('should return -1 (unlimited) for ENTERPRISE plan', () => { - const plan = SubscriptionPlan.enterprise(); + it('should return -1 (unlimited) for PLATINIUM plan', () => { + const plan = SubscriptionPlan.platinium(); expect(plan.maxLicenses).toBe(-1); }); }); describe('isUnlimited', () => { - it('should return false for FREE plan', () => { - expect(SubscriptionPlan.free().isUnlimited()).toBe(false); + it('should return false for BRONZE plan', () => { + expect(SubscriptionPlan.bronze().isUnlimited()).toBe(false); }); - it('should return false for STARTER plan', () => { - expect(SubscriptionPlan.starter().isUnlimited()).toBe(false); + it('should return false for SILVER plan', () => { + expect(SubscriptionPlan.silver().isUnlimited()).toBe(false); }); - it('should return false for PRO plan', () => { - expect(SubscriptionPlan.pro().isUnlimited()).toBe(false); + it('should return false for GOLD plan', () => { + expect(SubscriptionPlan.gold().isUnlimited()).toBe(false); }); - it('should return true for ENTERPRISE plan', () => { - expect(SubscriptionPlan.enterprise().isUnlimited()).toBe(true); + it('should return true for PLATINIUM plan', () => { + expect(SubscriptionPlan.platinium().isUnlimited()).toBe(true); }); }); describe('isPaid', () => { - it('should return false for FREE plan', () => { - expect(SubscriptionPlan.free().isPaid()).toBe(false); + it('should return false for BRONZE plan', () => { + expect(SubscriptionPlan.bronze().isPaid()).toBe(false); }); - it('should return true for STARTER plan', () => { - expect(SubscriptionPlan.starter().isPaid()).toBe(true); + it('should return true for SILVER plan', () => { + expect(SubscriptionPlan.silver().isPaid()).toBe(true); }); - it('should return true for PRO plan', () => { - expect(SubscriptionPlan.pro().isPaid()).toBe(true); + it('should return true for GOLD plan', () => { + expect(SubscriptionPlan.gold().isPaid()).toBe(true); }); - it('should return true for ENTERPRISE plan', () => { - expect(SubscriptionPlan.enterprise().isPaid()).toBe(true); + it('should return true for PLATINIUM plan', () => { + expect(SubscriptionPlan.platinium().isPaid()).toBe(true); }); }); describe('isFree', () => { - it('should return true for FREE plan', () => { - expect(SubscriptionPlan.free().isFree()).toBe(true); + it('should return true for BRONZE plan', () => { + expect(SubscriptionPlan.bronze().isFree()).toBe(true); }); - it('should return false for STARTER plan', () => { - expect(SubscriptionPlan.starter().isFree()).toBe(false); + it('should return false for SILVER plan', () => { + expect(SubscriptionPlan.silver().isFree()).toBe(false); }); }); describe('canAccommodateUsers', () => { - it('should return true for FREE plan with 2 users', () => { - expect(SubscriptionPlan.free().canAccommodateUsers(2)).toBe(true); + it('should return true for BRONZE plan with 1 user', () => { + expect(SubscriptionPlan.bronze().canAccommodateUsers(1)).toBe(true); }); - it('should return false for FREE plan with 3 users', () => { - expect(SubscriptionPlan.free().canAccommodateUsers(3)).toBe(false); + it('should return false for BRONZE plan with 2 users', () => { + expect(SubscriptionPlan.bronze().canAccommodateUsers(2)).toBe(false); }); - it('should return true for STARTER plan with 5 users', () => { - expect(SubscriptionPlan.starter().canAccommodateUsers(5)).toBe(true); + it('should return true for SILVER plan with 5 users', () => { + expect(SubscriptionPlan.silver().canAccommodateUsers(5)).toBe(true); }); - it('should always return true for ENTERPRISE plan', () => { - expect(SubscriptionPlan.enterprise().canAccommodateUsers(1000)).toBe(true); + it('should always return true for PLATINIUM plan', () => { + expect(SubscriptionPlan.platinium().canAccommodateUsers(1000)).toBe(true); }); }); describe('canUpgradeTo', () => { - it('should allow upgrade from FREE to STARTER', () => { - expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.starter())).toBe(true); + it('should allow upgrade from BRONZE to SILVER', () => { + expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.silver())).toBe(true); }); - it('should allow upgrade from FREE to PRO', () => { - expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.pro())).toBe(true); + it('should allow upgrade from BRONZE to GOLD', () => { + expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.gold())).toBe(true); }); - it('should allow upgrade from FREE to ENTERPRISE', () => { - expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.enterprise())).toBe(true); + it('should allow upgrade from BRONZE to PLATINIUM', () => { + expect(SubscriptionPlan.bronze().canUpgradeTo(SubscriptionPlan.platinium())).toBe(true); }); - it('should allow upgrade from STARTER to PRO', () => { - expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.pro())).toBe(true); + it('should allow upgrade from SILVER to GOLD', () => { + expect(SubscriptionPlan.silver().canUpgradeTo(SubscriptionPlan.gold())).toBe(true); }); - it('should not allow downgrade from STARTER to FREE', () => { - expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.free())).toBe(false); + it('should not allow downgrade from SILVER to BRONZE', () => { + expect(SubscriptionPlan.silver().canUpgradeTo(SubscriptionPlan.bronze())).toBe(false); }); it('should not allow same plan upgrade', () => { - expect(SubscriptionPlan.pro().canUpgradeTo(SubscriptionPlan.pro())).toBe(false); + expect(SubscriptionPlan.gold().canUpgradeTo(SubscriptionPlan.gold())).toBe(false); }); }); describe('canDowngradeTo', () => { - it('should allow downgrade from STARTER to FREE when users fit', () => { - expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true); + it('should allow downgrade from SILVER to BRONZE when users fit', () => { + expect(SubscriptionPlan.silver().canDowngradeTo(SubscriptionPlan.bronze(), 1)).toBe(true); }); - it('should not allow downgrade from STARTER to FREE when users exceed', () => { - expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false); + it('should not allow downgrade from SILVER to BRONZE when users exceed', () => { + expect(SubscriptionPlan.silver().canDowngradeTo(SubscriptionPlan.bronze(), 5)).toBe(false); }); it('should not allow upgrade via canDowngradeTo', () => { - expect(SubscriptionPlan.free().canDowngradeTo(SubscriptionPlan.starter(), 1)).toBe(false); + expect(SubscriptionPlan.bronze().canDowngradeTo(SubscriptionPlan.silver(), 1)).toBe(false); }); }); describe('plan details', () => { - it('should return correct name for FREE plan', () => { - expect(SubscriptionPlan.free().name).toBe('Free'); + it('should return correct name for BRONZE plan', () => { + expect(SubscriptionPlan.bronze().name).toBe('Bronze'); }); - it('should return correct prices for STARTER plan', () => { - const plan = SubscriptionPlan.starter(); - expect(plan.monthlyPriceEur).toBe(49); - expect(plan.yearlyPriceEur).toBe(470); + it('should return correct name for SILVER plan', () => { + expect(SubscriptionPlan.silver().name).toBe('Silver'); }); - it('should return features for PRO plan', () => { - const plan = SubscriptionPlan.pro(); - expect(plan.features).toContain('Up to 20 users'); - expect(plan.features).toContain('API access'); + it('should return correct prices for SILVER plan', () => { + const plan = SubscriptionPlan.silver(); + expect(plan.monthlyPriceEur).toBe(249); + expect(plan.yearlyPriceEur).toBe(2739); + }); + + it('should return features for GOLD plan', () => { + const plan = SubscriptionPlan.gold(); + expect(plan.features).toContain("Jusqu'à 20 utilisateurs"); + expect(plan.features).toContain('Intégration API'); }); }); @@ -200,24 +249,24 @@ describe('SubscriptionPlan Value Object', () => { const plans = SubscriptionPlan.getAllPlans(); expect(plans).toHaveLength(4); - expect(plans.map(p => p.value)).toEqual(['FREE', 'STARTER', 'PRO', 'ENTERPRISE']); + expect(plans.map(p => p.value)).toEqual(['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']); }); }); describe('equals', () => { it('should return true for same plan', () => { - expect(SubscriptionPlan.free().equals(SubscriptionPlan.free())).toBe(true); + expect(SubscriptionPlan.bronze().equals(SubscriptionPlan.bronze())).toBe(true); }); it('should return false for different plans', () => { - expect(SubscriptionPlan.free().equals(SubscriptionPlan.starter())).toBe(false); + expect(SubscriptionPlan.bronze().equals(SubscriptionPlan.silver())).toBe(false); }); }); describe('toString', () => { it('should return plan value as string', () => { - expect(SubscriptionPlan.free().toString()).toBe('FREE'); - expect(SubscriptionPlan.starter().toString()).toBe('STARTER'); + expect(SubscriptionPlan.bronze().toString()).toBe('BRONZE'); + expect(SubscriptionPlan.silver().toString()).toBe('SILVER'); }); }); }); diff --git a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts index f198956..5ffa990 100644 --- a/apps/backend/src/domain/value-objects/subscription-plan.vo.ts +++ b/apps/backend/src/domain/value-objects/subscription-plan.vo.ts @@ -55,7 +55,7 @@ const PLAN_DETAILS: Record = { name: 'Silver', maxLicenses: 5, monthlyPriceEur: 249, - yearlyPriceEur: 2739, // 249 * 11 months + yearlyPriceEur: 2739, maxShipmentsPerYear: -1, commissionRatePercent: 3, statusBadge: 'silver', @@ -75,7 +75,7 @@ const PLAN_DETAILS: Record = { name: 'Gold', maxLicenses: 20, monthlyPriceEur: 899, - yearlyPriceEur: 9889, // 899 * 11 months + yearlyPriceEur: 9889, maxShipmentsPerYear: -1, commissionRatePercent: 2, statusBadge: 'gold', @@ -225,59 +225,35 @@ export class SubscriptionPlan { return PLAN_DETAILS[this.plan].planFeatures; } - /** - * Check if this plan includes a specific feature - */ hasFeature(feature: PlanFeature): boolean { return this.planFeatures.includes(feature); } - /** - * Returns true if this plan has unlimited licenses - */ isUnlimited(): boolean { return this.maxLicenses === -1; } - /** - * Returns true if this plan has unlimited shipments - */ hasUnlimitedShipments(): boolean { return this.maxShipmentsPerYear === -1; } - /** - * Returns true if this is a paid plan - */ isPaid(): boolean { return this.plan !== 'BRONZE'; } - /** - * Returns true if this is the free (Bronze) plan - */ isFree(): boolean { return this.plan === 'BRONZE'; } - /** - * Returns true if this plan has custom pricing (Platinium) - */ isCustomPricing(): boolean { return this.plan === 'PLATINIUM'; } - /** - * Check if a given number of users can be accommodated by this plan - */ canAccommodateUsers(userCount: number): boolean { if (this.isUnlimited()) return true; return userCount <= this.maxLicenses; } - /** - * Check if upgrade to target plan is allowed - */ canUpgradeTo(targetPlan: SubscriptionPlan): boolean { const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const currentIndex = planOrder.indexOf(this.plan); @@ -285,15 +261,12 @@ export class SubscriptionPlan { return targetIndex > currentIndex; } - /** - * Check if downgrade to target plan is allowed given current user count - */ canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean { const planOrder: SubscriptionPlanType[] = ['BRONZE', 'SILVER', 'GOLD', 'PLATINIUM']; const currentIndex = planOrder.indexOf(this.plan); const targetIndex = planOrder.indexOf(targetPlan.value); - if (targetIndex >= currentIndex) return false; // Not a downgrade + if (targetIndex >= currentIndex) return false; return targetPlan.canAccommodateUsers(currentUserCount); } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts index 1e07da1..bfc2d1c 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts @@ -5,7 +5,20 @@ */ import { Subscription } from '@domain/entities/subscription.entity'; -import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity'; +import { SubscriptionOrmEntity, SubscriptionPlanOrmType } from '../entities/subscription.orm-entity'; + +/** Maps canonical domain plan names back to the values stored in the DB. */ +const DOMAIN_TO_ORM_PLAN: Record = { + FREE: 'BRONZE', + STARTER: 'SILVER', + PRO: 'GOLD', + ENTERPRISE: 'PLATINIUM', + // Pass-through for any value already in ORM format + BRONZE: 'BRONZE', + SILVER: 'SILVER', + GOLD: 'GOLD', + PLATINIUM: 'PLATINIUM', +}; export class SubscriptionOrmMapper { /** @@ -17,7 +30,7 @@ export class SubscriptionOrmMapper { orm.id = props.id; orm.organizationId = props.organizationId; - orm.plan = props.plan; + orm.plan = DOMAIN_TO_ORM_PLAN[props.plan] ?? 'BRONZE'; orm.status = props.status; orm.stripeCustomerId = props.stripeCustomerId; orm.stripeSubscriptionId = props.stripeSubscriptionId; diff --git a/apps/frontend/app/dashboard/admin/logs/page.tsx b/apps/frontend/app/dashboard/admin/logs/page.tsx index edf04ed..b40fe25 100644 --- a/apps/frontend/app/dashboard/admin/logs/page.tsx +++ b/apps/frontend/app/dashboard/admin/logs/page.tsx @@ -11,9 +11,9 @@ import { Bug, Server, } from 'lucide-react'; +import { get, download } from '@/lib/api/client'; -const LOG_EXPORTER_URL = - process.env.NEXT_PUBLIC_LOG_EXPORTER_URL || 'http://localhost:3200'; +const LOGS_PREFIX = '/api/v1/logs'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -125,8 +125,7 @@ export default function AdminLogsPage() { // Load available services useEffect(() => { - fetch(`${LOG_EXPORTER_URL}/api/logs/services`) - .then(r => r.json()) + get<{ services: string[] }>(`${LOGS_PREFIX}/services`) .then(d => setServices(d.services || [])) .catch(() => {}); }, []); @@ -150,14 +149,9 @@ export default function AdminLogsPage() { setLoading(true); setError(null); try { - const res = await fetch( - `${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString('json')}`, + const data = await get( + `${LOGS_PREFIX}/export?${buildQueryString('json')}`, ); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `HTTP ${res.status}`); - } - const data: LogsResponse = await res.json(); setLogs(data.logs || []); setTotal(data.total || 0); } catch (err: any) { @@ -174,19 +168,11 @@ export default function AdminLogsPage() { const handleExport = async (format: 'json' | 'csv') => { setExportLoading(true); try { - const res = await fetch( - `${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString(format)}`, + const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`; + await download( + `${LOGS_PREFIX}/export?${buildQueryString(format)}`, + filename, ); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); } catch (err: any) { setError(err.message); } finally { @@ -384,8 +370,7 @@ export default function AdminLogsPage() { Impossible de contacter le log-exporter : {error}
- Vérifiez que le container log-exporter est démarré sur{' '} - {LOG_EXPORTER_URL} + Vérifiez que le backend et le log-exporter sont démarrés. diff --git a/apps/frontend/e2e/booking-workflow.spec.ts b/apps/frontend/e2e/booking-workflow.spec.ts index 04e1405..7e4d1ed 100644 --- a/apps/frontend/e2e/booking-workflow.spec.ts +++ b/apps/frontend/e2e/booking-workflow.spec.ts @@ -77,7 +77,7 @@ test.describe('Complete Booking Workflow', () => { // Step 4: Select a Rate and Create Booking await test.step('Select Rate and Create Booking', async () => { // Select first available rate - await page.locator('.rate-card').first().click('button:has-text("Book")'); + await page.locator('.rate-card').first().locator('button:has-text("Book")').click(); // Should navigate to booking form await expect(page).toHaveURL(/.*bookings\/create/); diff --git a/apps/frontend/jest.config.js b/apps/frontend/jest.config.js new file mode 100644 index 0000000..fefd61b --- /dev/null +++ b/apps/frontend/jest.config.js @@ -0,0 +1,24 @@ +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({ dir: './' }); + +/** @type {import('jest').Config} */ +const customConfig = { + testEnvironment: 'jest-environment-jsdom', + setupFilesAfterEnv: ['/jest.setup.ts'], + testMatch: [ + '/src/**/*.{spec,test}.{ts,tsx}', + '/src/**/__tests__/**/*.{spec,test}.{ts,tsx}', + ], + testPathIgnorePatterns: [ + '/node_modules/', + '/.next/', + '/e2e/', + ], + moduleNameMapper: { + '^@/app/(.*)$': '/app/$1', + '^@/(.*)$': '/src/$1', + }, +}; + +module.exports = createJestConfig(customConfig); diff --git a/apps/frontend/jest.setup.ts b/apps/frontend/jest.setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/apps/frontend/jest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 2c862b3..d70090c 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -44,6 +44,7 @@ "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", "@types/file-saver": "^2.0.7", + "@types/jest": "^29.5.12", "@types/node": "^20.10.5", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", @@ -2767,6 +2768,52 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 4aa2e93..e34a647 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -8,7 +8,7 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit", + "type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "test": "jest", "test:watch": "jest --watch", "test:e2e": "playwright test" @@ -49,6 +49,7 @@ "@playwright/test": "^1.56.0", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", + "@types/jest": "^29.5.12", "@types/file-saver": "^2.0.7", "@types/node": "^20.10.5", "@types/react": "^18.2.45", diff --git a/apps/frontend/src/__tests__/hooks/useCompanies.test.tsx b/apps/frontend/src/__tests__/hooks/useCompanies.test.tsx new file mode 100644 index 0000000..94bdd38 --- /dev/null +++ b/apps/frontend/src/__tests__/hooks/useCompanies.test.tsx @@ -0,0 +1,143 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useCompanies } from '@/hooks/useCompanies'; +import { getAvailableCompanies } from '@/lib/api/csv-rates'; + +jest.mock('@/lib/api/csv-rates', () => ({ + getAvailableCompanies: jest.fn(), +})); + +const mockGetAvailableCompanies = jest.mocked(getAvailableCompanies); + +const MOCK_COMPANIES = ['Maersk', 'MSC', 'CMA CGM', 'Hapag-Lloyd']; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('useCompanies', () => { + describe('initial state', () => { + it('starts with loading=true', () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + const { result } = renderHook(() => useCompanies()); + expect(result.current.loading).toBe(true); + }); + + it('starts with an empty companies array', () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + const { result } = renderHook(() => useCompanies()); + expect(result.current.companies).toEqual([]); + }); + + it('starts with error=null', () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + const { result } = renderHook(() => useCompanies()); + expect(result.current.error).toBeNull(); + }); + }); + + describe('on mount — success', () => { + it('fetches companies automatically on mount', async () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + + renderHook(() => useCompanies()); + + await waitFor(() => { + expect(mockGetAvailableCompanies).toHaveBeenCalledTimes(1); + }); + }); + + it('populates companies after a successful fetch', async () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.companies).toEqual(MOCK_COMPANIES); + expect(result.current.error).toBeNull(); + }); + + it('handles an empty companies list', async () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: [], total: 0 }); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.companies).toEqual([]); + }); + }); + + describe('on mount — error', () => { + it('sets error when the API call fails', async () => { + mockGetAvailableCompanies.mockRejectedValue(new Error('Service unavailable')); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Service unavailable'); + expect(result.current.companies).toEqual([]); + }); + + it('uses a default error message when the error has no message', async () => { + mockGetAvailableCompanies.mockRejectedValue({}); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Failed to fetch companies'); + }); + }); + + describe('refetch', () => { + it('exposes a refetch function', async () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(typeof result.current.refetch).toBe('function'); + }); + + it('re-triggers the API call when refetch is invoked', async () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockGetAvailableCompanies).toHaveBeenCalledTimes(2); + }); + + it('updates companies with fresh data on refetch', async () => { + mockGetAvailableCompanies + .mockResolvedValueOnce({ companies: ['Maersk'], total: 1 }) + .mockResolvedValueOnce({ companies: ['Maersk', 'MSC'], total: 2 }); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => expect(result.current.companies).toEqual(['Maersk'])); + + await act(async () => { + await result.current.refetch(); + }); + + expect(result.current.companies).toEqual(['Maersk', 'MSC']); + }); + }); +}); diff --git a/apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx b/apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx new file mode 100644 index 0000000..37be54f --- /dev/null +++ b/apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx @@ -0,0 +1,198 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useCsvRateSearch } from '@/hooks/useCsvRateSearch'; +import { searchCsvRates } from '@/lib/api/csv-rates'; +import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rate-filters'; + +jest.mock('@/lib/api/csv-rates', () => ({ + searchCsvRates: jest.fn(), +})); + +const mockSearchCsvRates = jest.mocked(searchCsvRates); + +const mockRequest: CsvRateSearchRequest = { + origin: 'Le Havre', + destination: 'Shanghai', + volumeCBM: 10, + weightKG: 5000, +}; + +const mockResponse: CsvRateSearchResponse = { + results: [ + { + companyName: 'Maersk', + origin: 'Le Havre', + destination: 'Shanghai', + containerType: '40ft', + priceUSD: 2500, + priceEUR: 2300, + primaryCurrency: 'USD', + hasSurcharges: false, + surchargeDetails: null, + transitDays: 30, + validUntil: '2024-12-31', + source: 'CSV', + matchScore: 95, + }, + ], + totalResults: 1, + searchedFiles: ['maersk-rates.csv'], + searchedAt: '2024-03-01T10:00:00Z', + appliedFilters: {}, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('useCsvRateSearch', () => { + describe('initial state', () => { + it('starts with data=null', () => { + const { result } = renderHook(() => useCsvRateSearch()); + expect(result.current.data).toBeNull(); + }); + + it('starts with loading=false', () => { + const { result } = renderHook(() => useCsvRateSearch()); + expect(result.current.loading).toBe(false); + }); + + it('starts with error=null', () => { + const { result } = renderHook(() => useCsvRateSearch()); + expect(result.current.error).toBeNull(); + }); + + it('exposes a search function', () => { + const { result } = renderHook(() => useCsvRateSearch()); + expect(typeof result.current.search).toBe('function'); + }); + + it('exposes a reset function', () => { + const { result } = renderHook(() => useCsvRateSearch()); + expect(typeof result.current.reset).toBe('function'); + }); + }); + + describe('search — success path', () => { + it('sets loading=true while the request is in flight', async () => { + let resolveSearch: (v: CsvRateSearchResponse) => void; + mockSearchCsvRates.mockReturnValue( + new Promise(resolve => { + resolveSearch = resolve; + }) + ); + + const { result } = renderHook(() => useCsvRateSearch()); + + act(() => { + result.current.search(mockRequest); + }); + + expect(result.current.loading).toBe(true); + + await act(async () => { + resolveSearch!(mockResponse); + }); + }); + + it('sets data and clears loading after a successful search', async () => { + mockSearchCsvRates.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useCsvRateSearch()); + + await act(async () => { + await result.current.search(mockRequest); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(mockResponse); + expect(result.current.error).toBeNull(); + }); + + it('calls searchCsvRates with the given request', async () => { + mockSearchCsvRates.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useCsvRateSearch()); + + await act(async () => { + await result.current.search(mockRequest); + }); + + expect(mockSearchCsvRates).toHaveBeenCalledWith(mockRequest); + }); + + it('clears a previous error when a new search starts', async () => { + mockSearchCsvRates.mockRejectedValueOnce(new Error('first error')); + mockSearchCsvRates.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useCsvRateSearch()); + + // First search fails + await act(async () => { + await result.current.search(mockRequest); + }); + expect(result.current.error).toBe('first error'); + + // Second search succeeds — error must be cleared + await act(async () => { + await result.current.search(mockRequest); + }); + expect(result.current.error).toBeNull(); + }); + }); + + describe('search — error path', () => { + it('sets error and clears data when the API throws', async () => { + mockSearchCsvRates.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useCsvRateSearch()); + + await act(async () => { + await result.current.search(mockRequest); + }); + + expect(result.current.error).toBe('Network error'); + expect(result.current.data).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('uses a default error message when the error has no message', async () => { + mockSearchCsvRates.mockRejectedValue({}); + + const { result } = renderHook(() => useCsvRateSearch()); + + await act(async () => { + await result.current.search(mockRequest); + }); + + expect(result.current.error).toBe('Failed to search rates'); + }); + }); + + describe('reset', () => { + it('clears data, error, and loading', async () => { + mockSearchCsvRates.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useCsvRateSearch()); + + await act(async () => { + await result.current.search(mockRequest); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('can be called before any search without throwing', () => { + const { result } = renderHook(() => useCsvRateSearch()); + + expect(() => { + act(() => result.current.reset()); + }).not.toThrow(); + }); + }); +}); diff --git a/apps/frontend/src/__tests__/hooks/useFilterOptions.test.tsx b/apps/frontend/src/__tests__/hooks/useFilterOptions.test.tsx new file mode 100644 index 0000000..3c691cc --- /dev/null +++ b/apps/frontend/src/__tests__/hooks/useFilterOptions.test.tsx @@ -0,0 +1,186 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useFilterOptions } from '@/hooks/useFilterOptions'; +import { getFilterOptions } from '@/lib/api/csv-rates'; +import type { FilterOptions } from '@/types/rate-filters'; + +jest.mock('@/lib/api/csv-rates', () => ({ + getFilterOptions: jest.fn(), +})); + +const mockGetFilterOptions = jest.mocked(getFilterOptions); + +const MOCK_OPTIONS: FilterOptions = { + companies: ['Maersk', 'MSC', 'CMA CGM'], + containerTypes: ['20ft', '40ft', '40ft HC'], + currencies: ['USD', 'EUR'], +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('useFilterOptions', () => { + describe('initial state', () => { + it('starts with loading=true', () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + const { result } = renderHook(() => useFilterOptions()); + expect(result.current.loading).toBe(true); + }); + + it('starts with empty companies array', () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + const { result } = renderHook(() => useFilterOptions()); + expect(result.current.companies).toEqual([]); + }); + + it('starts with empty containerTypes array', () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + const { result } = renderHook(() => useFilterOptions()); + expect(result.current.containerTypes).toEqual([]); + }); + + it('starts with empty currencies array', () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + const { result } = renderHook(() => useFilterOptions()); + expect(result.current.currencies).toEqual([]); + }); + + it('starts with error=null', () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + const { result } = renderHook(() => useFilterOptions()); + expect(result.current.error).toBeNull(); + }); + }); + + describe('on mount — success', () => { + it('fetches options automatically on mount', async () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + + renderHook(() => useFilterOptions()); + + await waitFor(() => { + expect(mockGetFilterOptions).toHaveBeenCalledTimes(1); + }); + }); + + it('populates all option arrays after a successful fetch', async () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.companies).toEqual(MOCK_OPTIONS.companies); + expect(result.current.containerTypes).toEqual(MOCK_OPTIONS.containerTypes); + expect(result.current.currencies).toEqual(MOCK_OPTIONS.currencies); + }); + + it('sets loading=false after a successful fetch', async () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).toBeNull(); + }); + + it('handles an API response with empty arrays', async () => { + mockGetFilterOptions.mockResolvedValue({ + companies: [], + containerTypes: [], + currencies: [], + }); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.companies).toEqual([]); + expect(result.current.containerTypes).toEqual([]); + expect(result.current.currencies).toEqual([]); + }); + }); + + describe('on mount — error', () => { + it('sets error when the API call fails', async () => { + mockGetFilterOptions.mockRejectedValue(new Error('Gateway timeout')); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).toBe('Gateway timeout'); + }); + + it('uses a fallback message when the error has no message', async () => { + mockGetFilterOptions.mockRejectedValue({}); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).toBe('Failed to fetch filter options'); + }); + + it('preserves the empty option arrays on error', async () => { + mockGetFilterOptions.mockRejectedValue(new Error('error')); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.companies).toEqual([]); + expect(result.current.containerTypes).toEqual([]); + expect(result.current.currencies).toEqual([]); + }); + }); + + describe('refetch', () => { + it('exposes a refetch function', async () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(typeof result.current.refetch).toBe('function'); + }); + + it('re-triggers the fetch when refetch is invoked', async () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockGetFilterOptions).toHaveBeenCalledTimes(2); + }); + + it('updates options with fresh data on refetch', async () => { + const updatedOptions: FilterOptions = { + companies: ['Maersk', 'MSC', 'ONE'], + containerTypes: ['20ft', '40ft'], + currencies: ['USD'], + }; + + mockGetFilterOptions + .mockResolvedValueOnce(MOCK_OPTIONS) + .mockResolvedValueOnce(updatedOptions); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.companies).toEqual(MOCK_OPTIONS.companies)); + + await act(async () => { + await result.current.refetch(); + }); + + expect(result.current.companies).toEqual(updatedOptions.companies); + }); + }); +}); diff --git a/apps/frontend/src/__tests__/lib/assets.test.ts b/apps/frontend/src/__tests__/lib/assets.test.ts new file mode 100644 index 0000000..03cd5e0 --- /dev/null +++ b/apps/frontend/src/__tests__/lib/assets.test.ts @@ -0,0 +1,86 @@ +import { + AssetPaths, + getImagePath, + getLogoPath, + getIconPath, + Images, + Logos, + Icons, +} from '@/lib/assets'; + +describe('AssetPaths constants', () => { + it('has the correct images base path', () => { + expect(AssetPaths.images).toBe('/assets/images'); + }); + + it('has the correct logos base path', () => { + expect(AssetPaths.logos).toBe('/assets/logos'); + }); + + it('has the correct icons base path', () => { + expect(AssetPaths.icons).toBe('/assets/icons'); + }); +}); + +describe('getImagePath', () => { + it('returns the correct full path for a given filename', () => { + expect(getImagePath('hero-banner.jpg')).toBe('/assets/images/hero-banner.jpg'); + }); + + it('handles filenames without extension', () => { + expect(getImagePath('background')).toBe('/assets/images/background'); + }); + + it('handles filenames with multiple dots', () => { + expect(getImagePath('my.image.v2.png')).toBe('/assets/images/my.image.v2.png'); + }); + + it('starts with a slash', () => { + expect(getImagePath('test.jpg')).toMatch(/^\//); + }); +}); + +describe('getLogoPath', () => { + it('returns the correct full path for a logo', () => { + expect(getLogoPath('xpeditis-logo.svg')).toBe('/assets/logos/xpeditis-logo.svg'); + }); + + it('handles a dark variant logo', () => { + expect(getLogoPath('xpeditis-logo-dark.svg')).toBe('/assets/logos/xpeditis-logo-dark.svg'); + }); + + it('starts with a slash', () => { + expect(getLogoPath('icon.svg')).toMatch(/^\//); + }); +}); + +describe('getIconPath', () => { + it('returns the correct full path for an icon', () => { + expect(getIconPath('shipping-icon.svg')).toBe('/assets/icons/shipping-icon.svg'); + }); + + it('handles a PNG icon', () => { + expect(getIconPath('notification.png')).toBe('/assets/icons/notification.png'); + }); + + it('starts with a slash', () => { + expect(getIconPath('arrow.svg')).toMatch(/^\//); + }); +}); + +describe('pre-defined asset collections', () => { + it('Images is a defined object', () => { + expect(Images).toBeDefined(); + expect(typeof Images).toBe('object'); + }); + + it('Logos is a defined object', () => { + expect(Logos).toBeDefined(); + expect(typeof Logos).toBe('object'); + }); + + it('Icons is a defined object', () => { + expect(Icons).toBeDefined(); + expect(typeof Icons).toBe('object'); + }); +}); diff --git a/apps/frontend/src/__tests__/lib/utils.test.ts b/apps/frontend/src/__tests__/lib/utils.test.ts new file mode 100644 index 0000000..0eceef5 --- /dev/null +++ b/apps/frontend/src/__tests__/lib/utils.test.ts @@ -0,0 +1,78 @@ +import { cn } from '@/lib/utils'; + +describe('cn — class name merger', () => { + describe('basic merging', () => { + it('returns an empty string when called with no arguments', () => { + expect(cn()).toBe(''); + }); + + it('returns the class when given a single string', () => { + expect(cn('foo')).toBe('foo'); + }); + + it('joins multiple class strings with a space', () => { + expect(cn('foo', 'bar', 'baz')).toBe('foo bar baz'); + }); + + it('ignores falsy values', () => { + expect(cn('foo', undefined, null, false, 'bar')).toBe('foo bar'); + }); + + it('handles an empty string argument', () => { + expect(cn('', 'foo')).toBe('foo'); + }); + }); + + describe('conditional classes', () => { + it('includes a class when its condition is true', () => { + expect(cn('base', true && 'active')).toBe('base active'); + }); + + it('excludes a class when its condition is false', () => { + expect(cn('base', false && 'active')).toBe('base'); + }); + + it('supports object syntax — includes keys whose value is truthy', () => { + expect(cn({ foo: true, bar: false, baz: true })).toBe('foo baz'); + }); + + it('supports array syntax', () => { + expect(cn(['foo', 'bar'])).toBe('foo bar'); + }); + + it('supports mixed input types', () => { + expect(cn('base', { active: true, disabled: false }, ['extra'])).toBe('base active extra'); + }); + }); + + describe('Tailwind conflict resolution', () => { + it('resolves padding conflicts — last padding wins', () => { + expect(cn('p-4', 'p-8')).toBe('p-8'); + }); + + it('resolves text-size conflicts — last size wins', () => { + expect(cn('text-sm', 'text-lg')).toBe('text-lg'); + }); + + it('resolves background-color conflicts', () => { + expect(cn('bg-red-500', 'bg-blue-500')).toBe('bg-blue-500'); + }); + + it('keeps non-conflicting utility classes', () => { + const result = cn('p-4', 'text-sm', 'font-bold'); + expect(result).toContain('p-4'); + expect(result).toContain('text-sm'); + expect(result).toContain('font-bold'); + }); + + it('resolves margin conflicts', () => { + expect(cn('mt-2', 'mt-4')).toBe('mt-4'); + }); + + it('does not remove classes that do not conflict', () => { + expect(cn('flex', 'items-center', 'justify-between')).toBe( + 'flex items-center justify-between' + ); + }); + }); +}); diff --git a/apps/frontend/src/__tests__/setup.ts b/apps/frontend/src/__tests__/setup.ts new file mode 100644 index 0000000..2b906b2 --- /dev/null +++ b/apps/frontend/src/__tests__/setup.ts @@ -0,0 +1,3 @@ +// This file is intentionally empty — the real setup is in jest.setup.ts at the root. +// It exists only to avoid breaking imports. Jest will skip it (no tests inside). +export {}; diff --git a/apps/frontend/src/__tests__/types/booking.test.ts b/apps/frontend/src/__tests__/types/booking.test.ts new file mode 100644 index 0000000..a890319 --- /dev/null +++ b/apps/frontend/src/__tests__/types/booking.test.ts @@ -0,0 +1,94 @@ +import { + BookingStatus, + ContainerType, + ExportFormat, +} from '@/types/booking'; + +describe('BookingStatus enum', () => { + it('has DRAFT value', () => { + expect(BookingStatus.DRAFT).toBe('draft'); + }); + + it('has CONFIRMED value', () => { + expect(BookingStatus.CONFIRMED).toBe('confirmed'); + }); + + it('has IN_PROGRESS value', () => { + expect(BookingStatus.IN_PROGRESS).toBe('in_progress'); + }); + + it('has COMPLETED value', () => { + expect(BookingStatus.COMPLETED).toBe('completed'); + }); + + it('has CANCELLED value', () => { + expect(BookingStatus.CANCELLED).toBe('cancelled'); + }); + + it('has exactly 5 statuses', () => { + const values = Object.values(BookingStatus); + expect(values).toHaveLength(5); + }); + + it('all values are lowercase strings', () => { + Object.values(BookingStatus).forEach(v => { + expect(v).toBe(v.toLowerCase()); + }); + }); +}); + +describe('ContainerType enum', () => { + it('has DRY_20 value', () => { + expect(ContainerType.DRY_20).toBe('20ft'); + }); + + it('has DRY_40 value', () => { + expect(ContainerType.DRY_40).toBe('40ft'); + }); + + it('has HIGH_CUBE_40 value', () => { + expect(ContainerType.HIGH_CUBE_40).toBe('40ft HC'); + }); + + it('has REEFER_20 value', () => { + expect(ContainerType.REEFER_20).toBe('20ft Reefer'); + }); + + it('has REEFER_40 value', () => { + expect(ContainerType.REEFER_40).toBe('40ft Reefer'); + }); + + it('has exactly 5 container types', () => { + expect(Object.values(ContainerType)).toHaveLength(5); + }); + + it('all standard (non-reefer) values start with a size prefix', () => { + expect(ContainerType.DRY_20).toMatch(/^\d+ft/); + expect(ContainerType.DRY_40).toMatch(/^\d+ft/); + expect(ContainerType.HIGH_CUBE_40).toMatch(/^\d+ft/); + }); +}); + +describe('ExportFormat enum', () => { + it('has CSV value', () => { + expect(ExportFormat.CSV).toBe('csv'); + }); + + it('has EXCEL value', () => { + expect(ExportFormat.EXCEL).toBe('excel'); + }); + + it('has JSON value', () => { + expect(ExportFormat.JSON).toBe('json'); + }); + + it('has exactly 3 formats', () => { + expect(Object.values(ExportFormat)).toHaveLength(3); + }); + + it('all values are lowercase', () => { + Object.values(ExportFormat).forEach(v => { + expect(v).toBe(v.toLowerCase()); + }); + }); +}); diff --git a/apps/frontend/src/__tests__/utils/export.test.ts b/apps/frontend/src/__tests__/utils/export.test.ts new file mode 100644 index 0000000..cbc2835 --- /dev/null +++ b/apps/frontend/src/__tests__/utils/export.test.ts @@ -0,0 +1,345 @@ +import { exportToCSV, exportToExcel, exportToJSON, exportBookings, ExportField } from '@/utils/export'; +import { Booking, BookingStatus, ContainerType } from '@/types/booking'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockSaveAs = jest.fn(); +jest.mock('file-saver', () => ({ + saveAs: (...args: unknown[]) => mockSaveAs(...args), +})); + +const mockAoaToSheet = jest.fn().mockReturnValue({ '!ref': 'A1:K2' }); +const mockBookNew = jest.fn().mockReturnValue({}); +const mockBookAppendSheet = jest.fn(); +const mockWrite = jest.fn().mockReturnValue(new ArrayBuffer(8)); + +jest.mock('xlsx', () => ({ + utils: { + aoa_to_sheet: (...args: unknown[]) => mockAoaToSheet(...args), + book_new: () => mockBookNew(), + book_append_sheet: (...args: unknown[]) => mockBookAppendSheet(...args), + }, + write: (...args: unknown[]) => mockWrite(...args), +})); + +// ── Blob capture helper ──────────────────────────────────────────────────────── +// blob.text() is not available in all jsdom versions; instead we intercept the +// Blob constructor to capture the raw string before it's wrapped. + +const OriginalBlob = global.Blob; +let capturedBlobParts: string[] = []; + +beforeEach(() => { + jest.clearAllMocks(); + capturedBlobParts = []; + + global.Blob = jest.fn().mockImplementation( + (parts?: BlobPart[], options?: BlobPropertyBag) => { + const content = (parts ?? []).map(p => (typeof p === 'string' ? p : '')).join(''); + capturedBlobParts.push(content); + return { type: options?.type ?? '', size: content.length } as Blob; + } + ) as unknown as typeof Blob; +}); + +afterEach(() => { + global.Blob = OriginalBlob; +}); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const makeBooking = (overrides: Partial = {}): Booking => ({ + id: 'b-1', + bookingNumber: 'WCM-2024-ABC001', + status: BookingStatus.CONFIRMED, + shipper: { + name: 'Acme Corp', + street: '1 rue de la Paix', + city: 'Paris', + postalCode: '75001', + country: 'France', + }, + consignee: { + name: 'Beta Ltd', + street: '42 Main St', + city: 'Shanghai', + postalCode: '200000', + country: 'China', + }, + containers: [ + { id: 'c-1', type: ContainerType.DRY_40 }, + { id: 'c-2', type: ContainerType.HIGH_CUBE_40 }, + ], + rateQuote: { + id: 'rq-1', + carrierName: 'Maersk', + carrierScac: 'MAEU', + origin: 'Le Havre', + destination: 'Shanghai', + priceValue: 2500, + priceCurrency: 'USD', + etd: '2024-03-01T00:00:00Z', + eta: '2024-04-01T00:00:00Z', + transitDays: 31, + }, + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + ...overrides, +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('exportToCSV', () => { + it('calls saveAs once', () => { + exportToCSV([makeBooking()]); + expect(mockSaveAs).toHaveBeenCalledTimes(1); + }); + + it('passes a Blob as the first saveAs argument', () => { + exportToCSV([makeBooking()]); + const [blob] = mockSaveAs.mock.calls[0]; + expect(blob).toBeDefined(); + expect(blob.type).toContain('text/csv'); + }); + + it('uses the default filename', () => { + exportToCSV([makeBooking()]); + const [, filename] = mockSaveAs.mock.calls[0]; + expect(filename).toBe('bookings-export.csv'); + }); + + it('uses a custom filename when provided', () => { + exportToCSV([makeBooking()], undefined, 'my-export.csv'); + const [, filename] = mockSaveAs.mock.calls[0]; + expect(filename).toBe('my-export.csv'); + }); + + it('generates a CSV header with default field labels', () => { + exportToCSV([makeBooking()]); + const csv = capturedBlobParts[0]; + expect(csv).toContain('Booking Number'); + expect(csv).toContain('Status'); + expect(csv).toContain('Carrier'); + expect(csv).toContain('Origin'); + expect(csv).toContain('Destination'); + }); + + it('includes booking data in the CSV rows', () => { + exportToCSV([makeBooking()]); + const csv = capturedBlobParts[0]; + expect(csv).toContain('WCM-2024-ABC001'); + expect(csv).toContain('confirmed'); + expect(csv).toContain('Maersk'); + expect(csv).toContain('Le Havre'); + expect(csv).toContain('Shanghai'); + }); + + it('applies custom fields and their labels', () => { + const customFields: ExportField[] = [ + { key: 'bookingNumber', label: 'Number' }, + { key: 'status', label: 'State' }, + ]; + exportToCSV([makeBooking()], customFields); + const csv = capturedBlobParts[0]; + expect(csv).toContain('Number'); + expect(csv).toContain('State'); + expect(csv).not.toContain('Carrier'); + }); + + it('applies field formatters', () => { + const customFields: ExportField[] = [ + { key: 'status', label: 'Status', formatter: (v: string) => v.toUpperCase() }, + ]; + exportToCSV([makeBooking()], customFields); + expect(capturedBlobParts[0]).toContain('CONFIRMED'); + }); + + it('extracts nested values with dot-notation keys', () => { + const customFields: ExportField[] = [ + { key: 'rateQuote.carrierName', label: 'Carrier' }, + { key: 'shipper.name', label: 'Shipper' }, + ]; + exportToCSV([makeBooking()], customFields); + const csv = capturedBlobParts[0]; + expect(csv).toContain('Maersk'); + expect(csv).toContain('Acme Corp'); + }); + + it('extracts deeply nested values', () => { + const customFields: ExportField[] = [ + { key: 'consignee.city', label: 'Consignee City' }, + ]; + exportToCSV([makeBooking()], customFields); + expect(capturedBlobParts[0]).toContain('Shanghai'); + }); + + it('generates only the header row when data is empty', () => { + exportToCSV([]); + const lines = capturedBlobParts[0].split('\n'); + expect(lines).toHaveLength(1); + }); + + it('generates one data row per booking', () => { + exportToCSV([ + makeBooking(), + makeBooking({ id: 'b-2', bookingNumber: 'WCM-2024-ABC002' }), + ]); + const lines = capturedBlobParts[0].trim().split('\n'); + expect(lines).toHaveLength(3); // header + 2 rows + }); + + it('wraps all cell values in double quotes', () => { + const customFields: ExportField[] = [ + { key: 'bookingNumber', label: 'Number' }, + ]; + exportToCSV([makeBooking()], customFields); + const dataLine = capturedBlobParts[0].split('\n')[1]; + expect(dataLine).toMatch(/^".*"$/); + }); + + it('escapes double quotes inside cell values', () => { + const customFields: ExportField[] = [ + { key: 'shipper.name', label: 'Shipper' }, + ]; + const booking = makeBooking({ + shipper: { + name: 'He said "hello"', + street: '1 st', + city: 'Paris', + postalCode: '75001', + country: 'France', + }, + }); + exportToCSV([booking], customFields); + // Original `"` should be escaped as `""` + expect(capturedBlobParts[0]).toContain('He said ""hello""'); + }); + + it('returns undefined', () => { + expect(exportToCSV([makeBooking()])).toBeUndefined(); + }); +}); + +describe('exportToExcel', () => { + it('calls saveAs with the default filename', () => { + exportToExcel([makeBooking()]); + expect(mockSaveAs).toHaveBeenCalledTimes(1); + const [, filename] = mockSaveAs.mock.calls[0]; + expect(filename).toBe('bookings-export.xlsx'); + }); + + it('uses a custom filename', () => { + exportToExcel([makeBooking()], undefined, 'report.xlsx'); + const [, filename] = mockSaveAs.mock.calls[0]; + expect(filename).toBe('report.xlsx'); + }); + + it('calls aoa_to_sheet with worksheet data', () => { + exportToExcel([makeBooking()]); + expect(mockAoaToSheet).toHaveBeenCalledTimes(1); + const [wsData] = mockAoaToSheet.mock.calls[0]; + expect(Array.isArray(wsData[0])).toBe(true); + }); + + it('places the header labels in the first row', () => { + exportToExcel([makeBooking()]); + const [wsData] = mockAoaToSheet.mock.calls[0]; + const headers = wsData[0]; + expect(headers).toContain('Booking Number'); + expect(headers).toContain('Carrier'); + expect(headers).toContain('Status'); + }); + + it('creates a new workbook', () => { + exportToExcel([makeBooking()]); + expect(mockBookNew).toHaveBeenCalledTimes(1); + }); + + it('appends the worksheet with the name "Bookings"', () => { + exportToExcel([makeBooking()]); + expect(mockBookAppendSheet).toHaveBeenCalledTimes(1); + const [, , sheetName] = mockBookAppendSheet.mock.calls[0]; + expect(sheetName).toBe('Bookings'); + }); + + it('calls XLSX.write with bookType "xlsx"', () => { + exportToExcel([makeBooking()]); + expect(mockWrite).toHaveBeenCalledTimes(1); + const [, opts] = mockWrite.mock.calls[0]; + expect(opts.bookType).toBe('xlsx'); + }); + + it('produces a row for each booking (plus one header)', () => { + exportToExcel([makeBooking(), makeBooking({ id: 'b-2' })]); + const [wsData] = mockAoaToSheet.mock.calls[0]; + expect(wsData).toHaveLength(3); // 1 header + 2 data rows + }); +}); + +describe('exportToJSON', () => { + it('calls saveAs with the default filename', () => { + exportToJSON([makeBooking()]); + expect(mockSaveAs).toHaveBeenCalledTimes(1); + const [, filename] = mockSaveAs.mock.calls[0]; + expect(filename).toBe('bookings-export.json'); + }); + + it('uses a custom filename', () => { + exportToJSON([makeBooking()], 'data.json'); + const [, filename] = mockSaveAs.mock.calls[0]; + expect(filename).toBe('data.json'); + }); + + it('creates a Blob with application/json type', () => { + exportToJSON([makeBooking()]); + const [blob] = mockSaveAs.mock.calls[0]; + expect(blob.type).toContain('application/json'); + }); + + it('serialises bookings as valid JSON', () => { + const booking = makeBooking(); + exportToJSON([booking]); + const json = capturedBlobParts[0]; + const parsed = JSON.parse(json); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].bookingNumber).toBe('WCM-2024-ABC001'); + }); + + it('produces pretty-printed JSON (2-space indent)', () => { + exportToJSON([makeBooking()]); + expect(capturedBlobParts[0]).toContain('\n '); + }); +}); + +describe('exportBookings dispatcher', () => { + it('routes "csv" to exportToCSV', () => { + exportBookings([makeBooking()], 'csv'); + const [, filename] = mockSaveAs.mock.calls[0]; + expect(filename).toBe('bookings-export.csv'); + }); + + it('routes "excel" to exportToExcel', () => { + exportBookings([makeBooking()], 'excel'); + expect(mockAoaToSheet).toHaveBeenCalledTimes(1); + const [, filename] = mockSaveAs.mock.calls[0]; + expect(filename).toBe('bookings-export.xlsx'); + }); + + it('routes "json" to exportToJSON', () => { + exportBookings([makeBooking()], 'json'); + const [, filename] = mockSaveAs.mock.calls[0]; + expect(filename).toBe('bookings-export.json'); + }); + + it('throws for an unknown format', () => { + expect(() => exportBookings([makeBooking()], 'pdf' as any)).toThrow( + 'Unsupported export format: pdf' + ); + }); + + it('passes a custom filename through to the underlying exporter', () => { + exportBookings([makeBooking()], 'csv', undefined, 'custom.csv'); + const [, filename] = mockSaveAs.mock.calls[0]; + expect(filename).toBe('custom.csv'); + }); +}); diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index f766a82..7aae428 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -31,5 +31,13 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + "src/__tests__", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx", + "jest.setup.ts" + ] } diff --git a/apps/frontend/tsconfig.test.json b/apps/frontend/tsconfig.test.json new file mode 100644 index 0000000..f4e0f47 --- /dev/null +++ b/apps/frontend/tsconfig.test.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest", "@testing-library/jest-dom", "node"], + "jsx": "react-jsx" + }, + "include": [ + "next-env.d.ts", + "jest.setup.ts", + "src/__tests__/**/*.ts", + "src/__tests__/**/*.tsx", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ], + "exclude": ["node_modules"] +} diff --git a/infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json b/infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json index 96e624f..c930170 100644 --- a/infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json +++ b/infra/logging/grafana/provisioning/dashboards/xpeditis-logs.json @@ -1,32 +1,37 @@ { - "title": "Xpeditis — Logs & Monitoring", - "uid": "xpeditis-logs", - "description": "Dashboard complet — logs backend/frontend, métriques HTTP, erreurs", - "tags": ["xpeditis", "logs", "backend", "frontend"], - "timezone": "browser", + "__inputs": [ + { + "name": "DS_LOKI", + "label": "Loki", + "description": "Loki datasource", + "type": "datasource", + "pluginId": "loki", + "pluginName": "Loki" + } + ], + "__requires": [ + { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "11.0.0" }, + { "type": "datasource", "id": "loki", "name": "Loki", "version": "1.0.0" }, + { "type": "panel", "id": "stat", "name": "Stat", "version": "" }, + { "type": "panel", "id": "timeseries", "name": "Time series", "version": "" }, + { "type": "panel", "id": "piechart", "name": "Pie chart", "version": "" }, + { "type": "panel", "id": "bargauge", "name": "Bar gauge", "version": "" }, + { "type": "panel", "id": "logs", "name": "Logs", "version": "" } + ], + "title": "Xpeditis — Logs & KPIs", + "uid": "xpeditis-logs-kpis", + "description": "Logs applicatifs, KPIs HTTP, temps de réponse et erreurs — Backend & Frontend", + "tags": ["xpeditis", "logs", "monitoring", "backend"], + "timezone": "Europe/Paris", "refresh": "30s", - "schemaVersion": 38, + "schemaVersion": 39, "time": { "from": "now-1h", "to": "now" }, "timepicker": {}, - "fiscalYearStartMonth": 0, "graphTooltip": 1, "editable": true, "version": 1, - "weekStart": "", "links": [], - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { "type": "grafana", "uid": "-- Grafana --" }, - "enable": true, - "hide": true, - "iconColor": "rgba(0,211,255,1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, + "annotations": { "list": [] }, "templating": { "list": [ @@ -34,119 +39,99 @@ "name": "service", "label": "Service", "type": "query", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "query": "label_values(service)", "refresh": 2, - "sort": 1, "includeAll": true, "allValue": ".+", - "multi": false, - "hide": 0, + "multi": true, "current": {}, - "options": [] + "hide": 0, + "sort": 1 }, { "name": "level", "label": "Niveau", - "type": "custom", - "query": "All : .+, error : error, fatal : fatal, warn : warn, info : info, debug : debug", - "includeAll": false, - "multi": false, + "type": "query", + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "query": "label_values(level)", + "refresh": 2, + "includeAll": true, + "allValue": ".+", + "multi": true, + "current": {}, "hide": 0, - "current": { "text": "All", "value": ".+" }, - "options": [ - { "text": "All", "value": ".+", "selected": true }, - { "text": "error", "value": "error", "selected": false }, - { "text": "fatal", "value": "fatal", "selected": false }, - { "text": "warn", "value": "warn", "selected": false }, - { "text": "info", "value": "info", "selected": false }, - { "text": "debug", "value": "debug", "selected": false } - ] - }, - { - "name": "search", - "label": "Recherche", - "type": "textbox", - "query": "", - "hide": 0, - "current": { "text": "", "value": "" }, - "options": [{ "selected": true, "text": "", "value": "" }] + "sort": 1 } ] }, "panels": [ - { - "id": 100, - "type": "row", - "title": "Vue d'ensemble", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } - }, - { "id": 1, - "title": "Total logs", + "title": "Requêtes totales", "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false }, "orientation": "auto", "textMode": "auto", "colorMode": "background", - "graphMode": "area", + "graphMode": "none", "justifyMode": "center" }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] }, - "mappings": [] + "color": { "mode": "fixed", "fixedColor": "#10183A" }, + "unit": "short", + "thresholds": { "mode": "absolute", "steps": [{ "color": "#10183A", "value": null }] } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=~\"$service\"} [$__range]))", - "legendFormat": "Total", - "instant": true + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum(count_over_time({service=~\"$service\"} | json | req_method != \"\" [$__range]))", + "legendFormat": "Requêtes", + "instant": true, + "range": false, + "refId": "A" } ] }, { "id": 2, - "title": "Erreurs & Fatal", + "title": "Erreurs (error + fatal)", "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false }, "orientation": "auto", "textMode": "auto", "colorMode": "background", - "graphMode": "area", + "graphMode": "none", "justifyMode": "center" }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }, - "mappings": [] + "color": { "mode": "fixed", "fixedColor": "red" }, + "unit": "short", + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }] } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "expr": "sum(count_over_time({service=~\"$service\", level=~\"error|fatal\"} [$__range]))", "legendFormat": "Erreurs", - "instant": true + "instant": true, + "range": false, + "refId": "A" } ] }, @@ -155,342 +140,342 @@ "id": 3, "title": "Warnings", "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false }, "orientation": "auto", "textMode": "auto", "colorMode": "background", - "graphMode": "area", + "graphMode": "none", "justifyMode": "center" }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 1 }] }, - "mappings": [] + "color": { "mode": "fixed", "fixedColor": "orange" }, + "unit": "short", + "thresholds": { "mode": "absolute", "steps": [{ "color": "orange", "value": null }] } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "expr": "sum(count_over_time({service=~\"$service\", level=\"warn\"} [$__range]))", "legendFormat": "Warnings", - "instant": true + "instant": true, + "range": false, + "refId": "A" } ] }, { "id": 4, - "title": "Info", + "title": "Taux d'erreur", "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "orientation": "auto", "textMode": "auto", "colorMode": "background", - "graphMode": "area", + "graphMode": "none", "justifyMode": "center" }, "fieldConfig": { "defaults": { - "color": { "fixedColor": "blue", "mode": "fixed" }, - "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] }, - "mappings": [] + "unit": "percentunit", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 0.01 }, + { "color": "red", "value": 0.05 } + ] + }, + "color": { "mode": "thresholds" } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=~\"$service\", level=\"info\"} [$__range]))", - "legendFormat": "Info", - "instant": true + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum(rate({service=~\"$service\", level=~\"error|fatal\"} [$__interval])) / sum(rate({service=~\"$service\"} [$__interval]))", + "legendFormat": "Taux d'erreur", + "instant": false, + "range": true, + "refId": "A" } ] }, { "id": 5, - "title": "Requêtes HTTP 5xx", - "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "title": "Trafic par service (req/s)", + "type": "timeseries", + "gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "orientation": "auto", - "textMode": "auto", - "colorMode": "background", - "graphMode": "area", - "justifyMode": "center" + "tooltip": { "mode": "multi", "sort": "desc" }, + "legend": { "displayMode": "list", "placement": "bottom" } }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }, - "mappings": [] + "unit": "reqps", + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "gradientMode": "opacity", + "spanNulls": false + } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__range]))", - "legendFormat": "5xx", - "instant": true + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum by(service) (rate({service=~\"$service\"} | json | req_method != \"\" [$__interval]))", + "legendFormat": "{{service}}", + "instant": false, + "range": true, + "refId": "A" } ] }, { "id": 6, - "title": "Temps réponse moyen (ms)", - "type": "stat", - "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "title": "Erreurs & Warnings dans le temps", + "type": "timeseries", + "gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, - "orientation": "auto", - "textMode": "auto", - "colorMode": "background", - "graphMode": "area", - "justifyMode": "center" + "tooltip": { "mode": "multi", "sort": "desc" }, + "legend": { "displayMode": "list", "placement": "bottom" } }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "unit": "ms", - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] }, - "mappings": [] + "unit": "short", + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 2, + "fillOpacity": 15, + "gradientMode": "opacity" + } }, - "overrides": [] + "overrides": [ + { + "matcher": { "id": "byName", "options": "error" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] + }, + { + "matcher": { "id": "byName", "options": "fatal" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "dark-red" } }] + }, + { + "matcher": { "id": "byName", "options": "warn" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }] + } + ] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__range]))", - "legendFormat": "Avg", - "instant": true + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum by(level) (rate({service=~\"$service\", level=~\"error|fatal|warn\"} [$__interval]))", + "legendFormat": "{{level}}", + "instant": false, + "range": true, + "refId": "A" } ] }, - { - "id": 200, - "type": "row", - "title": "Volume des logs", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 } - }, - { "id": 7, - "title": "Volume par niveau", + "title": "Temps de réponse Backend", "type": "timeseries", - "gridPos": { "h": 8, "w": 14, "x": 0, "y": 6 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 0, "y": 12, "w": 16, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true }, - "tooltip": { "mode": "multi", "sort": "desc" } + "tooltip": { "mode": "multi", "sort": "desc" }, + "legend": { "displayMode": "list", "placement": "bottom" } }, "fieldConfig": { "defaults": { + "unit": "ms", "color": { "mode": "palette-classic" }, "custom": { - "drawStyle": "bars", - "fillOpacity": 80, - "stacking": { "group": "A", "mode": "normal" }, - "lineWidth": 1, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false + "lineWidth": 2, + "fillOpacity": 8, + "gradientMode": "opacity" }, - "unit": "short", - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 500 }, + { "color": "red", "value": 1000 } + ] + } }, "overrides": [ - { "matcher": { "id": "byName", "options": "error" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "fatal" }, "properties": [{ "id": "color", "value": { "fixedColor": "dark-red", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "warn" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "info" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "debug" }, "properties": [{ "id": "color", "value": { "fixedColor": "gray", "mode": "fixed" } }] } + { + "matcher": { "id": "byName", "options": "Pire cas (1% des requêtes)" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] + }, + { + "matcher": { "id": "byName", "options": "Lent (5% des requêtes)" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }] + }, + { + "matcher": { "id": "byName", "options": "Temps médian (requête typique)" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#34CCCD" } }] + } ] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum by (level) (count_over_time({service=~\"$service\", level=~\".+\"} [$__interval]))", - "legendFormat": "{{level}}" + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "quantile_over_time(0.50, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])", + "legendFormat": "Temps médian (requête typique)", + "instant": false, + "range": true, + "refId": "A" + }, + { + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "quantile_over_time(0.95, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])", + "legendFormat": "Lent (5% des requêtes)", + "instant": false, + "range": true, + "refId": "B" + }, + { + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "quantile_over_time(0.99, {service=\"backend\"} | json | responseTime > 0 | unwrap responseTime [$__interval])", + "legendFormat": "Pire cas (1% des requêtes)", + "instant": false, + "range": true, + "refId": "C" } ] }, { "id": 8, - "title": "Volume par service", - "type": "timeseries", - "gridPos": { "h": 8, "w": 10, "x": 14, "y": 6 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "title": "Répartition par niveau de log", + "type": "piechart", + "gridPos": { "x": 16, "y": 12, "w": 8, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true }, - "tooltip": { "mode": "multi", "sort": "desc" } + "pieType": "donut", + "tooltip": { "mode": "single" }, + "legend": { "displayMode": "list", "placement": "bottom", "values": ["percent"] } }, "fieldConfig": { - "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "drawStyle": "bars", - "fillOpacity": 60, - "stacking": { "group": "A", "mode": "normal" }, - "lineWidth": 1, - "showPoints": "never", - "spanNulls": false - }, - "unit": "short", - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } - }, - "overrides": [] + "defaults": { "unit": "short", "color": { "mode": "palette-classic" } }, + "overrides": [ + { "matcher": { "id": "byName", "options": "error" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] }, + { "matcher": { "id": "byName", "options": "fatal" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "dark-red" } }] }, + { "matcher": { "id": "byName", "options": "warn" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } }] }, + { "matcher": { "id": "byName", "options": "info" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#34CCCD" } }] }, + { "matcher": { "id": "byName", "options": "debug" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } }] } + ] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum by (service) (count_over_time({service=~\"$service\"} [$__interval]))", - "legendFormat": "{{service}}" + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum by(level) (count_over_time({service=~\"$service\", level=~\"$level\"} [$__range]))", + "legendFormat": "{{level}}", + "instant": true, + "range": false, + "refId": "A" } ] }, - { - "id": 300, - "type": "row", - "title": "HTTP — Backend", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 } - }, - { "id": 9, - "title": "Taux d'erreur HTTP", - "type": "timeseries", - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "title": "Codes HTTP (5m)", + "type": "bargauge", + "gridPos": { "x": 0, "y": 20, "w": 12, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true }, - "tooltip": { "mode": "multi", "sort": "desc" } + "orientation": "horizontal", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "displayMode": "gradient", + "valueMode": "color", + "showUnfilled": true, + "minVizWidth": 10, + "minVizHeight": 10 }, "fieldConfig": { "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "drawStyle": "line", - "fillOpacity": 20, - "lineWidth": 2, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false - }, "unit": "short", - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] } + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 1 } + ] + } }, - "overrides": [ - { "matcher": { "id": "byName", "options": "5xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "4xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "2xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] } - ] + "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__interval]))", - "legendFormat": "5xx" - }, - { - "refId": "B", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 400 < 500 [$__interval]))", - "legendFormat": "4xx" - }, - { - "refId": "C", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 200 < 300 [$__interval]))", - "legendFormat": "2xx" + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "sum by(status_code) (count_over_time({service=\"backend\"} | json | res_statusCode != \"\" | label_format status_code=\"{{res_statusCode}}\" [$__range]))", + "legendFormat": "HTTP {{status_code}}", + "instant": true, + "range": false, + "refId": "A" } ] }, { "id": 10, - "title": "Temps de réponse (ms)", - "type": "timeseries", - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "title": "Top erreurs par contexte NestJS", + "type": "bargauge", + "gridPos": { "x": 12, "y": 20, "w": 12, "h": 8 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { - "legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true }, - "tooltip": { "mode": "multi", "sort": "desc" } + "orientation": "horizontal", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "displayMode": "gradient", + "showUnfilled": true }, "fieldConfig": { "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { - "drawStyle": "line", - "fillOpacity": 10, - "lineWidth": 2, - "pointSize": 5, - "showPoints": "never", - "spanNulls": false - }, - "unit": "ms", - "mappings": [], - "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] } + "unit": "short", + "color": { "mode": "fixed", "fixedColor": "red" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }] } }, "overrides": [] }, "targets": [ { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))", - "legendFormat": "Moy" - }, - { - "refId": "B", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "max(max_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))", - "legendFormat": "Max" + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "topk(10, sum by(context) (count_over_time({service=\"backend\", level=~\"error|fatal\"} | json | context != \"\" [$__range]) ))", + "legendFormat": "{{context}}", + "instant": true, + "range": false, + "refId": "A" } ] }, - { - "id": 400, - "type": "row", - "title": "Logs — Flux en direct", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 } - }, - { "id": 11, - "title": "Backend — Logs", + "title": "Logs — Backend", "type": "logs", - "gridPos": { "h": 14, "w": 12, "x": 0, "y": 24 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 0, "y": 28, "w": 24, "h": 12 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { "dedupStrategy": "none", "enableLogDetails": true, @@ -503,24 +488,27 @@ }, "targets": [ { + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "{service=\"backend\", level=~\"$level\"}", + "legendFormat": "", + "instant": false, + "range": true, "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "{service=\"backend\", level=~\"$level\"} |= \"$search\"", - "legendFormat": "" + "maxLines": 500 } ] }, { "id": 12, - "title": "Frontend — Logs", + "title": "Logs — Frontend", "type": "logs", - "gridPos": { "h": 14, "w": 12, "x": 12, "y": 24 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, + "gridPos": { "x": 0, "y": 40, "w": 24, "h": 10 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, "options": { "dedupStrategy": "none", "enableLogDetails": true, - "prettifyLogMessage": true, + "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": false, "showTime": true, @@ -529,105 +517,13 @@ }, "targets": [ { + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "expr": "{service=\"frontend\"}", + "legendFormat": "", + "instant": false, + "range": true, "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "{service=\"frontend\", level=~\"$level\"} |= \"$search\"", - "legendFormat": "" - } - ] - }, - - { - "id": 500, - "type": "row", - "title": "Tous les logs filtrés", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 38 } - }, - - { - "id": 13, - "title": "Flux filtré — $service / $level", - "description": "Utilisez les variables en haut pour filtrer par service, niveau ou mot-clé", - "type": "logs", - "gridPos": { "h": 14, "w": 24, "x": 0, "y": 39 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": true, - "showCommonLabels": false, - "showLabels": true, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": true - }, - "targets": [ - { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "{service=~\"$service\", level=~\"$level\"} |= \"$search\"", - "legendFormat": "" - } - ] - }, - - { - "id": 600, - "type": "row", - "title": "Erreurs & Exceptions", - "collapsed": false, - "gridPos": { "h": 1, "w": 24, "x": 0, "y": 53 } - }, - - { - "id": 14, - "title": "Erreurs — Backend", - "type": "logs", - "gridPos": { "h": 10, "w": 12, "x": 0, "y": 54 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "options": { - "dedupStrategy": "signature", - "enableLogDetails": true, - "prettifyLogMessage": true, - "showCommonLabels": false, - "showLabels": false, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": true - }, - "targets": [ - { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "{service=\"backend\", level=~\"error|fatal\"}", - "legendFormat": "" - } - ] - }, - - { - "id": 15, - "title": "Erreurs — Frontend", - "type": "logs", - "gridPos": { "h": 10, "w": 12, "x": 12, "y": 54 }, - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "options": { - "dedupStrategy": "signature", - "enableLogDetails": true, - "prettifyLogMessage": true, - "showCommonLabels": false, - "showLabels": false, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": true - }, - "targets": [ - { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki-xpeditis" }, - "expr": "{service=\"frontend\", level=~\"error|fatal\"}", - "legendFormat": "" + "maxLines": 200 } ] } diff --git a/infra/logging/grafana/provisioning/datasources/loki.yml b/infra/logging/grafana/provisioning/datasources/loki.yml index b3102e9..3d48bde 100644 --- a/infra/logging/grafana/provisioning/datasources/loki.yml +++ b/infra/logging/grafana/provisioning/datasources/loki.yml @@ -5,7 +5,7 @@ datasources: uid: loki-xpeditis type: loki access: proxy - url: http://loki:3100 + url: http://xpeditis-loki:3100 isDefault: true version: 1 editable: false diff --git a/infra/logging/promtail/promtail-config.yml b/infra/logging/promtail/promtail-config.yml index 453e222..df36f4a 100644 --- a/infra/logging/promtail/promtail-config.yml +++ b/infra/logging/promtail/promtail-config.yml @@ -1,53 +1,43 @@ server: http_listen_port: 9080 - grpc_listen_port: 0 log_level: warn positions: filename: /tmp/positions.yaml clients: - - url: http://loki:3100/loki/api/v1/push + - url: http://xpeditis-loki:3100/loki/api/v1/push batchwait: 1s batchsize: 1048576 timeout: 10s scrape_configs: - # ─── Docker container log collection (Mac-compatible via Docker socket API) ─ - job_name: docker docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 5s filters: - # Only collect containers with label: logging=promtail - # Add this label to backend and frontend in docker-compose.dev.yml - name: label values: ['logging=promtail'] relabel_configs: - # Use docker-compose service name as the "service" label - - source_labels: ['__meta_docker_container_label_com_docker_compose_service'] + - source_labels: ['__meta_docker_container_label_logging_service'] target_label: service - # Keep container name for context - source_labels: ['__meta_docker_container_name'] regex: '/?(.*)' replacement: '${1}' target_label: container - # Log stream (stdout / stderr) - source_labels: ['__meta_docker_container_log_stream'] target_label: stream pipeline_stages: - # Drop entries older than 15 min to avoid replaying full container log history - drop: older_than: 15m drop_counter_reason: entry_too_old - # Drop noisy health-check / ping lines - drop: expression: 'GET /(health|metrics|minio/health)' - # Try to parse JSON (NestJS/pino output) - json: expressions: level: level @@ -55,12 +45,10 @@ scrape_configs: context: context reqId: reqId - # Promote parsed fields as Loki labels - labels: level: context: - # Map pino numeric levels to strings - template: source: level template: >- diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6fc6118 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,69 @@ +{ + "name": "xpeditis", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xpeditis", + "version": "0.1.0", + "license": "UNLICENSED", + "devDependencies": { + "@types/node": "^20.10.0", + "prettier": "^3.1.0", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +}