mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
Implement DDD aggregate boundary validation to detect and prevent direct entity references across aggregate boundaries. Features: - Detect direct entity imports between aggregates - Allow only ID or Value Object references - Support multiple folder structures (domain/aggregates/*, domain/*, domain/entities/*) - Filter allowed imports (value-objects, events, repositories, services) - Critical severity level for violations - 41 comprehensive tests with 92.55% coverage - CLI output with detailed suggestions - Examples of good and bad patterns Breaking changes: None Backwards compatible: Yes
539 lines
20 KiB
TypeScript
539 lines
20 KiB
TypeScript
import { describe, it, expect } from "vitest"
|
|
import { AggregateBoundaryDetector } from "../src/infrastructure/analyzers/AggregateBoundaryDetector"
|
|
import { LAYERS } from "../src/shared/constants/rules"
|
|
|
|
describe("AggregateBoundaryDetector", () => {
|
|
const detector = new AggregateBoundaryDetector()
|
|
|
|
describe("extractAggregateFromPath", () => {
|
|
it("should extract aggregate from domain/aggregates/name path", () => {
|
|
expect(detector.extractAggregateFromPath("src/domain/aggregates/order/Order.ts")).toBe(
|
|
"order",
|
|
)
|
|
expect(detector.extractAggregateFromPath("src/domain/aggregates/user/User.ts")).toBe(
|
|
"user",
|
|
)
|
|
expect(
|
|
detector.extractAggregateFromPath("src/domain/aggregates/product/Product.ts"),
|
|
).toBe("product")
|
|
})
|
|
|
|
it("should extract aggregate from domain/name path", () => {
|
|
expect(detector.extractAggregateFromPath("src/domain/order/Order.ts")).toBe("order")
|
|
expect(detector.extractAggregateFromPath("src/domain/user/User.ts")).toBe("user")
|
|
expect(detector.extractAggregateFromPath("src/domain/cart/ShoppingCart.ts")).toBe(
|
|
"cart",
|
|
)
|
|
})
|
|
|
|
it("should extract aggregate from domain/entities/name path", () => {
|
|
expect(detector.extractAggregateFromPath("src/domain/entities/order/Order.ts")).toBe(
|
|
"order",
|
|
)
|
|
expect(detector.extractAggregateFromPath("src/domain/entities/user/User.ts")).toBe(
|
|
"user",
|
|
)
|
|
})
|
|
|
|
it("should return undefined for non-domain paths", () => {
|
|
expect(
|
|
detector.extractAggregateFromPath("src/application/use-cases/CreateUser.ts"),
|
|
).toBeUndefined()
|
|
expect(
|
|
detector.extractAggregateFromPath(
|
|
"src/infrastructure/repositories/UserRepository.ts",
|
|
),
|
|
).toBeUndefined()
|
|
expect(detector.extractAggregateFromPath("src/shared/types/Result.ts")).toBeUndefined()
|
|
})
|
|
|
|
it("should return undefined for paths without aggregate structure", () => {
|
|
expect(detector.extractAggregateFromPath("src/domain/User.ts")).toBeUndefined()
|
|
expect(detector.extractAggregateFromPath("src/User.ts")).toBeUndefined()
|
|
})
|
|
|
|
it("should handle Windows-style paths", () => {
|
|
expect(
|
|
detector.extractAggregateFromPath("src\\domain\\aggregates\\order\\Order.ts"),
|
|
).toBe("order")
|
|
expect(detector.extractAggregateFromPath("src\\domain\\user\\User.ts")).toBe("user")
|
|
})
|
|
})
|
|
|
|
describe("isAggregateBoundaryViolation", () => {
|
|
it("should detect direct entity import from another aggregate", () => {
|
|
expect(detector.isAggregateBoundaryViolation("../user/User", "order")).toBe(true)
|
|
expect(detector.isAggregateBoundaryViolation("../../user/User", "order")).toBe(true)
|
|
expect(
|
|
detector.isAggregateBoundaryViolation("../../../domain/user/User", "order"),
|
|
).toBe(true)
|
|
})
|
|
|
|
it("should NOT detect import from same aggregate", () => {
|
|
expect(detector.isAggregateBoundaryViolation("../order/Order", "order")).toBe(false)
|
|
expect(detector.isAggregateBoundaryViolation("./OrderItem", "order")).toBe(false)
|
|
})
|
|
|
|
it("should NOT detect value object imports", () => {
|
|
expect(
|
|
detector.isAggregateBoundaryViolation("../user/value-objects/UserId", "order"),
|
|
).toBe(false)
|
|
expect(detector.isAggregateBoundaryViolation("../user/vo/Email", "order")).toBe(false)
|
|
})
|
|
|
|
it("should NOT detect event imports", () => {
|
|
expect(
|
|
detector.isAggregateBoundaryViolation("../user/events/UserCreatedEvent", "order"),
|
|
).toBe(false)
|
|
expect(
|
|
detector.isAggregateBoundaryViolation(
|
|
"../user/domain-events/UserRegisteredEvent",
|
|
"order",
|
|
),
|
|
).toBe(false)
|
|
})
|
|
|
|
it("should NOT detect repository interface imports", () => {
|
|
expect(
|
|
detector.isAggregateBoundaryViolation(
|
|
"../user/repositories/IUserRepository",
|
|
"order",
|
|
),
|
|
).toBe(false)
|
|
})
|
|
|
|
it("should NOT detect service imports", () => {
|
|
expect(
|
|
detector.isAggregateBoundaryViolation("../user/services/UserService", "order"),
|
|
).toBe(false)
|
|
})
|
|
|
|
it("should NOT detect external package imports", () => {
|
|
expect(detector.isAggregateBoundaryViolation("express", "order")).toBe(false)
|
|
expect(detector.isAggregateBoundaryViolation("@nestjs/common", "order")).toBe(false)
|
|
})
|
|
|
|
it("should NOT detect imports without path separator", () => {
|
|
expect(detector.isAggregateBoundaryViolation("User", "order")).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("detectViolations", () => {
|
|
describe("Domain layer aggregate boundary violations", () => {
|
|
it("should detect direct entity import from another aggregate", () => {
|
|
const code = `
|
|
import { User } from '../user/User'
|
|
|
|
export class Order {
|
|
constructor(private user: User) {}
|
|
}`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(1)
|
|
expect(violations[0].fromAggregate).toBe("order")
|
|
expect(violations[0].toAggregate).toBe("user")
|
|
expect(violations[0].entityName).toBe("User")
|
|
expect(violations[0].importPath).toBe("../user/User")
|
|
expect(violations[0].line).toBe(2)
|
|
})
|
|
|
|
it("should detect multiple entity imports from different aggregates", () => {
|
|
const code = `
|
|
import { User } from '../user/User'
|
|
import { Product } from '../product/Product'
|
|
import { Category } from '../catalog/Category'
|
|
|
|
export class Order {
|
|
constructor(
|
|
private user: User,
|
|
private product: Product,
|
|
private category: Category
|
|
) {}
|
|
}`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(3)
|
|
expect(violations[0].entityName).toBe("User")
|
|
expect(violations[1].entityName).toBe("Product")
|
|
expect(violations[2].entityName).toBe("Category")
|
|
})
|
|
|
|
it("should NOT detect value object imports", () => {
|
|
const code = `
|
|
import { UserId } from '../user/value-objects/UserId'
|
|
import { ProductId } from '../product/value-objects/ProductId'
|
|
|
|
export class Order {
|
|
constructor(
|
|
private userId: UserId,
|
|
private productId: ProductId
|
|
) {}
|
|
}`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(0)
|
|
})
|
|
|
|
it("should NOT detect event imports", () => {
|
|
const code = `
|
|
import { UserCreatedEvent } from '../user/events/UserCreatedEvent'
|
|
import { ProductAddedEvent } from '../product/domain-events/ProductAddedEvent'
|
|
|
|
export class Order {
|
|
handle(event: UserCreatedEvent): void {}
|
|
}`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(0)
|
|
})
|
|
|
|
it("should NOT detect repository interface imports", () => {
|
|
const code = `
|
|
import { IUserRepository } from '../user/repositories/IUserRepository'
|
|
|
|
export class OrderService {
|
|
constructor(private userRepo: IUserRepository) {}
|
|
}`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/OrderService.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(0)
|
|
})
|
|
|
|
it("should NOT detect imports from same aggregate", () => {
|
|
const code = `
|
|
import { OrderItem } from './OrderItem'
|
|
import { OrderStatus } from './value-objects/OrderStatus'
|
|
|
|
export class Order {
|
|
constructor(
|
|
private items: OrderItem[],
|
|
private status: OrderStatus
|
|
) {}
|
|
}`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe("Non-domain layers", () => {
|
|
it("should return empty array for application layer", () => {
|
|
const code = `
|
|
import { User } from '../../domain/aggregates/user/User'
|
|
import { Order } from '../../domain/aggregates/order/Order'
|
|
|
|
export class CreateOrder {
|
|
constructor() {}
|
|
}`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/application/use-cases/CreateOrder.ts",
|
|
LAYERS.APPLICATION,
|
|
)
|
|
|
|
expect(violations).toHaveLength(0)
|
|
})
|
|
|
|
it("should return empty array for infrastructure layer", () => {
|
|
const code = `
|
|
import { User } from '../../domain/aggregates/user/User'
|
|
|
|
export class UserController {
|
|
constructor() {}
|
|
}`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/infrastructure/controllers/UserController.ts",
|
|
LAYERS.INFRASTRUCTURE,
|
|
)
|
|
|
|
expect(violations).toHaveLength(0)
|
|
})
|
|
|
|
it("should return empty array for undefined layer", () => {
|
|
const code = `import { User } from '../user/User'`
|
|
const violations = detector.detectViolations(code, "src/utils/helper.ts", undefined)
|
|
|
|
expect(violations).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe("Import statement formats", () => {
|
|
it("should detect violations in named imports", () => {
|
|
const code = `import { User, UserProfile } from '../user/User'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(1)
|
|
})
|
|
|
|
it("should detect violations in default imports", () => {
|
|
const code = `import User from '../user/User'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(1)
|
|
})
|
|
|
|
it("should detect violations in namespace imports", () => {
|
|
const code = `import * as UserAggregate from '../user/User'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(1)
|
|
})
|
|
|
|
it("should detect violations in require statements", () => {
|
|
const code = `const User = require('../user/User')`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe("Different path structures", () => {
|
|
it("should detect violations in domain/aggregates/name structure", () => {
|
|
const code = `import { User } from '../user/User'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(1)
|
|
expect(violations[0].fromAggregate).toBe("order")
|
|
expect(violations[0].toAggregate).toBe("user")
|
|
})
|
|
|
|
it("should detect violations in domain/name structure", () => {
|
|
const code = `import { User } from '../user/User'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(1)
|
|
expect(violations[0].fromAggregate).toBe("order")
|
|
expect(violations[0].toAggregate).toBe("user")
|
|
})
|
|
|
|
it("should detect violations in domain/entities/name structure", () => {
|
|
const code = `import { User } from '../../user/User'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/entities/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(1)
|
|
expect(violations[0].fromAggregate).toBe("order")
|
|
expect(violations[0].toAggregate).toBe("user")
|
|
})
|
|
})
|
|
|
|
describe("Edge cases", () => {
|
|
it("should handle empty code", () => {
|
|
const violations = detector.detectViolations(
|
|
"",
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(0)
|
|
})
|
|
|
|
it("should handle code with no imports", () => {
|
|
const code = `
|
|
export class Order {
|
|
constructor(private id: string) {}
|
|
}`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(0)
|
|
})
|
|
|
|
it("should handle file without aggregate in path", () => {
|
|
const code = `import { User } from '../user/User'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(0)
|
|
})
|
|
|
|
it("should handle comments in imports", () => {
|
|
const code = `
|
|
// This is a comment
|
|
import { User } from '../user/User' // Bad import
|
|
`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe("getMessage", () => {
|
|
it("should return correct violation message", () => {
|
|
const code = `import { User } from '../user/User'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations[0].getMessage()).toBe(
|
|
"Order aggregate should not directly reference User entity from User aggregate",
|
|
)
|
|
})
|
|
|
|
it("should capitalize aggregate names in message", () => {
|
|
const code = `import { Product } from '../product/Product'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/cart/ShoppingCart.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations[0].getMessage()).toContain("Cart aggregate")
|
|
expect(violations[0].getMessage()).toContain("Product aggregate")
|
|
})
|
|
})
|
|
|
|
describe("getSuggestion", () => {
|
|
it("should return suggestions for fixing aggregate boundary violations", () => {
|
|
const code = `import { User } from '../user/User'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
const suggestion = violations[0].getSuggestion()
|
|
expect(suggestion).toContain("Reference other aggregates by ID")
|
|
expect(suggestion).toContain("Use Value Objects")
|
|
expect(suggestion).toContain("Avoid direct entity references")
|
|
expect(suggestion).toContain("independently modifiable")
|
|
})
|
|
})
|
|
|
|
describe("getExampleFix", () => {
|
|
it("should return example fix for aggregate boundary violation", () => {
|
|
const code = `import { User } from '../user/User'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
const example = violations[0].getExampleFix()
|
|
expect(example).toContain("// ❌ Bad")
|
|
expect(example).toContain("// ✅ Good")
|
|
expect(example).toContain("UserId")
|
|
expect(example).toContain("CustomerInfo")
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("Complex scenarios", () => {
|
|
it("should detect mixed valid and invalid imports", () => {
|
|
const code = `
|
|
import { User } from '../user/User' // VIOLATION
|
|
import { UserId } from '../user/value-objects/UserId' // OK
|
|
import { Product } from '../product/Product' // VIOLATION
|
|
import { ProductId } from '../product/value-objects/ProductId' // OK
|
|
import { OrderItem } from './OrderItem' // OK - same aggregate
|
|
|
|
export class Order {
|
|
constructor(
|
|
private user: User,
|
|
private userId: UserId,
|
|
private product: Product,
|
|
private productId: ProductId,
|
|
private items: OrderItem[]
|
|
) {}
|
|
}`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(2)
|
|
expect(violations[0].entityName).toBe("User")
|
|
expect(violations[1].entityName).toBe("Product")
|
|
})
|
|
|
|
it("should handle deeply nested import paths", () => {
|
|
const code = `import { User } from '../../../domain/aggregates/user/entities/User'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(1)
|
|
expect(violations[0].entityName).toBe("User")
|
|
})
|
|
|
|
it("should detect violations with .ts extension in import", () => {
|
|
const code = `import { User } from '../user/User.ts'`
|
|
const violations = detector.detectViolations(
|
|
code,
|
|
"src/domain/aggregates/order/Order.ts",
|
|
LAYERS.DOMAIN,
|
|
)
|
|
|
|
expect(violations).toHaveLength(1)
|
|
expect(violations[0].entityName).toBe("User")
|
|
})
|
|
})
|
|
})
|