chore: sync main with preprod (remove smoke tests + latest changes)
Some checks failed
CD Production / Backend — Lint (push) Successful in 10m22s
CD Production / Frontend — Lint & Type-check (push) Successful in 10m53s
CD Production / Backend — Unit Tests (push) Successful in 10m10s
CD Production / Frontend — Unit Tests (push) Successful in 10m30s
CD Production / Verify Preprod Image Exists (push) Failing after 9s
CD Production / Promote Images (preprod-SHA → prod) (push) Has been skipped
CD Production / Deploy to Production (k3s) (push) Has been skipped
CD Production / Notify Success (push) Has been skipped
CD Production / Notify Failure (push) Has been skipped
Some checks failed
CD Production / Backend — Lint (push) Successful in 10m22s
CD Production / Frontend — Lint & Type-check (push) Successful in 10m53s
CD Production / Backend — Unit Tests (push) Successful in 10m10s
CD Production / Frontend — Unit Tests (push) Successful in 10m30s
CD Production / Verify Preprod Image Exists (push) Failing after 9s
CD Production / Promote Images (preprod-SHA → prod) (push) Has been skipped
CD Production / Deploy to Production (k3s) (push) Has been skipped
CD Production / Notify Success (push) Has been skipped
CD Production / Notify Failure (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
5a54940424
42
.github/workflows/cd-main.yml
vendored
42
.github/workflows/cd-main.yml
vendored
@ -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: |
|
||||
|
||||
82
.github/workflows/cd-preprod.yml
vendored
82
.github/workflows/cd-preprod.yml
vendored
@ -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: |
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -44,6 +44,8 @@ lerna-debug.log*
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
stack-portainer.yaml
|
||||
tmp.stack-portainer.yaml
|
||||
|
||||
# Uploads
|
||||
uploads/
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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 {
|
||||
|
||||
98
apps/backend/src/application/logs/logs.controller.ts
Normal file
98
apps/backend/src/application/logs/logs.controller.ts
Normal file
@ -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<string>(
|
||||
'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);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/backend/src/application/logs/logs.module.ts
Normal file
9
apps/backend/src/application/logs/logs.module.ts
Normal file
@ -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 {}
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -55,7 +55,7 @@ const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
|
||||
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<SubscriptionPlanType, PlanDetails> = {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, SubscriptionPlanOrmType> = {
|
||||
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;
|
||||
|
||||
@ -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<LogsResponse>(
|
||||
`${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 : <strong>{error}</strong>
|
||||
<br />
|
||||
<span className="text-xs text-red-500">
|
||||
Vérifiez que le container log-exporter est démarré sur{' '}
|
||||
<code className="font-mono">{LOG_EXPORTER_URL}</code>
|
||||
Vérifiez que le backend et le log-exporter sont démarrés.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -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/);
|
||||
|
||||
24
apps/frontend/jest.config.js
Normal file
24
apps/frontend/jest.config.js
Normal file
@ -0,0 +1,24 @@
|
||||
const nextJest = require('next/jest');
|
||||
|
||||
const createJestConfig = nextJest({ dir: './' });
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
const customConfig = {
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testMatch: [
|
||||
'<rootDir>/src/**/*.{spec,test}.{ts,tsx}',
|
||||
'<rootDir>/src/**/__tests__/**/*.{spec,test}.{ts,tsx}',
|
||||
],
|
||||
testPathIgnorePatterns: [
|
||||
'<rootDir>/node_modules/',
|
||||
'<rootDir>/.next/',
|
||||
'<rootDir>/e2e/',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^@/app/(.*)$': '<rootDir>/app/$1',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = createJestConfig(customConfig);
|
||||
1
apps/frontend/jest.setup.ts
Normal file
1
apps/frontend/jest.setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
47
apps/frontend/package-lock.json
generated
47
apps/frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
143
apps/frontend/src/__tests__/hooks/useCompanies.test.tsx
Normal file
143
apps/frontend/src/__tests__/hooks/useCompanies.test.tsx
Normal file
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
198
apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx
Normal file
198
apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
186
apps/frontend/src/__tests__/hooks/useFilterOptions.test.tsx
Normal file
186
apps/frontend/src/__tests__/hooks/useFilterOptions.test.tsx
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
86
apps/frontend/src/__tests__/lib/assets.test.ts
Normal file
86
apps/frontend/src/__tests__/lib/assets.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
78
apps/frontend/src/__tests__/lib/utils.test.ts
Normal file
78
apps/frontend/src/__tests__/lib/utils.test.ts
Normal file
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
3
apps/frontend/src/__tests__/setup.ts
Normal file
3
apps/frontend/src/__tests__/setup.ts
Normal file
@ -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 {};
|
||||
94
apps/frontend/src/__tests__/types/booking.test.ts
Normal file
94
apps/frontend/src/__tests__/types/booking.test.ts
Normal file
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
345
apps/frontend/src/__tests__/utils/export.test.ts
Normal file
345
apps/frontend/src/__tests__/utils/export.test.ts
Normal file
@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
18
apps/frontend/tsconfig.test.json
Normal file
18
apps/frontend/tsconfig.test.json
Normal file
@ -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"]
|
||||
}
|
||||
@ -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 }] }
|
||||
},
|
||||
"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" } }] }
|
||||
"color": { "mode": "palette-classic" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "orange", "value": 1 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: >-
|
||||
|
||||
69
package-lock.json
generated
Normal file
69
package-lock.json
generated
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user