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
8 changes: 8 additions & 0 deletions .changeset/email-lowercase-normalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rineex/ddd': patch
---

Normalize `Email` value object to lowercase on construction.

- Uppercase and mixed-case inputs are stored as lowercase
- Emails differing only by case compare equal via `equals()`
96 changes: 79 additions & 17 deletions packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { describe, expect, it } from 'vitest';

import { InvalidValueObjectError } from '@/domain/errors/invalid-vo.error';

import { Email } from '../email.vo';

describe('email', () => {
describe('email ValueObject', () => {
const validEmail = 'test@example.com';
const invalidEmail = 'not-an-email';

describe('constructor', () => {
it('should create a valid email', () => {
const email = new Email('test@example.com');
const email = new Email(validEmail);

expect(email.value).toBe('test@example.com');
expect(email.value).toBe(validEmail);
});

it('should accept valid email formats', () => {
Expand Down Expand Up @@ -41,49 +46,106 @@
expect(() => {
// eslint-disable-next-line no-new
new Email(emailStr);
// eslint-disable-next-line vitest/require-to-throw-message
}).toThrow();
// @ts-expect-error - Cannot assign a 'protected' constructor type to a 'public' constructor
}).toThrow(InvalidValueObjectError);
expect(() => {
// eslint-disable-next-line no-new
new Email(emailStr);
}).toThrow(`Invalid Email: ${emailStr}`);
});
});

it('should include the invalid value in error metadata', () => {
try {
// eslint-disable-next-line no-new
new Email(invalidEmail);
expect.fail('Expected InvalidValueObjectError to be thrown');

Check warning on line 62 in packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

expect blank line before this statement
} catch (error) {
expect(error).toBeInstanceOf(InvalidValueObjectError);

Check warning on line 64 in packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` inside conditional statements
expect((error as InvalidValueObjectError).metadata).toEqual({

Check warning on line 65 in packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Avoid calling `expect` inside conditional statements
value: invalidEmail,
});
}
});
});

describe('fromString', () => {
it('should create email from string', () => {
const email = Email.fromString('test@example.com');
const email = Email.fromString(validEmail);

expect(email).toBeInstanceOf(Email);
expect(email.value).toBe('test@example.com');
expect(email.value).toBe(validEmail);
});

it('should throw error for invalid email', () => {
expect(() => {
Email.fromString('invalid-email');
// eslint-disable-next-line vitest/require-to-throw-message
}).toThrow();
it('should throw InvalidValueObjectError for invalid email', () => {
expect(() => Email.fromString(invalidEmail)).toThrow(
// @ts-expect-error - Cannot assign a 'protected' constructor type to a 'public' constructor
InvalidValueObjectError,
);
expect(() => Email.fromString(invalidEmail)).toThrow(
`Invalid Email: ${invalidEmail}`,
);
});
});

describe('equals', () => {
it('should return true for equal emails', () => {
const email1 = new Email('test@example.com');
const email2 = new Email('test@example.com');
const email1 = new Email(validEmail);
const email2 = new Email(validEmail);

expect(email1.equals(email2)).toBe(true);
});

it('should return false for different emails', () => {
const email1 = new Email('test@example.com');
const email1 = new Email(validEmail);
const email2 = new Email('other@example.com');

expect(email1.equals(email2)).toBe(false);
});

it('should return false for null or undefined', () => {
const email = new Email(validEmail);

expect(email.equals(null)).toBe(false);
expect(email.equals(undefined)).toBe(false);
});

it('should return false for non-Email values', () => {
const email = new Email(validEmail);

expect(email.equals({ value: validEmail })).toBe(false);
expect(email.equals(validEmail)).toBe(false);
});
});

describe('getValue', () => {
it('should return the email string', () => {
const email = new Email(validEmail);

expect(email.value).toBe(validEmail);
});
});

describe('lowercase', () => {
it('should normalize the email string to lowercase', () => {
const email = new Email('TEST@EXAMPLE.COM');

expect(email.value).toBe('test@example.com');
});

it('should treat differently cased emails as equal', () => {
const email1 = new Email('TEST@EXAMPLE.COM');
const email2 = new Email('test@example.com');

expect(email1.equals(email2)).toBe(true);
});
});

describe('toString', () => {
it('should return email string', () => {
const email = new Email('test@example.com');
const email = new Email(validEmail);

expect(email.toString()).toBe('test@example.com');
expect(email.toString()).toBe(validEmail);
});
});
});
3 changes: 1 addition & 2 deletions packages/ddd/src/domain/value-objects/email.vo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ export class Email extends PrimitiveValueObject<string> {
private static readonly schema = z.email();

public constructor(value: string) {
super(value);
this.validate(value);
super(value.toLowerCase());
}

public static fromString(value: string): Email {
Expand Down
Loading