feat(shared): add shared module with domain primitives, infrastructure services, and utils
Domain primitives: BaseEntity, AggregateRoot, ValueObject, DomainEvent, Result<T,E> 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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
85
apps/api/src/modules/shared/domain/__tests__/result.spec.ts
Normal file
85
apps/api/src/modules/shared/domain/__tests__/result.spec.ts
Normal file
@@ -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<number>(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<number>(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<number>(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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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__/**"]
|
||||
}
|
||||
|
||||
15
apps/api/vitest.config.ts
Normal file
15
apps/api/vitest.config.ts
Normal file
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { BaseEntity } from './base.entity';
|
||||
|
||||
export interface DomainEvent {
|
||||
readonly occurredAt: Date;
|
||||
readonly eventName: string;
|
||||
}
|
||||
|
||||
export abstract class AggregateRoot<TProps> extends BaseEntity<TProps> {
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
get domainEvents(): ReadonlyArray<DomainEvent> {
|
||||
return this._domainEvents;
|
||||
}
|
||||
|
||||
protected addDomainEvent(event: DomainEvent): void {
|
||||
this._domainEvents.push(event);
|
||||
}
|
||||
|
||||
clearDomainEvents(): void {
|
||||
this._domainEvents = [];
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export abstract class BaseEntity<TProps> {
|
||||
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<TProps>): boolean {
|
||||
if (other === null || other === undefined) return false;
|
||||
if (this === other) return true;
|
||||
return this._id === other._id;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export abstract class ValueObject<TProps> {
|
||||
protected readonly props: TProps;
|
||||
|
||||
constructor(props: TProps) {
|
||||
this.props = Object.freeze(props);
|
||||
}
|
||||
|
||||
equals(other: ValueObject<TProps>): boolean {
|
||||
if (other === null || other === undefined) return false;
|
||||
return JSON.stringify(this.props) === JSON.stringify(other.props);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
|
||||
export function ok<T>(value: T): Result<T, never> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
export function err<E>(error: E): Result<never, E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
Reference in New Issue
Block a user