mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat: add aggregate boundary validation (v0.7.0)
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
This commit is contained in:
538
packages/guardian/tests/AggregateBoundaryDetector.test.ts
Normal file
538
packages/guardian/tests/AggregateBoundaryDetector.test.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user