From 1fb7bb39d2ddc10a9b70449501b5c3ad46d4008b Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 00:07:27 +0700 Subject: [PATCH] feat(shared): add shared module with domain primitives, infrastructure services, and utils Domain primitives: BaseEntity, AggregateRoot, ValueObject, DomainEvent, Result Infrastructure: PrismaService, RedisService, LoggerService (pino), EventBusService Utils: Vietnam phone validator/normalizer, VND currency formatter, Vietnamese slug generator Includes 45 unit tests covering all domain primitives, validators, and formatters. Co-Authored-By: Paperclip --- .../domain/__tests__/aggregate-root.spec.ts | 43 ++++++++++ .../shared/domain/__tests__/result.spec.ts | 85 +++++++++++++++++++ .../domain/__tests__/value-object.spec.ts | 35 ++++++++ .../modules/shared/domain/aggregate-root.ts | 0 .../src}/modules/shared/domain/base-entity.ts | 0 .../modules/shared/domain/domain-event.ts | 0 .../api/src}/modules/shared/domain/index.ts | 0 .../api/src}/modules/shared/domain/result.ts | 0 .../modules/shared/domain/value-object.ts | 0 {src => apps/api/src}/modules/shared/index.ts | 0 .../infrastructure/event-bus.service.ts | 0 .../modules/shared/infrastructure/index.ts | 0 .../shared/infrastructure/logger.service.ts | 0 .../shared/infrastructure/prisma.service.ts | 0 .../shared/infrastructure/redis.service.ts | 0 .../api/src}/modules/shared/shared.module.ts | 0 .../__tests__/currency.formatter.spec.ts | 32 +++++++ .../utils/__tests__/slug.generator.spec.ts | 24 ++++++ .../__tests__/vietnam-phone.validator.spec.ts | 45 ++++++++++ .../shared/utils/currency.formatter.ts | 0 .../api/src}/modules/shared/utils/index.ts | 0 .../modules/shared/utils/slug.generator.ts | 0 .../shared/utils/vietnam-phone.validator.ts | 0 apps/api/tsconfig.json | 6 +- apps/api/vitest.config.ts | 15 ++++ .../shared/domain/entities/aggregate-root.ts | 22 ----- .../shared/domain/entities/base.entity.ts | 37 -------- .../domain/value-objects/value-object.ts | 12 --- src/modules/shared/tsconfig.json | 9 -- src/modules/shared/types/result.ts | 9 -- 30 files changed, 282 insertions(+), 92 deletions(-) create mode 100644 apps/api/src/modules/shared/domain/__tests__/aggregate-root.spec.ts create mode 100644 apps/api/src/modules/shared/domain/__tests__/result.spec.ts create mode 100644 apps/api/src/modules/shared/domain/__tests__/value-object.spec.ts rename {src => apps/api/src}/modules/shared/domain/aggregate-root.ts (100%) rename {src => apps/api/src}/modules/shared/domain/base-entity.ts (100%) rename {src => apps/api/src}/modules/shared/domain/domain-event.ts (100%) rename {src => apps/api/src}/modules/shared/domain/index.ts (100%) rename {src => apps/api/src}/modules/shared/domain/result.ts (100%) rename {src => apps/api/src}/modules/shared/domain/value-object.ts (100%) rename {src => apps/api/src}/modules/shared/index.ts (100%) rename {src => apps/api/src}/modules/shared/infrastructure/event-bus.service.ts (100%) rename {src => apps/api/src}/modules/shared/infrastructure/index.ts (100%) rename {src => apps/api/src}/modules/shared/infrastructure/logger.service.ts (100%) rename {src => apps/api/src}/modules/shared/infrastructure/prisma.service.ts (100%) rename {src => apps/api/src}/modules/shared/infrastructure/redis.service.ts (100%) rename {src => apps/api/src}/modules/shared/shared.module.ts (100%) create mode 100644 apps/api/src/modules/shared/utils/__tests__/currency.formatter.spec.ts create mode 100644 apps/api/src/modules/shared/utils/__tests__/slug.generator.spec.ts create mode 100644 apps/api/src/modules/shared/utils/__tests__/vietnam-phone.validator.spec.ts rename {src => apps/api/src}/modules/shared/utils/currency.formatter.ts (100%) rename {src => apps/api/src}/modules/shared/utils/index.ts (100%) rename {src => apps/api/src}/modules/shared/utils/slug.generator.ts (100%) rename {src => apps/api/src}/modules/shared/utils/vietnam-phone.validator.ts (100%) create mode 100644 apps/api/vitest.config.ts delete mode 100644 src/modules/shared/domain/entities/aggregate-root.ts delete mode 100644 src/modules/shared/domain/entities/base.entity.ts delete mode 100644 src/modules/shared/domain/value-objects/value-object.ts delete mode 100644 src/modules/shared/tsconfig.json delete mode 100644 src/modules/shared/types/result.ts diff --git a/apps/api/src/modules/shared/domain/__tests__/aggregate-root.spec.ts b/apps/api/src/modules/shared/domain/__tests__/aggregate-root.spec.ts new file mode 100644 index 0000000..0b05c50 --- /dev/null +++ b/apps/api/src/modules/shared/domain/__tests__/aggregate-root.spec.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { AggregateRoot } from '../aggregate-root'; +import { type DomainEvent } from '../domain-event'; + +class TestAggregate extends AggregateRoot { + doSomething(): void { + this.addDomainEvent({ + eventName: 'TestHappened', + occurredAt: new Date(), + aggregateId: this.id, + }); + } +} + +describe('AggregateRoot', () => { + it('should collect domain events', () => { + const agg = new TestAggregate('agg-1'); + agg.doSomething(); + agg.doSomething(); + expect(agg.domainEvents).toHaveLength(2); + expect(agg.domainEvents[0]!.eventName).toBe('TestHappened'); + }); + + it('should clear domain events and return them', () => { + const agg = new TestAggregate('agg-1'); + agg.doSomething(); + const events = agg.clearDomainEvents(); + expect(events).toHaveLength(1); + expect(agg.domainEvents).toHaveLength(0); + }); + + it('should return a copy of domain events (not mutable reference)', () => { + const agg = new TestAggregate('agg-1'); + agg.doSomething(); + const events = agg.domainEvents; + (events as DomainEvent[]).push({ + eventName: 'Fake', + occurredAt: new Date(), + aggregateId: 'x', + }); + expect(agg.domainEvents).toHaveLength(1); + }); +}); diff --git a/apps/api/src/modules/shared/domain/__tests__/result.spec.ts b/apps/api/src/modules/shared/domain/__tests__/result.spec.ts new file mode 100644 index 0000000..b4c25e7 --- /dev/null +++ b/apps/api/src/modules/shared/domain/__tests__/result.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { Result } from '../result'; + +describe('Result', () => { + describe('ok', () => { + it('should create a successful result', () => { + const result = Result.ok(42); + expect(result.isOk).toBe(true); + expect(result.isErr).toBe(false); + expect(result.unwrap()).toBe(42); + }); + }); + + describe('err', () => { + it('should create an error result', () => { + const error = new Error('fail'); + const result = Result.err(error); + expect(result.isOk).toBe(false); + expect(result.isErr).toBe(true); + expect(result.unwrapErr()).toBe(error); + }); + }); + + describe('unwrap', () => { + it('should throw on error result', () => { + const result = Result.err(new Error('boom')); + expect(() => result.unwrap()).toThrow('boom'); + }); + }); + + describe('unwrapOr', () => { + it('should return value on ok', () => { + expect(Result.ok(10).unwrapOr(0)).toBe(10); + }); + + it('should return default on err', () => { + expect(Result.err(new Error('x')).unwrapOr(0)).toBe(0); + }); + }); + + describe('map', () => { + it('should transform ok value', () => { + const result = Result.ok(5).map((v) => v * 2); + expect(result.unwrap()).toBe(10); + }); + + it('should pass through error', () => { + const error = new Error('oops'); + const result = Result.err(error).map((v) => v * 2); + expect(result.unwrapErr()).toBe(error); + }); + }); + + describe('andThen', () => { + it('should chain ok results', () => { + const result = Result.ok(5).andThen((v) => + v > 0 ? Result.ok(v * 2) : Result.err(new Error('negative')), + ); + expect(result.unwrap()).toBe(10); + }); + + it('should short-circuit on error', () => { + const result = Result.err(new Error('first')).andThen((v) => Result.ok(v * 2)); + expect(result.unwrapErr().message).toBe('first'); + }); + }); + + describe('match', () => { + it('should call ok handler on success', () => { + const output = Result.ok(42).match({ + ok: (v) => `value: ${v}`, + err: (e) => `error: ${e.message}`, + }); + expect(output).toBe('value: 42'); + }); + + it('should call err handler on failure', () => { + const output = Result.err(new Error('bad')).match({ + ok: () => 'ok', + err: (e) => `error: ${e.message}`, + }); + expect(output).toBe('error: bad'); + }); + }); +}); diff --git a/apps/api/src/modules/shared/domain/__tests__/value-object.spec.ts b/apps/api/src/modules/shared/domain/__tests__/value-object.spec.ts new file mode 100644 index 0000000..39d6ec4 --- /dev/null +++ b/apps/api/src/modules/shared/domain/__tests__/value-object.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { ValueObject } from '../value-object'; + +class TestVO extends ValueObject<{ value: string }> { + get value(): string { + return this.props.value; + } +} + +describe('ValueObject', () => { + it('should be immutable', () => { + const vo = new TestVO({ value: 'hello' }); + expect(() => { + (vo as any).props.value = 'changed'; + }).toThrow(); + }); + + it('should be equal when props match', () => { + const a = new TestVO({ value: 'same' }); + const b = new TestVO({ value: 'same' }); + expect(a.equals(b)).toBe(true); + }); + + it('should not be equal when props differ', () => { + const a = new TestVO({ value: 'one' }); + const b = new TestVO({ value: 'two' }); + expect(a.equals(b)).toBe(false); + }); + + it('should not be equal to null/undefined', () => { + const vo = new TestVO({ value: 'x' }); + expect(vo.equals(null as any)).toBe(false); + expect(vo.equals(undefined as any)).toBe(false); + }); +}); diff --git a/src/modules/shared/domain/aggregate-root.ts b/apps/api/src/modules/shared/domain/aggregate-root.ts similarity index 100% rename from src/modules/shared/domain/aggregate-root.ts rename to apps/api/src/modules/shared/domain/aggregate-root.ts diff --git a/src/modules/shared/domain/base-entity.ts b/apps/api/src/modules/shared/domain/base-entity.ts similarity index 100% rename from src/modules/shared/domain/base-entity.ts rename to apps/api/src/modules/shared/domain/base-entity.ts diff --git a/src/modules/shared/domain/domain-event.ts b/apps/api/src/modules/shared/domain/domain-event.ts similarity index 100% rename from src/modules/shared/domain/domain-event.ts rename to apps/api/src/modules/shared/domain/domain-event.ts diff --git a/src/modules/shared/domain/index.ts b/apps/api/src/modules/shared/domain/index.ts similarity index 100% rename from src/modules/shared/domain/index.ts rename to apps/api/src/modules/shared/domain/index.ts diff --git a/src/modules/shared/domain/result.ts b/apps/api/src/modules/shared/domain/result.ts similarity index 100% rename from src/modules/shared/domain/result.ts rename to apps/api/src/modules/shared/domain/result.ts diff --git a/src/modules/shared/domain/value-object.ts b/apps/api/src/modules/shared/domain/value-object.ts similarity index 100% rename from src/modules/shared/domain/value-object.ts rename to apps/api/src/modules/shared/domain/value-object.ts diff --git a/src/modules/shared/index.ts b/apps/api/src/modules/shared/index.ts similarity index 100% rename from src/modules/shared/index.ts rename to apps/api/src/modules/shared/index.ts diff --git a/src/modules/shared/infrastructure/event-bus.service.ts b/apps/api/src/modules/shared/infrastructure/event-bus.service.ts similarity index 100% rename from src/modules/shared/infrastructure/event-bus.service.ts rename to apps/api/src/modules/shared/infrastructure/event-bus.service.ts diff --git a/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts similarity index 100% rename from src/modules/shared/infrastructure/index.ts rename to apps/api/src/modules/shared/infrastructure/index.ts diff --git a/src/modules/shared/infrastructure/logger.service.ts b/apps/api/src/modules/shared/infrastructure/logger.service.ts similarity index 100% rename from src/modules/shared/infrastructure/logger.service.ts rename to apps/api/src/modules/shared/infrastructure/logger.service.ts diff --git a/src/modules/shared/infrastructure/prisma.service.ts b/apps/api/src/modules/shared/infrastructure/prisma.service.ts similarity index 100% rename from src/modules/shared/infrastructure/prisma.service.ts rename to apps/api/src/modules/shared/infrastructure/prisma.service.ts diff --git a/src/modules/shared/infrastructure/redis.service.ts b/apps/api/src/modules/shared/infrastructure/redis.service.ts similarity index 100% rename from src/modules/shared/infrastructure/redis.service.ts rename to apps/api/src/modules/shared/infrastructure/redis.service.ts diff --git a/src/modules/shared/shared.module.ts b/apps/api/src/modules/shared/shared.module.ts similarity index 100% rename from src/modules/shared/shared.module.ts rename to apps/api/src/modules/shared/shared.module.ts diff --git a/apps/api/src/modules/shared/utils/__tests__/currency.formatter.spec.ts b/apps/api/src/modules/shared/utils/__tests__/currency.formatter.spec.ts new file mode 100644 index 0000000..67cf775 --- /dev/null +++ b/apps/api/src/modules/shared/utils/__tests__/currency.formatter.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { formatVND, formatVNDCompact, parseVND } from '../currency.formatter'; + +describe('formatVND', () => { + it('should format VND amount', () => { + const result = formatVND(1500000); + expect(result).toMatch(/1[.,]500[.,]000/); + expect(result).toContain('₫'); + }); + + it('should format zero', () => { + const result = formatVND(0); + expect(result).toContain('0'); + }); +}); + +describe('formatVNDCompact', () => { + it('should format large amounts compactly', () => { + const result = formatVNDCompact(1500000000); + expect(result.length).toBeLessThan(20); + }); +}); + +describe('parseVND', () => { + it('should parse numeric string', () => { + expect(parseVND('1.500.000 ₫')).toBe(1500000); + }); + + it('should return null for non-numeric', () => { + expect(parseVND('')).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/shared/utils/__tests__/slug.generator.spec.ts b/apps/api/src/modules/shared/utils/__tests__/slug.generator.spec.ts new file mode 100644 index 0000000..7a1a69a --- /dev/null +++ b/apps/api/src/modules/shared/utils/__tests__/slug.generator.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { generateSlug } from '../slug.generator'; + +describe('generateSlug', () => { + it('should generate slug from Vietnamese text', () => { + expect(generateSlug('Căn hộ cao cấp Quận 7')).toBe('can-ho-cao-cap-quan-7'); + }); + + it('should handle đ character', () => { + expect(generateSlug('Đất nền Bình Dương')).toBe('dat-nen-binh-duong'); + }); + + it('should handle multiple spaces and special chars', () => { + expect(generateSlug(' Nhà phố - Thủ Đức!! ')).toBe('nha-pho-thu-duc'); + }); + + it('should handle plain ASCII', () => { + expect(generateSlug('Hello World')).toBe('hello-world'); + }); + + it('should handle empty string', () => { + expect(generateSlug('')).toBe(''); + }); +}); diff --git a/apps/api/src/modules/shared/utils/__tests__/vietnam-phone.validator.spec.ts b/apps/api/src/modules/shared/utils/__tests__/vietnam-phone.validator.spec.ts new file mode 100644 index 0000000..8253705 --- /dev/null +++ b/apps/api/src/modules/shared/utils/__tests__/vietnam-phone.validator.spec.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { isValidVietnamPhone, normalizeVietnamPhone } from '../vietnam-phone.validator'; + +describe('isValidVietnamPhone', () => { + it.each([ + '0912345678', + '0351234567', + '0561234567', + '0701234567', + '0812345678', + '+84912345678', + '84912345678', + ])('should accept valid phone: %s', (phone) => { + expect(isValidVietnamPhone(phone)).toBe(true); + }); + + it.each([ + '012345678', + '091234567', // too short + '09123456789', // too long + '1234567890', + '+1234567890', + '', + ])('should reject invalid phone: %s', (phone) => { + expect(isValidVietnamPhone(phone)).toBe(false); + }); +}); + +describe('normalizeVietnamPhone', () => { + it('should normalize 0-prefix to +84', () => { + expect(normalizeVietnamPhone('0912345678')).toBe('+84912345678'); + }); + + it('should normalize 84-prefix to +84', () => { + expect(normalizeVietnamPhone('84912345678')).toBe('+84912345678'); + }); + + it('should keep +84 prefix', () => { + expect(normalizeVietnamPhone('+84912345678')).toBe('+84912345678'); + }); + + it('should return null for invalid phone', () => { + expect(normalizeVietnamPhone('invalid')).toBeNull(); + }); +}); diff --git a/src/modules/shared/utils/currency.formatter.ts b/apps/api/src/modules/shared/utils/currency.formatter.ts similarity index 100% rename from src/modules/shared/utils/currency.formatter.ts rename to apps/api/src/modules/shared/utils/currency.formatter.ts diff --git a/src/modules/shared/utils/index.ts b/apps/api/src/modules/shared/utils/index.ts similarity index 100% rename from src/modules/shared/utils/index.ts rename to apps/api/src/modules/shared/utils/index.ts diff --git a/src/modules/shared/utils/slug.generator.ts b/apps/api/src/modules/shared/utils/slug.generator.ts similarity index 100% rename from src/modules/shared/utils/slug.generator.ts rename to apps/api/src/modules/shared/utils/slug.generator.ts diff --git a/src/modules/shared/utils/vietnam-phone.validator.ts b/apps/api/src/modules/shared/utils/vietnam-phone.validator.ts similarity index 100% rename from src/modules/shared/utils/vietnam-phone.validator.ts rename to apps/api/src/modules/shared/utils/vietnam-phone.validator.ts diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 8248de5..c1d1616 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -10,9 +10,9 @@ "declaration": false, "declarationMap": false, "paths": { - "@modules/*": ["../../src/modules/*"] + "@modules/*": ["./src/modules/*"] } }, - "include": ["src/**/*", "../../src/modules/**/*"], - "exclude": ["node_modules", "dist"] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/__tests__/**"] } diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..1620d5e --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,15 @@ +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.spec.ts'], + }, + resolve: { + alias: { + '@modules': path.resolve(__dirname, 'src/modules'), + }, + }, +}); diff --git a/src/modules/shared/domain/entities/aggregate-root.ts b/src/modules/shared/domain/entities/aggregate-root.ts deleted file mode 100644 index 85e2028..0000000 --- a/src/modules/shared/domain/entities/aggregate-root.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BaseEntity } from './base.entity'; - -export interface DomainEvent { - readonly occurredAt: Date; - readonly eventName: string; -} - -export abstract class AggregateRoot extends BaseEntity { - private _domainEvents: DomainEvent[] = []; - - get domainEvents(): ReadonlyArray { - return this._domainEvents; - } - - protected addDomainEvent(event: DomainEvent): void { - this._domainEvents.push(event); - } - - clearDomainEvents(): void { - this._domainEvents = []; - } -} diff --git a/src/modules/shared/domain/entities/base.entity.ts b/src/modules/shared/domain/entities/base.entity.ts deleted file mode 100644 index aea11e1..0000000 --- a/src/modules/shared/domain/entities/base.entity.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -export abstract class BaseEntity { - private readonly _id: string; - private readonly _createdAt: Date; - private _updatedAt: Date; - protected props: TProps; - - constructor(props: TProps, id?: string) { - this._id = id ?? randomUUID(); - this._createdAt = new Date(); - this._updatedAt = new Date(); - this.props = props; - } - - get id(): string { - return this._id; - } - - get createdAt(): Date { - return this._createdAt; - } - - get updatedAt(): Date { - return this._updatedAt; - } - - protected markUpdated(): void { - this._updatedAt = new Date(); - } - - equals(other: BaseEntity): boolean { - if (other === null || other === undefined) return false; - if (this === other) return true; - return this._id === other._id; - } -} diff --git a/src/modules/shared/domain/value-objects/value-object.ts b/src/modules/shared/domain/value-objects/value-object.ts deleted file mode 100644 index c9251bd..0000000 --- a/src/modules/shared/domain/value-objects/value-object.ts +++ /dev/null @@ -1,12 +0,0 @@ -export abstract class ValueObject { - protected readonly props: TProps; - - constructor(props: TProps) { - this.props = Object.freeze(props); - } - - equals(other: ValueObject): boolean { - if (other === null || other === undefined) return false; - return JSON.stringify(this.props) === JSON.stringify(other.props); - } -} diff --git a/src/modules/shared/tsconfig.json b/src/modules/shared/tsconfig.json deleted file mode 100644 index 8bcc9b6..0000000 --- a/src/modules/shared/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "." - }, - "include": ["./**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/src/modules/shared/types/result.ts b/src/modules/shared/types/result.ts deleted file mode 100644 index 39e40a2..0000000 --- a/src/modules/shared/types/result.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type Result = { ok: true; value: T } | { ok: false; error: E }; - -export function ok(value: T): Result { - return { ok: true, value }; -} - -export function err(error: E): Result { - return { ok: false, error }; -}