From eee7b259d8f4bd1e1acea021d7c1ac282a508f7e Mon Sep 17 00:00:00 2001 From: Hossein Pourdavar Date: Sun, 5 Jul 2026 08:59:20 +0330 Subject: [PATCH] fix(ddd): Normalize Email value object to lowercase Store email addresses in lowercase so differently cased inputs compare equal. Expand Email VO tests for validation errors and normalization. Co-authored-by: Cursor --- .changeset/email-lowercase-normalization.md | 8 ++ .../value-objects/__tests__/email.vo.spec.ts | 96 +++++++++++++++---- .../ddd/src/domain/value-objects/email.vo.ts | 3 +- 3 files changed, 88 insertions(+), 19 deletions(-) create mode 100644 .changeset/email-lowercase-normalization.md diff --git a/.changeset/email-lowercase-normalization.md b/.changeset/email-lowercase-normalization.md new file mode 100644 index 0000000..9d722c5 --- /dev/null +++ b/.changeset/email-lowercase-normalization.md @@ -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()` diff --git a/packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts b/packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts index aa45d2b..0b45798 100644 --- a/packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts +++ b/packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts @@ -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', () => { @@ -41,49 +46,106 @@ describe('email', () => { 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'); + } catch (error) { + expect(error).toBeInstanceOf(InvalidValueObjectError); + expect((error as InvalidValueObjectError).metadata).toEqual({ + 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); }); }); }); diff --git a/packages/ddd/src/domain/value-objects/email.vo.ts b/packages/ddd/src/domain/value-objects/email.vo.ts index 46ba678..d0222e5 100644 --- a/packages/ddd/src/domain/value-objects/email.vo.ts +++ b/packages/ddd/src/domain/value-objects/email.vo.ts @@ -7,8 +7,7 @@ export class Email extends PrimitiveValueObject { private static readonly schema = z.email(); public constructor(value: string) { - super(value); - this.validate(value); + super(value.toLowerCase()); } public static fromString(value: string): Email {