Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/common/config/config-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export const ConfigKey = {
JwtAccessSecret: 'JWT_ACCESS_SECRET_KEY',
JwtTempSecret: 'JWT_TEMP_SECRET_KEY',
JwtProgressSecret: 'JWT_PROGRESS_SECRET_KEY',
JwtAccessExpiresIn: 'JWT_ACCESS_EXPIRES_IN',
JwtTempExpiresIn: 'JWT_TEMP_EXPIRES_IN',
JwtProgressExpiresIn: 'JWT_PROGRESS_EXPIRES_IN',
},
Pricing: {
BasePrice: 'PRICING_BASE_PRICE',
Expand Down
4 changes: 4 additions & 0 deletions src/common/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export class EnvSchema {
@IsString() @MinLength(16) JWT_TEMP_SECRET_KEY!: string;
@IsString() @MinLength(16) JWT_PROGRESS_SECRET_KEY!: string;

@IsOptional() @IsString() JWT_ACCESS_EXPIRES_IN?: string;
@IsOptional() @IsString() JWT_TEMP_EXPIRES_IN?: string;
@IsOptional() @IsString() JWT_PROGRESS_EXPIRES_IN?: string;

@IsString() AWS_ACCESS_KEY!: string;
@IsString() AWS_SECRET_KEY!: string;
@IsString() AWS_BUCKET_NAME!: string;
Expand Down
15 changes: 7 additions & 8 deletions src/common/utilities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as crypto from 'crypto';
import { Prisma } from '../../generated/prisma';
import {
BadRequestException,
Expand All @@ -16,13 +15,13 @@ import {
} from 'class-validator';
import { differenceInCalendarMonths, isAfter, isValid } from 'date-fns';

export const generateCode = () => {
return crypto.randomInt(11_111, 99_999);
};

export const generateUniqueCode = () => {
return Date.now().toString() + crypto.randomInt(1_111_111, 9_999_999);
};
export {
generateCode,
generateSecureOtp,
generateUniqueCode,
generateTripCode,
generateTrackingCode,
} from './utils/codes';

export const formatPrismaError = (error: Error): never => {
if (process.env.NODE_ENV === 'development') console.error(error);
Expand Down
86 changes: 86 additions & 0 deletions src/common/utils/codes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import * as crypto from 'crypto';
import {
generateCode,
generateSecureOtp,
generateUniqueCode,
generateTripCode,
generateTrackingCode,
} from './codes';

describe('codes', () => {
describe('generateSecureOtp', () => {
it('should return a zero-padded 6-digit string', () => {
const code = generateSecureOtp();
expect(code).toMatch(/^\d{6}$/);
expect(code).toHaveLength(6);
});

it('should zero-pad values below 100000', () => {
const spy = jest.spyOn(crypto, 'randomInt') as unknown as jest.Mock;
spy.mockReturnValue(5);
expect(generateSecureOtp()).toBe('000005');
spy.mockRestore();
});

it('should cover the full 0–999999 space', () => {
const spy = jest.spyOn(crypto, 'randomInt') as unknown as jest.Mock;
spy.mockReturnValueOnce(999_999).mockReturnValueOnce(0);
expect(generateSecureOtp()).toBe('999999');
expect(generateSecureOtp()).toBe('000000');
spy.mockRestore();
});

it('should generate unique codes across a large sample', () => {
let counter = 0;
const spy = jest.spyOn(crypto, 'randomInt') as unknown as jest.Mock;
spy.mockImplementation(() => counter++ % 1_000_000);
const sample = Array.from({ length: 10_000 }, () => generateSecureOtp());
expect(new Set(sample).size).toBe(10_000);
spy.mockRestore();
});
});

describe('generateUniqueCode', () => {
it('should keep the legacy timestamp+random format', () => {
const code = generateUniqueCode();
expect(typeof code).toBe('string');
expect(code).toMatch(/^\d+$/);
expect(code.length).toBeGreaterThan(13);
});
});

describe('generateTripCode / generateTrackingCode', () => {
it('should return 8-character base32 codes', () => {
const trip = generateTripCode();
const tracking = generateTrackingCode();
expect(trip).toMatch(/^[A-Z2-7]{8}$/);
expect(tracking).toMatch(/^[A-Z2-7]{8}$/);
});

it('should be unique across a large sample', () => {
const sample = Array.from({ length: 10_000 }, () => generateTripCode());
expect(new Set(sample).size).toBe(10_000);
});

it('should not include excluded base32 characters (0,1,8,9)', () => {
const spy = jest.spyOn(crypto, 'randomInt') as unknown as jest.Mock;
spy
.mockReturnValueOnce(0)
.mockReturnValueOnce(1)
.mockReturnValueOnce(8)
.mockReturnValueOnce(9);
const trip = generateTripCode();
expect(trip).toHaveLength(8);
expect(trip).toMatch(/^[A-Z2-7]{8}$/);
spy.mockRestore();
});
});

describe('generateCode (deprecated legacy)', () => {
it('should return a 5-digit number in the legacy range', () => {
const code = generateCode();
expect(code).toBeGreaterThanOrEqual(11_111);
expect(code).toBeLessThan(99_999);
});
});
});
63 changes: 63 additions & 0 deletions src/common/utils/codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as crypto from 'crypto';

const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

/**
* Legacy 5-digit OTP generator (biased range, not zero-padded).
*
* @deprecated Use generateSecureOtp() instead. Kept only because the trip
* delivery code (trip.service.ts) still relies on this range/format;
* migrated in Phase 4.
*/
export function generateCode(): number {
return crypto.randomInt(11_111, 99_999);
}

/**
* Generate a cryptographically secure 6-digit OTP code, zero-padded.
* Uniform distribution over the full 000000–999999 space.
*
* BREAKING: previously this was a biased 5-digit number (11111–99998).
* Now returns a zero-padded 6-digit string.
*/
export function generateSecureOtp(): string {
return crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
}

/**
* Legacy unique code generator. Format: timestamp + random int.
*
* NOTE: uniqueness is NOT guaranteed — the timestamp prefix makes this
* deterministic within the same millisecond. For new code, prefer
* generateTripCode() / generateTrackingCode() with a DB unique column
* constraint and P2002 retry (Phase 4 TransactionRunner).
*/
export function generateUniqueCode(): string {
return Date.now().toString() + crypto.randomInt(1_111_111, 9_999_999);
}

/**
* Generate a crypto-random 8-character base32 code.
* Uniqueness is enforced by the database unique column (P2002 retry via
* TransactionRunner in Phase 4) — the code itself carries no timestamp.
*/
export function generateTripCode(): string {
return generateBase32Code(8);
}

/**
* Generate a crypto-random 8-character base32 code.
* Uniqueness is enforced by the database unique column (P2002 retry via
* TransactionRunner in Phase 4) — the code itself carries no timestamp.
*/
export function generateTrackingCode(): string {
return generateBase32Code(8);
}

function generateBase32Code(length: number): string {
let result = '';
for (let i = 0; i < length; i++) {
result += BASE32_ALPHABET[crypto.randomInt(0, BASE32_ALPHABET.length)];
}
return result;
}
161 changes: 161 additions & 0 deletions src/modules/auth/application/transporter-signup.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DeepMockProxy, mockDeep } from 'jest-mock-extended';
import { TransporterSignupService } from './transporter-signup.service';
import { PrismaService } from '../../prisma/prisma.service';
import { TokenService } from '../../token/token.service';
import { NotificationService } from '../../notification/notification.service';
import { PrismaClient, RolesEnum, GendersEnum, LicenseTypeEnum } from '../../../../generated/prisma';
import { NotificationMessages } from '../../notification/notification-messages';

describe('TransporterSignupService', () => {
let service: TransporterSignupService;
let prismaService: DeepMockProxy<PrismaClient>;
let tokenService: DeepMockProxy<TokenService>;
let notificationService: DeepMockProxy<NotificationService>;

const senderDto = {
firstName: 'احمد',
lastName: 'محمدی',
phoneNumber: '+989123456789',
gender: GendersEnum.male,
};

const transporterDto = {
firstName: 'احمد',
lastName: 'محمدی',
phoneNumber: '+989123456789',
gender: GendersEnum.male,
birthDate: new Date('1990-01-01'),
nationalId: '1234567890',
licenseNumber: '0987654321',
licenseType: LicenseTypeEnum.grade_one,
licenseExpiryDate: new Date('2030-01-01'),
};

const mockSender = {
id: 'user-1',
phoneNumber: '+989123456789',
role: RolesEnum.sender,
};

const mockTransporterUser = {
id: 'user-2',
phoneNumber: '+989123456789',
role: RolesEnum.transporter,
transporter: {
id: 'trans-2',
nationalId: '1234567890',
},
};

beforeEach(async () => {
jest.resetAllMocks();

prismaService = mockDeep<PrismaClient>();
tokenService = mockDeep<TokenService>();
notificationService = mockDeep<NotificationService>();
notificationService.create.mockResolvedValue({} as any);

prismaService.$transaction.mockImplementation(
async (callback) => callback(prismaService),
);

const module: TestingModule = await Test.createTestingModule({
providers: [
TransporterSignupService,
{ provide: PrismaService, useValue: prismaService },
{ provide: TokenService, useValue: tokenService },
{ provide: NotificationService, useValue: notificationService },
],
}).compile();

service = module.get<TransporterSignupService>(TransporterSignupService);
});

describe('signupSender', () => {
it('should create the sender with a wallet, issue an access token and welcome notification', async () => {
prismaService.user.create.mockResolvedValue(mockSender as any);
tokenService.generateAccessToken.mockReturnValue('access-token');

const result = await service.signupSender(senderDto);

expect(prismaService.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
role: RolesEnum.sender,
wallet: { create: {} },
phoneVerifiedAt: expect.any(Date),
}),
}),
);
expect(tokenService.generateAccessToken).toHaveBeenCalledWith({
sub: 'user-1',
phoneNumber: '+989123456789',
});
expect(notificationService.create).toHaveBeenCalledWith(
'user-1',
{ content: NotificationMessages.Welcome },
prismaService,
);
expect(result).toEqual({
sender: mockSender,
accessToken: 'access-token',
});
});

it('should propagate constraint failures without wrapping', async () => {
const error = new Error('P2002 unique constraint');
prismaService.user.create.mockRejectedValue(error);

await expect(service.signupSender(senderDto)).rejects.toThrow(
'P2002 unique constraint',
);
});
});

describe('signupTransporter', () => {
it('should create the transporter with statuses, issue a progress token and welcome notification', async () => {
prismaService.user.create.mockResolvedValue(mockTransporterUser as any);
tokenService.generateProgressToken.mockReturnValue('progress-token');

const result = await service.signupTransporter(transporterDto);

expect(prismaService.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
role: RolesEnum.transporter,
transporter: {
create: expect.objectContaining({
nationalId: '1234567890',
nationalIdStatus: { create: {} },
licenseStatus: { create: {} },
verificationStatus: { create: {} },
}),
},
}),
}),
);
expect(tokenService.generateProgressToken).toHaveBeenCalledWith({
sub: 'user-2',
phoneNumber: '+989123456789',
});
expect(notificationService.create).toHaveBeenCalledWith(
'user-2',
{ content: NotificationMessages.Welcome },
prismaService,
);
expect(result.progressToken).toBe('progress-token');
expect(result.transporter.id).toBe('trans-2');
expect(result.transporter.nationalId).toBe('1234567890');
});

it('should propagate constraint failures without wrapping', async () => {
const error = new Error('P2002 unique constraint');
prismaService.user.create.mockRejectedValue(error);

await expect(
service.signupTransporter(transporterDto),
).rejects.toThrow('P2002 unique constraint');
});
});
});
Loading
Loading