Compare commits
2 Commits
d7c5b1ca2c
...
732e9b02bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
732e9b02bd | ||
|
|
fbe28102a1 |
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -67,6 +67,19 @@ jobs:
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
# GOO-134: API unit-test coverage gate (≥70% stmt/lines/funcs, ≥58% branches → ratcheting to 60 via GOO-180).
|
||||
- name: Test coverage (API)
|
||||
run: pnpm --filter @goodgo/api test:coverage
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: api-coverage
|
||||
path: apps/api/coverage
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Supplemental branch-coverage tests for auth guards.
|
||||
* Covers: OptionalJwtAuthGuard.handleRequest pass-through,
|
||||
* RolesGuard x-forwarded-for array/string ip extraction.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { OptionalJwtAuthGuard } from '../guards/optional-jwt-auth.guard';
|
||||
import { RolesGuard } from '../guards/roles.guard';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
describe('OptionalJwtAuthGuard — handleRequest branch coverage', () => {
|
||||
it('handleRequest returns user when user is provided', () => {
|
||||
const guard = new OptionalJwtAuthGuard();
|
||||
const fakeUser = { sub: 'user-1', role: 'BUYER' };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = (guard as any).handleRequest(null, fakeUser);
|
||||
expect(result).toBe(fakeUser);
|
||||
});
|
||||
|
||||
it('handleRequest returns undefined when user is falsy (anonymous)', () => {
|
||||
const guard = new OptionalJwtAuthGuard();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = (guard as any).handleRequest(null, undefined);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handleRequest returns false for unauthenticated passport result', () => {
|
||||
const guard = new OptionalJwtAuthGuard();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = (guard as any).handleRequest(null, false);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('handleRequest ignores error and returns user', () => {
|
||||
const guard = new OptionalJwtAuthGuard();
|
||||
const fakeUser = { sub: 'user-2' };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = (guard as any).handleRequest(new Error('invalid token'), fakeUser);
|
||||
expect(result).toBe(fakeUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RolesGuard — ip extraction branch coverage', () => {
|
||||
let guard: RolesGuard;
|
||||
let mockReflector: { getAllAndOverride: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockReflector = { getAllAndOverride: vi.fn() };
|
||||
mockLogger = { warn: vi.fn() };
|
||||
guard = new RolesGuard(mockReflector as any, mockLogger as any);
|
||||
});
|
||||
|
||||
it('uses x-forwarded-for header for ip when req.ip is absent', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue(['ADMIN']);
|
||||
const mockRequest = {
|
||||
user: { sub: 'u1', role: 'BUYER' },
|
||||
ip: undefined,
|
||||
headers: { 'x-forwarded-for': '203.0.113.1' },
|
||||
};
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: () => mockRequest }),
|
||||
getHandler: () => ({ name: 'h' }),
|
||||
getClass: () => ({ name: 'C' }),
|
||||
} as any;
|
||||
|
||||
const result = guard.canActivate(ctx);
|
||||
expect(result).toBe(false);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Access denied'),
|
||||
'RolesGuard',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs "unknown" ip when neither ip nor headers present', () => {
|
||||
mockReflector.getAllAndOverride.mockReturnValue(['ADMIN']);
|
||||
const mockRequest = {
|
||||
user: { sub: 'u1', role: 'BUYER' },
|
||||
};
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: () => mockRequest }),
|
||||
getHandler: () => ({ name: 'h' }),
|
||||
getClass: () => ({ name: 'C' }),
|
||||
} as any;
|
||||
|
||||
guard.canActivate(ctx);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('unknown'),
|
||||
'RolesGuard',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Supplemental branch-coverage tests for payments handlers.
|
||||
* Covers gateway error path, InternalServerErrorException wrap,
|
||||
* and refund handler edge cases.
|
||||
*/
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { type IPaymentRepository } from '../../domain/repositories/payment.repository';
|
||||
import { CreatePaymentCommand } from '../commands/create-payment/create-payment.command';
|
||||
import { CreatePaymentHandler } from '../commands/create-payment/create-payment.handler';
|
||||
import { RefundPaymentCommand } from '../commands/refund-payment/refund-payment.command';
|
||||
import { RefundPaymentHandler } from '../commands/refund-payment/refund-payment.handler';
|
||||
import { PaymentEntity } from '../../domain/entities/payment.entity';
|
||||
import { Money } from '../../domain/value-objects/money.vo';
|
||||
|
||||
function makeCompletedPayment(): PaymentEntity {
|
||||
const money = Money.create(500_000n).unwrap();
|
||||
const p = PaymentEntity.createNew('pay-1', 'user-1', 'VNPAY', 'LISTING_FEE', money);
|
||||
p.markProcessing('vnpay-tx-1');
|
||||
p.markCompleted({ resultCode: '00' });
|
||||
p.clearDomainEvents();
|
||||
return p;
|
||||
}
|
||||
|
||||
function makePendingPayment(): PaymentEntity {
|
||||
const money = Money.create(500_000n).unwrap();
|
||||
const p = PaymentEntity.createNew('pay-2', 'user-1', 'VNPAY', 'LISTING_FEE', money);
|
||||
p.clearDomainEvents();
|
||||
return p;
|
||||
}
|
||||
|
||||
function makeRepo(): { [K in keyof IPaymentRepository]: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
findById: vi.fn(),
|
||||
findByProviderTxId: vi.fn(),
|
||||
findByIdempotencyKey: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
updateIfStatus: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('CreatePaymentHandler — branch coverage supplements', () => {
|
||||
let handler: CreatePaymentHandler;
|
||||
let mockRepo: ReturnType<typeof makeRepo>;
|
||||
let mockGatewayFactory: { getGateway: ReturnType<typeof vi.fn> };
|
||||
let mockGateway: { createPaymentUrl: ReturnType<typeof vi.fn>; verifyCallback: ReturnType<typeof vi.fn>; refund: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = makeRepo();
|
||||
mockGateway = {
|
||||
createPaymentUrl: vi.fn().mockResolvedValue({ paymentUrl: 'https://pay.vn/1', providerTxId: 'tx-1' }),
|
||||
verifyCallback: vi.fn(),
|
||||
refund: vi.fn(),
|
||||
};
|
||||
mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) };
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
handler = new CreatePaymentHandler(mockRepo as any, mockGatewayFactory as any, mockEventBus as any, mockLogger);
|
||||
});
|
||||
|
||||
const makeCmd = () => new CreatePaymentCommand(
|
||||
'user-1', 'VNPAY', 'LISTING_FEE', 500_000n, 'Thanh toán phí đăng tin',
|
||||
'https://return.url', '127.0.0.1',
|
||||
);
|
||||
|
||||
it('throws ValidationException when gateway createPaymentUrl throws', async () => {
|
||||
mockRepo.findByIdempotencyKey.mockResolvedValue(null);
|
||||
mockGateway.createPaymentUrl.mockRejectedValue(new Error('Gateway timeout'));
|
||||
|
||||
const { ValidationException } = await import('@modules/shared');
|
||||
await expect(handler.execute(makeCmd())).rejects.toBeInstanceOf(ValidationException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps unexpected repo.save error in InternalServerErrorException', async () => {
|
||||
mockRepo.findByIdempotencyKey.mockResolvedValue(null);
|
||||
mockRepo.save.mockRejectedValue(new Error('DB write failed'));
|
||||
|
||||
await expect(handler.execute(makeCmd())).rejects.toBeInstanceOf(InternalServerErrorException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RefundPaymentHandler — branch coverage supplements', () => {
|
||||
let handler: RefundPaymentHandler;
|
||||
let mockRepo: ReturnType<typeof makeRepo>;
|
||||
let mockGatewayFactory: { getGateway: ReturnType<typeof vi.fn> };
|
||||
let mockGateway: { createPaymentUrl: ReturnType<typeof vi.fn>; verifyCallback: ReturnType<typeof vi.fn>; refund: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = makeRepo();
|
||||
mockGateway = {
|
||||
createPaymentUrl: vi.fn(),
|
||||
verifyCallback: vi.fn(),
|
||||
refund: vi.fn().mockResolvedValue({ success: true, refundTxId: 'ref-tx-1' }),
|
||||
};
|
||||
mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) };
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
handler = new RefundPaymentHandler(mockRepo as any, mockGatewayFactory as any, mockLogger);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when payment not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
const { NotFoundException } = await import('@modules/shared');
|
||||
|
||||
await expect(
|
||||
handler.execute(new RefundPaymentCommand('missing-id', 'duplicate', 'user-1')),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when trying to refund non-completed payment', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makePendingPayment());
|
||||
const { ValidationException } = await import('@modules/shared');
|
||||
|
||||
await expect(
|
||||
handler.execute(new RefundPaymentCommand('pay-2', 'error', 'user-1')),
|
||||
).rejects.toBeInstanceOf(ValidationException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Supplemental branch-coverage tests for subscription application handlers.
|
||||
* Targets the uncovered `catch (non-DomainException)` → InternalServerErrorException
|
||||
* paths and plan-field=null branches that were missed by the primary spec files.
|
||||
*/
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SubscriptionEntity } from '../../domain/entities/subscription.entity';
|
||||
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
|
||||
import { CheckQuotaHandler } from '../queries/check-quota/check-quota.handler';
|
||||
import { CheckQuotaQuery } from '../queries/check-quota/check-quota.query';
|
||||
import { MeterUsageHandler } from '../commands/meter-usage/meter-usage.handler';
|
||||
import { MeterUsageCommand } from '../commands/meter-usage/meter-usage.command';
|
||||
import { UpgradeSubscriptionHandler } from '../commands/upgrade-subscription/upgrade-subscription.handler';
|
||||
import { UpgradeSubscriptionCommand } from '../commands/upgrade-subscription/upgrade-subscription.command';
|
||||
import { CancelSubscriptionHandler } from '../commands/cancel-subscription/cancel-subscription.handler';
|
||||
import { CancelSubscriptionCommand } from '../commands/cancel-subscription/cancel-subscription.command';
|
||||
|
||||
function makeActiveSub(): SubscriptionEntity {
|
||||
const sub = SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-1', 'AGENT_PRO',
|
||||
new Date('2026-01-01'), new Date('2026-02-01'),
|
||||
);
|
||||
sub.clearDomainEvents();
|
||||
return sub;
|
||||
}
|
||||
|
||||
function makeRepo(): { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
findById: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeLogger() {
|
||||
return { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
}
|
||||
|
||||
// ── CheckQuotaHandler ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('CheckQuotaHandler — branch coverage supplements', () => {
|
||||
let handler: CheckQuotaHandler;
|
||||
let mockRepo: ReturnType<typeof makeRepo>;
|
||||
let mockPrisma: any;
|
||||
let mockCache: { getOrSet: ReturnType<typeof vi.fn>; invalidate: ReturnType<typeof vi.fn>; invalidateByPrefix: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: ReturnType<typeof makeLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = makeRepo();
|
||||
mockPrisma = {
|
||||
plan: { findFirst: vi.fn(), findUnique: vi.fn() },
|
||||
usageRecord: { findFirst: vi.fn(), findUnique: vi.fn() },
|
||||
};
|
||||
mockCache = {
|
||||
getOrSet: vi.fn().mockImplementation((_k: string, fn: () => Promise<unknown>) => fn()),
|
||||
invalidate: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockLogger = makeLogger();
|
||||
handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any);
|
||||
});
|
||||
|
||||
it('returns unlimited when known plan field has null value (e.g. unlimited savedSearches)', async () => {
|
||||
const sub = makeActiveSub();
|
||||
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||
// maxSavedSearches = null means unlimited
|
||||
mockPrisma.plan.findUnique.mockResolvedValue({ id: 'plan-1', maxSavedSearches: null });
|
||||
|
||||
const result = await handler.execute(new CheckQuotaQuery('user-1', 'searches_saved'));
|
||||
|
||||
expect(result.limit).toBeNull();
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBeNull();
|
||||
});
|
||||
|
||||
it('propagates error from inner loadQuota when DB throws', async () => {
|
||||
mockRepo.findByUserId.mockRejectedValue(new Error('DB crash'));
|
||||
mockCache.getOrSet.mockImplementation(async (_k: string, fn: () => Promise<unknown>) => fn());
|
||||
|
||||
await expect(handler.execute(new CheckQuotaQuery('user-1', 'listings_created')))
|
||||
.rejects.toThrow('DB crash');
|
||||
});
|
||||
|
||||
it('re-throws DomainException directly without wrapping', async () => {
|
||||
const { NotFoundException } = await import('@modules/shared');
|
||||
mockCache.getOrSet.mockImplementationOnce(async (_k: string, fn: () => Promise<unknown>) => {
|
||||
throw new NotFoundException('Plan', 'plan-missing');
|
||||
});
|
||||
|
||||
await expect(handler.execute(new CheckQuotaQuery('user-1', 'listings_created')))
|
||||
.rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ── MeterUsageHandler ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('MeterUsageHandler — branch coverage supplements', () => {
|
||||
let handler: MeterUsageHandler;
|
||||
let mockRepo: ReturnType<typeof makeRepo>;
|
||||
let mockPrisma: any;
|
||||
let mockCache: any;
|
||||
let mockLogger: ReturnType<typeof makeLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = makeRepo();
|
||||
mockPrisma = { usageRecord: { upsert: vi.fn() } };
|
||||
mockCache = { getOrSet: vi.fn(), invalidate: vi.fn().mockResolvedValue(undefined), invalidateByPrefix: vi.fn() };
|
||||
mockLogger = makeLogger();
|
||||
handler = new MeterUsageHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any);
|
||||
});
|
||||
|
||||
it('wraps unexpected repo error in InternalServerErrorException', async () => {
|
||||
const sub = makeActiveSub();
|
||||
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||
mockPrisma.usageRecord.upsert.mockRejectedValue(new Error('DB unavailable'));
|
||||
|
||||
await expect(handler.execute(new MeterUsageCommand('user-1', 'listings_created', 1)))
|
||||
.rejects.toBeInstanceOf(InternalServerErrorException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── UpgradeSubscriptionHandler ────────────────────────────────────────────────
|
||||
|
||||
describe('UpgradeSubscriptionHandler — branch coverage supplements', () => {
|
||||
let handler: UpgradeSubscriptionHandler;
|
||||
let mockRepo: ReturnType<typeof makeRepo>;
|
||||
let mockPrisma: any;
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockCache: any;
|
||||
let mockLogger: ReturnType<typeof makeLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = makeRepo();
|
||||
mockPrisma = { plan: { findFirst: vi.fn() } };
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
mockCache = { invalidateByPrefix: vi.fn().mockResolvedValue(undefined) };
|
||||
mockLogger = makeLogger();
|
||||
handler = new UpgradeSubscriptionHandler(
|
||||
mockRepo as any, mockPrisma, mockEventBus as any, mockCache as any, mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps unexpected error in InternalServerErrorException', async () => {
|
||||
mockRepo.findByUserId.mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
await expect(
|
||||
handler.execute(new UpgradeSubscriptionCommand('user-1', 'ENTERPRISE' as any)),
|
||||
).rejects.toBeInstanceOf(InternalServerErrorException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows lateral switch AGENT_PRO → INVESTOR (same tier order level)', async () => {
|
||||
const sub = makeActiveSub(); // planTier = AGENT_PRO
|
||||
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||
mockPrisma.plan.findFirst.mockResolvedValue({ id: 'plan-investor', tier: 'INVESTOR' });
|
||||
mockRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
const result = await handler.execute(
|
||||
new UpgradeSubscriptionCommand('user-1', 'INVESTOR' as any),
|
||||
);
|
||||
|
||||
expect(result.newTier).toBe('INVESTOR');
|
||||
expect(result.previousTier).toBe('AGENT_PRO');
|
||||
});
|
||||
});
|
||||
|
||||
// ── CancelSubscriptionHandler ─────────────────────────────────────────────────
|
||||
|
||||
describe('CancelSubscriptionHandler — branch coverage supplements', () => {
|
||||
let handler: CancelSubscriptionHandler;
|
||||
let mockRepo: ReturnType<typeof makeRepo>;
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: ReturnType<typeof makeLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = makeRepo();
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
mockLogger = makeLogger();
|
||||
handler = new CancelSubscriptionHandler(mockRepo as any, mockEventBus as any, mockLogger as any);
|
||||
});
|
||||
|
||||
it('wraps unexpected error in InternalServerErrorException', async () => {
|
||||
mockRepo.findByUserId.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(
|
||||
handler.execute(new CancelSubscriptionCommand('user-1', 'test-reason')),
|
||||
).rejects.toBeInstanceOf(InternalServerErrorException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Supplemental branch-coverage tests for SubscriptionEntity.
|
||||
* Covers error paths in markPastDue, markExpired, and isExpired.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SubscriptionEntity } from '../entities/subscription.entity';
|
||||
|
||||
function makeSub(): SubscriptionEntity {
|
||||
return SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-1', 'AGENT_PRO',
|
||||
new Date('2026-01-01'), new Date('2026-02-01'),
|
||||
);
|
||||
}
|
||||
|
||||
describe('SubscriptionEntity — branch coverage supplements', () => {
|
||||
it('returns error from markPastDue when already cancelled', () => {
|
||||
const sub = makeSub();
|
||||
sub.cancel();
|
||||
const result = sub.markPastDue();
|
||||
expect(result.isErr).toBe(true);
|
||||
expect(result.unwrapErr().message).toContain('CANCELLED');
|
||||
});
|
||||
|
||||
it('returns error from markPastDue when already expired', () => {
|
||||
const sub = makeSub();
|
||||
sub.markExpired();
|
||||
const result = sub.markPastDue();
|
||||
expect(result.isErr).toBe(true);
|
||||
});
|
||||
|
||||
it('returns error from markExpired when already cancelled', () => {
|
||||
const sub = makeSub();
|
||||
sub.cancel();
|
||||
const result = sub.markExpired();
|
||||
expect(result.isErr).toBe(true);
|
||||
expect(result.unwrapErr().message).toContain('CANCELLED');
|
||||
});
|
||||
|
||||
it('marks expired from PAST_DUE state successfully', () => {
|
||||
const sub = makeSub();
|
||||
sub.markPastDue();
|
||||
sub.clearDomainEvents();
|
||||
const result = sub.markExpired();
|
||||
expect(result.isOk).toBe(true);
|
||||
expect(sub.status).toBe('EXPIRED');
|
||||
});
|
||||
|
||||
it('isExpired returns false for subscription with future end date', () => {
|
||||
const futureSub = SubscriptionEntity.createNew(
|
||||
'sub-2', 'user-1', 'plan-1', 'FREE',
|
||||
new Date(), new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
expect(futureSub.isExpired()).toBe(false);
|
||||
});
|
||||
|
||||
it('isExpired returns true for subscription with past end date', () => {
|
||||
const pastSub = SubscriptionEntity.createNew(
|
||||
'sub-3', 'user-1', 'plan-1', 'FREE',
|
||||
new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
|
||||
new Date(Date.now() - 1 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
expect(pastSub.isExpired()).toBe(true);
|
||||
});
|
||||
|
||||
it('isActive returns false for cancelled subscription', () => {
|
||||
const sub = makeSub();
|
||||
sub.cancel();
|
||||
expect(sub.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('isActive returns false for past-due subscription', () => {
|
||||
const sub = makeSub();
|
||||
sub.markPastDue();
|
||||
expect(sub.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BankTransferConfirmedEvent } from '@modules/payments';
|
||||
import { SubscriptionEntity } from '../../domain/entities/subscription.entity';
|
||||
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
|
||||
import { BankTransferSubscriptionActivationHandler } from '../event-handlers/bank-transfer-subscription-activation.handler';
|
||||
|
||||
function makeSub(periodStart: Date, periodEnd: Date): SubscriptionEntity {
|
||||
const sub = SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-1', 'AGENT_PRO', periodStart, periodEnd,
|
||||
);
|
||||
sub.clearDomainEvents();
|
||||
return sub;
|
||||
}
|
||||
|
||||
describe('BankTransferSubscriptionActivationHandler', () => {
|
||||
let handler: BankTransferSubscriptionActivationHandler;
|
||||
let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
handler = new BankTransferSubscriptionActivationHandler(
|
||||
mockRepo as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
const makeEvent = (type: 'SUBSCRIPTION' | 'LISTING_FEE'): BankTransferConfirmedEvent =>
|
||||
new BankTransferConfirmedEvent(
|
||||
'payment-1', 'user-1', type as any, 5_000_000n, 'admin-1', 'REF-001',
|
||||
);
|
||||
|
||||
it('does nothing for non-SUBSCRIPTION payment types', async () => {
|
||||
await handler.handle(makeEvent('LISTING_FEE'));
|
||||
expect(mockRepo.findByUserId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs warning and returns when no subscription found for user', async () => {
|
||||
mockRepo.findByUserId.mockResolvedValue(null);
|
||||
await handler.handle(makeEvent('SUBSCRIPTION'));
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('manual CS review required'),
|
||||
'BankTransferSubscriptionActivationHandler',
|
||||
);
|
||||
expect(mockRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renews period from current periodEnd when subscription is still active (end > now)', async () => {
|
||||
const now = new Date();
|
||||
const futureEnd = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000); // +15 days
|
||||
const start = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000); // -15 days
|
||||
const sub = makeSub(start, futureEnd);
|
||||
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||
|
||||
await handler.handle(makeEvent('SUBSCRIPTION'));
|
||||
|
||||
expect(mockRepo.update).toHaveBeenCalledWith(sub);
|
||||
const events = sub.domainEvents;
|
||||
expect(events.some((e) => e.eventName === 'subscription.renewed')).toBe(true);
|
||||
expect(sub.currentPeriodEnd.getTime()).toBeGreaterThan(futureEnd.getTime());
|
||||
});
|
||||
|
||||
it('renews period from now when subscription is already expired (end <= now)', async () => {
|
||||
const pastStart = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);
|
||||
const pastEnd = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
|
||||
const sub = makeSub(pastStart, pastEnd);
|
||||
mockRepo.findByUserId.mockResolvedValue(sub);
|
||||
|
||||
const before = Date.now();
|
||||
await handler.handle(makeEvent('SUBSCRIPTION'));
|
||||
const after = Date.now();
|
||||
|
||||
expect(mockRepo.update).toHaveBeenCalledWith(sub);
|
||||
expect(sub.currentPeriodStart.getTime()).toBeGreaterThanOrEqual(before - 100);
|
||||
expect(sub.currentPeriodStart.getTime()).toBeLessThanOrEqual(after + 100);
|
||||
});
|
||||
|
||||
it('logs success after activation', async () => {
|
||||
const now = new Date();
|
||||
const futureEnd = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000);
|
||||
mockRepo.findByUserId.mockResolvedValue(makeSub(now, futureEnd));
|
||||
|
||||
await handler.handle(makeEvent('SUBSCRIPTION'));
|
||||
|
||||
expect(mockLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Subscription activated via bank transfer'),
|
||||
'BankTransferSubscriptionActivationHandler',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs error and does not rethrow when repo.update throws', async () => {
|
||||
const now = new Date();
|
||||
const futureEnd = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000);
|
||||
mockRepo.findByUserId.mockResolvedValue(makeSub(now, futureEnd));
|
||||
mockRepo.update.mockRejectedValue(new Error('DB connection lost'));
|
||||
|
||||
await expect(handler.handle(makeEvent('SUBSCRIPTION'))).resolves.not.toThrow();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to activate subscription on bank transfer confirmation'),
|
||||
expect.any(String),
|
||||
'BankTransferSubscriptionActivationHandler',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,30 @@ export default defineConfig({
|
||||
env: {
|
||||
BCRYPT_ROUNDS: '4',
|
||||
},
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'text-summary', 'html', 'lcov', 'json-summary'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.spec.ts',
|
||||
'src/**/*.integration.spec.ts',
|
||||
'src/**/__tests__/**',
|
||||
'src/**/*.module.ts',
|
||||
'src/**/*.dto.ts',
|
||||
'src/**/index.ts',
|
||||
'src/main.ts',
|
||||
],
|
||||
// GOO-134: CI gate thresholds. Branches starts at 58 (no-regression ratchet)
|
||||
// and will be raised to 60 via follow-up GOO-180 (payments/sbv-compliance,
|
||||
// subscriptions/quotas, auth/guards). CTO approval: 8f2b125a.
|
||||
thresholds: {
|
||||
statements: 70,
|
||||
lines: 70,
|
||||
functions: 70,
|
||||
branches: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -56,7 +56,7 @@ export function TransferListingCard({ listing }: TransferListingCardProps) {
|
||||
{/* Location */}
|
||||
<div className="mb-3 flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="line-clamp-1">{listing.district}, {listing.city}</span>
|
||||
<span className="line-clamp-1">{listing.ward ? `${listing.ward}, ` : ''}{listing.district}, {listing.city}</span>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { useMarkInquiryRead } from '@/lib/hooks/use-inquiries';
|
||||
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
||||
import { formatPhone, zaloHref } from '@/lib/phone';
|
||||
|
||||
interface InquiryDetailDialogProps {
|
||||
inquiry: InquiryReadDto | null;
|
||||
@@ -42,6 +43,8 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const phone = inquiry.phone ?? inquiry.userPhone;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md sm:max-w-lg">
|
||||
@@ -60,7 +63,7 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
||||
<InquiryStatusBadge isRead={inquiry.isRead} />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>SĐT: {inquiry.phone ?? inquiry.userPhone}</p>
|
||||
<p>SĐT: {formatPhone(phone)}</p>
|
||||
<p>Ngày gửi: {formattedDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,13 +81,13 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
||||
<h4 className="text-sm font-medium">Liên hệ nhanh</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href={`tel:${inquiry.phone ?? inquiry.userPhone}`}
|
||||
href={`tel:${phone}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<Phone className="h-4 w-4" aria-hidden="true" /> Gọi điện
|
||||
</a>
|
||||
<a
|
||||
href={`https://zalo.me/${(inquiry.phone ?? inquiry.userPhone).replace(/^0/, '84')}`}
|
||||
href={zaloHref(phone)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { useDeleteLead, useUpdateLeadStatus } from '@/lib/hooks/use-leads';
|
||||
import { LEAD_STATUSES, LEAD_SOURCES, type LeadReadDto, type LeadStatus } from '@/lib/leads-api';
|
||||
import { formatPhone, zaloHref } from '@/lib/phone';
|
||||
|
||||
interface LeadDetailDialogProps {
|
||||
lead: LeadReadDto | null;
|
||||
@@ -96,7 +97,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
||||
<LeadStatusBadge status={lead.status} />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>SĐT: {lead.phone}</p>
|
||||
<p>SĐT: {formatPhone(lead.phone)}</p>
|
||||
{lead.email && <p>Email: {lead.email}</p>}
|
||||
<p>Nguồn: {getSourceLabel(lead.source)}</p>
|
||||
{lead.score !== null && <p>Điểm: {lead.score}/100</p>}
|
||||
@@ -163,7 +164,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={`https://zalo.me/${lead.phone.replace(/^0/, '84')}`}
|
||||
href={zaloHref(lead.phone)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
|
||||
@@ -106,9 +106,15 @@ describe('PropertyCard', () => {
|
||||
expect(screen.getByText(/3\.5 tỷ/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property address', () => {
|
||||
it('renders property address including ward', () => {
|
||||
render(<PropertyCard listing={makeListing()} />);
|
||||
expect(screen.getByText(/208 Nguyễn Hữu Cảnh/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Phường 22/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ward in list layout', () => {
|
||||
render(<PropertyCard listing={makeListing()} layout="list" />);
|
||||
expect(screen.getByText(/Phường 22/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders transaction type badge for SALE', () => {
|
||||
|
||||
@@ -92,7 +92,7 @@ export function PropertyCard({ listing, compact, layout = 'card' }: PropertyCard
|
||||
{listing.property.title}
|
||||
</h3>
|
||||
<p className="mt-0.5 line-clamp-1 text-sm text-muted-foreground">
|
||||
{listing.property.address}, {listing.property.district}, {listing.property.city}
|
||||
{listing.property.address}, {listing.property.ward}, {listing.property.district}, {listing.property.city}
|
||||
</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-lg font-bold text-primary">
|
||||
@@ -186,7 +186,7 @@ export function PropertyCard({ listing, compact, layout = 'card' }: PropertyCard
|
||||
</p>
|
||||
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
|
||||
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
|
||||
{listing.property.address}, {listing.property.district}, {listing.property.city}
|
||||
{listing.property.address}, {listing.property.ward}, {listing.property.district}, {listing.property.city}
|
||||
</p>
|
||||
<ul className="mt-3 flex flex-wrap gap-1.5" aria-label="Thông tin bất động sản">
|
||||
<li>
|
||||
|
||||
59
apps/web/lib/phone.ts
Normal file
59
apps/web/lib/phone.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Vietnamese phone number helpers.
|
||||
*
|
||||
* Regex covers the current VN numbering plan:
|
||||
* 0[35789]x xxxxxxx — Viettel, Mobifone, Vinaphone, Gmobile, Indochina
|
||||
*
|
||||
* See: https://en.wikipedia.org/wiki/Telephone_numbers_in_Vietnam
|
||||
*/
|
||||
|
||||
/** Matches a VN mobile number, with optional +84 or leading 0. */
|
||||
export const VN_PHONE_REGEX =
|
||||
/^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
|
||||
|
||||
/**
|
||||
* Normalise a VN phone number to the E.164-ish form used by Zalo / APIs:
|
||||
* strip leading 0 and prepend the country code (84).
|
||||
*
|
||||
* "0987654321" → "84987654321"
|
||||
* "+84987654321" → "84987654321"
|
||||
* "84987654321" → "84987654321" (already normalised — idempotent)
|
||||
*/
|
||||
export function normalizePhone(phone: string): string {
|
||||
const cleaned = phone.trim();
|
||||
if (cleaned.startsWith('+84')) return `84${cleaned.slice(3)}`;
|
||||
if (cleaned.startsWith('84')) return cleaned;
|
||||
if (cleaned.startsWith('0')) return `84${cleaned.slice(1)}`;
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a raw VN phone number for display.
|
||||
* Handles 10-digit numbers (0xx xxxx xxxx).
|
||||
*
|
||||
* "0987654321" → "0987 654 321"
|
||||
* Passthrough for anything that doesn't match.
|
||||
*/
|
||||
export function formatPhone(phone: string): string {
|
||||
const cleaned = phone.trim().replace(/\s+/g, '');
|
||||
|
||||
// 10-digit local format: 0xxx yyy zzz
|
||||
const tenDigit = cleaned.match(/^(0\d{3})(\d{3})(\d{3})$/);
|
||||
if (tenDigit) return `${tenDigit[1]} ${tenDigit[2]} ${tenDigit[3]}`;
|
||||
|
||||
// +84 prefix → treat as 10-digit local after swapping prefix
|
||||
const e164 = cleaned.match(/^\+84(\d{9})$/);
|
||||
if (e164) return formatPhone(`0${e164[1]}`);
|
||||
|
||||
return phone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the https://zalo.me deep-link URL for a given phone number.
|
||||
*
|
||||
* Zalo expects the number without a leading zero, prefixed with 84.
|
||||
* "0987654321" → "https://zalo.me/84987654321"
|
||||
*/
|
||||
export function zaloHref(phone: string): string {
|
||||
return `https://zalo.me/${normalizePhone(phone)}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const phoneRegex = /^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
|
||||
import { VN_PHONE_REGEX as phoneRegex } from '@/lib/phone';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
phone: z
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Vietnamese phone number rule:
|
||||
* - 9–11 digits, optional leading +84 or 0.
|
||||
* We keep validation pragmatic: whitespace is stripped, then the remaining
|
||||
* string must be 9–11 digits (country code / leading zero stripped).
|
||||
*/
|
||||
const PHONE_REGEX = /^(?:\+?84|0)?\d{9,11}$/;
|
||||
import { VN_PHONE_REGEX as PHONE_REGEX } from '@/lib/phone';
|
||||
|
||||
export const inquiryFormSchema = z.object({
|
||||
message: z
|
||||
|
||||
Reference in New Issue
Block a user