diff --git a/packages/guardian/CHANGELOG.md b/packages/guardian/CHANGELOG.md index a670d4c..f4d6429 100644 --- a/packages/guardian/CHANGELOG.md +++ b/packages/guardian/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to @samiyev/guardian will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.7] - 2025-11-25 + +### Added + +- ๐Ÿงช **Comprehensive test coverage for under-tested domain files**: + - Added 31 tests for `SourceFile.ts` - coverage improved from 46% to 100% + - Added 31 tests for `ProjectPath.ts` - coverage improved from 50% to 100% + - Added 18 tests for `ValueObject.ts` - coverage improved from 25% to 100% + - Added 32 tests for `RepositoryViolation.ts` - coverage improved from 58% to 92.68% + - Total test count increased from 345 to 457 tests + - Overall coverage improved to 95.4% statements, 86.25% branches, 96.68% functions + - All tests pass with no breaking changes + +### Changed + +- ๐Ÿ“Š **Improved code quality and maintainability**: + - Enhanced test suite for core domain entities and value objects + - Better coverage of edge cases and error handling + - Increased confidence in domain layer correctness + ## [0.7.6] - 2025-11-25 ### Changed diff --git a/packages/guardian/ROADMAP.md b/packages/guardian/ROADMAP.md index 718012f..4953716 100644 --- a/packages/guardian/ROADMAP.md +++ b/packages/guardian/ROADMAP.md @@ -365,26 +365,29 @@ cli/ --- -### Version 0.7.7 - Improve Test Coverage ๐Ÿงช +### Version 0.7.7 - Improve Test Coverage ๐Ÿงช โœ… RELEASED +**Released:** 2025-11-25 **Priority:** MEDIUM **Scope:** Single session (~128K tokens) Increase coverage for under-tested domain files. -**Current State:** -| File | Coverage | -|------|----------| -| SourceFile.ts | 46% | -| ProjectPath.ts | 50% | -| ValueObject.ts | 25% | -| RepositoryViolation.ts | 58% | +**Results:** +| File | Before | After | +|------|--------|-------| +| SourceFile.ts | 46% | 100% โœ… | +| ProjectPath.ts | 50% | 100% โœ… | +| ValueObject.ts | 25% | 100% โœ… | +| RepositoryViolation.ts | 58% | 92.68% โœ… | **Deliverables:** -- [ ] SourceFile.ts โ†’ 80%+ -- [ ] ProjectPath.ts โ†’ 80%+ -- [ ] ValueObject.ts โ†’ 80%+ -- [ ] RepositoryViolation.ts โ†’ 80%+ +- โœ… SourceFile.ts โ†’ 100% (31 tests) +- โœ… ProjectPath.ts โ†’ 100% (31 tests) +- โœ… ValueObject.ts โ†’ 100% (18 tests) +- โœ… RepositoryViolation.ts โ†’ 92.68% (32 tests) +- โœ… All 457 tests passing +- โœ… Overall coverage: 95.4% statements, 86.25% branches, 96.68% functions - [ ] Publish to npm --- @@ -2074,4 +2077,4 @@ Until we reach 1.0.0, minor version bumps (0.x.0) may include breaking changes a --- **Last Updated:** 2025-11-25 -**Current Version:** 0.7.4 +**Current Version:** 0.7.7 diff --git a/packages/guardian/package.json b/packages/guardian/package.json index 6f2b4f8..66c7ae3 100644 --- a/packages/guardian/package.json +++ b/packages/guardian/package.json @@ -1,6 +1,6 @@ { "name": "@samiyev/guardian", - "version": "0.7.6", + "version": "0.7.7", "description": "Research-backed code quality guardian for AI-assisted development. Detects hardcodes, circular deps, framework leaks, entity exposure, and 8 architecture violations. Enforces Clean Architecture/DDD principles. Works with GitHub Copilot, Cursor, Windsurf, Claude, ChatGPT, Cline, and any AI coding tool.", "keywords": [ "puaros", diff --git a/packages/guardian/tests/unit/domain/ProjectPath.test.ts b/packages/guardian/tests/unit/domain/ProjectPath.test.ts new file mode 100644 index 0000000..09f8ab1 --- /dev/null +++ b/packages/guardian/tests/unit/domain/ProjectPath.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect } from "vitest" +import { ProjectPath } from "../../../src/domain/value-objects/ProjectPath" + +describe("ProjectPath", () => { + describe("create", () => { + it("should create a ProjectPath with absolute and relative paths", () => { + const absolutePath = "/Users/dev/project/src/domain/User.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.absolute).toBe(absolutePath) + expect(projectPath.relative).toBe("src/domain/User.ts") + }) + + it("should handle paths with same directory", () => { + const absolutePath = "/Users/dev/project/User.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.absolute).toBe(absolutePath) + expect(projectPath.relative).toBe("User.ts") + }) + + it("should handle nested directory structures", () => { + const absolutePath = "/Users/dev/project/src/domain/entities/user/User.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.absolute).toBe(absolutePath) + expect(projectPath.relative).toBe("src/domain/entities/user/User.ts") + }) + + it("should handle Windows-style paths", () => { + const absolutePath = "C:\\Users\\dev\\project\\src\\domain\\User.ts" + const projectRoot = "C:\\Users\\dev\\project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.absolute).toBe(absolutePath) + }) + }) + + describe("absolute getter", () => { + it("should return the absolute path", () => { + const absolutePath = "/Users/dev/project/src/domain/User.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.absolute).toBe(absolutePath) + }) + }) + + describe("relative getter", () => { + it("should return the relative path", () => { + const absolutePath = "/Users/dev/project/src/domain/User.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.relative).toBe("src/domain/User.ts") + }) + }) + + describe("extension getter", () => { + it("should return .ts for TypeScript files", () => { + const absolutePath = "/Users/dev/project/src/domain/User.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.extension).toBe(".ts") + }) + + it("should return .tsx for TypeScript JSX files", () => { + const absolutePath = "/Users/dev/project/src/components/Button.tsx" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.extension).toBe(".tsx") + }) + + it("should return .js for JavaScript files", () => { + const absolutePath = "/Users/dev/project/src/utils/helper.js" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.extension).toBe(".js") + }) + + it("should return .jsx for JavaScript JSX files", () => { + const absolutePath = "/Users/dev/project/src/components/Button.jsx" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.extension).toBe(".jsx") + }) + + it("should return empty string for files without extension", () => { + const absolutePath = "/Users/dev/project/README" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.extension).toBe("") + }) + }) + + describe("filename getter", () => { + it("should return the filename with extension", () => { + const absolutePath = "/Users/dev/project/src/domain/User.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.filename).toBe("User.ts") + }) + + it("should handle filenames with multiple dots", () => { + const absolutePath = "/Users/dev/project/src/domain/User.test.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.filename).toBe("User.test.ts") + }) + + it("should handle filenames without extension", () => { + const absolutePath = "/Users/dev/project/README" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.filename).toBe("README") + }) + }) + + describe("directory getter", () => { + it("should return the directory path relative to project root", () => { + const absolutePath = "/Users/dev/project/src/domain/entities/User.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.directory).toBe("src/domain/entities") + }) + + it("should return dot for files in project root", () => { + const absolutePath = "/Users/dev/project/README.md" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.directory).toBe(".") + }) + + it("should handle single-level directories", () => { + const absolutePath = "/Users/dev/project/src/User.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.directory).toBe("src") + }) + }) + + describe("isTypeScript", () => { + it("should return true for .ts files", () => { + const absolutePath = "/Users/dev/project/src/domain/User.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.isTypeScript()).toBe(true) + }) + + it("should return true for .tsx files", () => { + const absolutePath = "/Users/dev/project/src/components/Button.tsx" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.isTypeScript()).toBe(true) + }) + + it("should return false for .js files", () => { + const absolutePath = "/Users/dev/project/src/utils/helper.js" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.isTypeScript()).toBe(false) + }) + + it("should return false for .jsx files", () => { + const absolutePath = "/Users/dev/project/src/components/Button.jsx" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.isTypeScript()).toBe(false) + }) + + it("should return false for other file types", () => { + const absolutePath = "/Users/dev/project/README.md" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.isTypeScript()).toBe(false) + }) + }) + + describe("isJavaScript", () => { + it("should return true for .js files", () => { + const absolutePath = "/Users/dev/project/src/utils/helper.js" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.isJavaScript()).toBe(true) + }) + + it("should return true for .jsx files", () => { + const absolutePath = "/Users/dev/project/src/components/Button.jsx" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.isJavaScript()).toBe(true) + }) + + it("should return false for .ts files", () => { + const absolutePath = "/Users/dev/project/src/domain/User.ts" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.isJavaScript()).toBe(false) + }) + + it("should return false for .tsx files", () => { + const absolutePath = "/Users/dev/project/src/components/Button.tsx" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.isJavaScript()).toBe(false) + }) + + it("should return false for other file types", () => { + const absolutePath = "/Users/dev/project/README.md" + const projectRoot = "/Users/dev/project" + + const projectPath = ProjectPath.create(absolutePath, projectRoot) + + expect(projectPath.isJavaScript()).toBe(false) + }) + }) + + describe("equals", () => { + it("should return true for identical paths", () => { + const absolutePath = "/Users/dev/project/src/domain/User.ts" + const projectRoot = "/Users/dev/project" + + const path1 = ProjectPath.create(absolutePath, projectRoot) + const path2 = ProjectPath.create(absolutePath, projectRoot) + + expect(path1.equals(path2)).toBe(true) + }) + + it("should return false for different absolute paths", () => { + const projectRoot = "/Users/dev/project" + const path1 = ProjectPath.create("/Users/dev/project/src/domain/User.ts", projectRoot) + const path2 = ProjectPath.create("/Users/dev/project/src/domain/Order.ts", projectRoot) + + expect(path1.equals(path2)).toBe(false) + }) + + it("should return false for different relative paths", () => { + const path1 = ProjectPath.create( + "/Users/dev/project1/src/User.ts", + "/Users/dev/project1", + ) + const path2 = ProjectPath.create( + "/Users/dev/project2/src/User.ts", + "/Users/dev/project2", + ) + + expect(path1.equals(path2)).toBe(false) + }) + + it("should return false when comparing with undefined", () => { + const absolutePath = "/Users/dev/project/src/domain/User.ts" + const projectRoot = "/Users/dev/project" + + const path1 = ProjectPath.create(absolutePath, projectRoot) + + expect(path1.equals(undefined)).toBe(false) + }) + }) +}) diff --git a/packages/guardian/tests/unit/domain/RepositoryViolation.test.ts b/packages/guardian/tests/unit/domain/RepositoryViolation.test.ts new file mode 100644 index 0000000..abcd491 --- /dev/null +++ b/packages/guardian/tests/unit/domain/RepositoryViolation.test.ts @@ -0,0 +1,521 @@ +import { describe, it, expect } from "vitest" +import { RepositoryViolation } from "../../../src/domain/value-objects/RepositoryViolation" +import { REPOSITORY_VIOLATION_TYPES } from "../../../src/shared/constants/rules" + +describe("RepositoryViolation", () => { + describe("create", () => { + it("should create a repository violation for ORM type in interface", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Repository uses Prisma type", + "Prisma.UserWhereInput", + ) + + expect(violation.violationType).toBe(REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE) + expect(violation.filePath).toBe("src/domain/repositories/IUserRepository.ts") + expect(violation.layer).toBe("domain") + expect(violation.line).toBe(15) + expect(violation.details).toBe("Repository uses Prisma type") + expect(violation.ormType).toBe("Prisma.UserWhereInput") + }) + + it("should create a repository violation for concrete repository in use case", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE, + "src/application/use-cases/CreateUser.ts", + "application", + 10, + "Use case depends on concrete repository", + undefined, + "UserRepository", + ) + + expect(violation.violationType).toBe( + REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE, + ) + expect(violation.repositoryName).toBe("UserRepository") + }) + + it("should create a repository violation for new repository in use case", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE, + "src/application/use-cases/CreateUser.ts", + "application", + 12, + "Use case creates repository with new", + undefined, + "UserRepository", + ) + + expect(violation.violationType).toBe( + REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE, + ) + expect(violation.repositoryName).toBe("UserRepository") + }) + + it("should create a repository violation for non-domain method name", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME, + "src/domain/repositories/IUserRepository.ts", + "domain", + 8, + "Method uses technical name. Consider: findById()", + undefined, + undefined, + "findOne", + ) + + expect(violation.violationType).toBe(REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME) + expect(violation.methodName).toBe("findOne") + }) + + it("should handle optional line parameter", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + undefined, + "Repository uses Prisma type", + ) + + expect(violation.line).toBeUndefined() + }) + }) + + describe("getters", () => { + it("should return violation type", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + ) + + expect(violation.violationType).toBe(REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE) + }) + + it("should return file path", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + ) + + expect(violation.filePath).toBe("src/domain/repositories/IUserRepository.ts") + }) + + it("should return layer", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + ) + + expect(violation.layer).toBe("domain") + }) + + it("should return line number", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + ) + + expect(violation.line).toBe(15) + }) + + it("should return details", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Repository uses Prisma type", + ) + + expect(violation.details).toBe("Repository uses Prisma type") + }) + + it("should return ORM type", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + "Prisma.UserWhereInput", + ) + + expect(violation.ormType).toBe("Prisma.UserWhereInput") + }) + + it("should return repository name", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE, + "src/application/use-cases/CreateUser.ts", + "application", + 10, + "Test", + undefined, + "UserRepository", + ) + + expect(violation.repositoryName).toBe("UserRepository") + }) + + it("should return method name", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME, + "src/domain/repositories/IUserRepository.ts", + "domain", + 8, + "Test", + undefined, + undefined, + "findOne", + ) + + expect(violation.methodName).toBe("findOne") + }) + }) + + describe("getMessage", () => { + it("should return message for ORM type in interface", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + "Prisma.UserWhereInput", + ) + + const message = violation.getMessage() + + expect(message).toContain("ORM-specific type") + expect(message).toContain("Prisma.UserWhereInput") + }) + + it("should return message for concrete repository in use case", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE, + "src/application/use-cases/CreateUser.ts", + "application", + 10, + "Test", + undefined, + "UserRepository", + ) + + const message = violation.getMessage() + + expect(message).toContain("depends on concrete repository") + expect(message).toContain("UserRepository") + }) + + it("should return message for new repository in use case", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE, + "src/application/use-cases/CreateUser.ts", + "application", + 12, + "Test", + undefined, + "UserRepository", + ) + + const message = violation.getMessage() + + expect(message).toContain("creates repository with 'new") + expect(message).toContain("UserRepository") + }) + + it("should return message for non-domain method name", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME, + "src/domain/repositories/IUserRepository.ts", + "domain", + 8, + "Test", + undefined, + undefined, + "findOne", + ) + + const message = violation.getMessage() + + expect(message).toContain("uses technical name") + expect(message).toContain("findOne") + }) + + it("should handle unknown ORM type gracefully", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + ) + + const message = violation.getMessage() + + expect(message).toContain("unknown") + }) + }) + + describe("getSuggestion", () => { + it("should return suggestion for ORM type in interface", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + "Prisma.UserWhereInput", + ) + + const suggestion = violation.getSuggestion() + + expect(suggestion).toContain("Remove ORM-specific types") + expect(suggestion).toContain("Use domain types") + }) + + it("should return suggestion for concrete repository in use case", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE, + "src/application/use-cases/CreateUser.ts", + "application", + 10, + "Test", + undefined, + "UserRepository", + ) + + const suggestion = violation.getSuggestion() + + expect(suggestion).toContain("Depend on repository interface") + expect(suggestion).toContain("IUserRepository") + }) + + it("should return suggestion for new repository in use case", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE, + "src/application/use-cases/CreateUser.ts", + "application", + 12, + "Test", + undefined, + "UserRepository", + ) + + const suggestion = violation.getSuggestion() + + expect(suggestion).toContain("Remove 'new Repository()'") + expect(suggestion).toContain("dependency injection") + }) + + it("should return suggestion for non-domain method name with smart suggestion", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME, + "src/domain/repositories/IUserRepository.ts", + "domain", + 8, + "Method uses technical name. Consider: findById()", + undefined, + undefined, + "findOne", + ) + + const suggestion = violation.getSuggestion() + + expect(suggestion).toContain("findById()") + }) + + it("should return fallback suggestion for known technical method", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME, + "src/domain/repositories/IUserRepository.ts", + "domain", + 8, + "Method uses technical name", + undefined, + undefined, + "insert", + ) + + const suggestion = violation.getSuggestion() + + expect(suggestion).toContain("save or create") + }) + + it("should return default suggestion for unknown method", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME, + "src/domain/repositories/IUserRepository.ts", + "domain", + 8, + "Method uses technical name", + undefined, + undefined, + "unknownMethod", + ) + + const suggestion = violation.getSuggestion() + + expect(suggestion).toBeDefined() + expect(suggestion.length).toBeGreaterThan(0) + }) + }) + + describe("getExampleFix", () => { + it("should return example fix for ORM type in interface", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + ) + + const example = violation.getExampleFix() + + expect(example).toContain("BAD") + expect(example).toContain("GOOD") + expect(example).toContain("IUserRepository") + }) + + it("should return example fix for concrete repository in use case", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE, + "src/application/use-cases/CreateUser.ts", + "application", + 10, + "Test", + ) + + const example = violation.getExampleFix() + + expect(example).toContain("BAD") + expect(example).toContain("GOOD") + expect(example).toContain("CreateUser") + }) + + it("should return example fix for new repository in use case", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE, + "src/application/use-cases/CreateUser.ts", + "application", + 12, + "Test", + ) + + const example = violation.getExampleFix() + + expect(example).toContain("BAD") + expect(example).toContain("GOOD") + expect(example).toContain("new UserRepository") + }) + + it("should return example fix for non-domain method name", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME, + "src/domain/repositories/IUserRepository.ts", + "domain", + 8, + "Test", + ) + + const example = violation.getExampleFix() + + expect(example).toContain("BAD") + expect(example).toContain("GOOD") + expect(example).toContain("findOne") + }) + }) + + describe("equals", () => { + it("should return true for violations with identical properties", () => { + const violation1 = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + "Prisma.UserWhereInput", + ) + + const violation2 = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + "Prisma.UserWhereInput", + ) + + expect(violation1.equals(violation2)).toBe(true) + }) + + it("should return false for violations with different types", () => { + const violation1 = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + ) + + const violation2 = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + ) + + expect(violation1.equals(violation2)).toBe(false) + }) + + it("should return false for violations with different file paths", () => { + const violation1 = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + ) + + const violation2 = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IOrderRepository.ts", + "domain", + 15, + "Test", + ) + + expect(violation1.equals(violation2)).toBe(false) + }) + + it("should return false when comparing with undefined", () => { + const violation = RepositoryViolation.create( + REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE, + "src/domain/repositories/IUserRepository.ts", + "domain", + 15, + "Test", + ) + + expect(violation.equals(undefined)).toBe(false) + }) + }) +}) diff --git a/packages/guardian/tests/unit/domain/SourceFile.test.ts b/packages/guardian/tests/unit/domain/SourceFile.test.ts new file mode 100644 index 0000000..665e211 --- /dev/null +++ b/packages/guardian/tests/unit/domain/SourceFile.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect } from "vitest" +import { SourceFile } from "../../../src/domain/entities/SourceFile" +import { ProjectPath } from "../../../src/domain/value-objects/ProjectPath" +import { LAYERS } from "../../../src/shared/constants/rules" + +describe("SourceFile", () => { + describe("constructor", () => { + it("should create a SourceFile instance with all properties", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const content = "class User {}" + const imports = ["./BaseEntity"] + const exports = ["User"] + const id = "test-id" + + const sourceFile = new SourceFile(path, content, imports, exports, id) + + expect(sourceFile.path).toBe(path) + expect(sourceFile.content).toBe(content) + expect(sourceFile.imports).toEqual(imports) + expect(sourceFile.exports).toEqual(exports) + expect(sourceFile.id).toBe(id) + }) + + it("should create a SourceFile with empty imports and exports by default", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const content = "class User {}" + + const sourceFile = new SourceFile(path, content) + + expect(sourceFile.imports).toEqual([]) + expect(sourceFile.exports).toEqual([]) + }) + + it("should generate an id if not provided", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const content = "class User {}" + + const sourceFile = new SourceFile(path, content) + + expect(sourceFile.id).toBeDefined() + expect(typeof sourceFile.id).toBe("string") + expect(sourceFile.id.length).toBeGreaterThan(0) + }) + }) + + describe("layer detection", () => { + it("should detect domain layer from path", () => { + const path = ProjectPath.create("/project/src/domain/entities/User.ts", "/project") + const sourceFile = new SourceFile(path, "") + + expect(sourceFile.layer).toBe(LAYERS.DOMAIN) + }) + + it("should detect application layer from path", () => { + const path = ProjectPath.create( + "/project/src/application/use-cases/CreateUser.ts", + "/project", + ) + const sourceFile = new SourceFile(path, "") + + expect(sourceFile.layer).toBe(LAYERS.APPLICATION) + }) + + it("should detect infrastructure layer from path", () => { + const path = ProjectPath.create( + "/project/src/infrastructure/database/UserRepository.ts", + "/project", + ) + const sourceFile = new SourceFile(path, "") + + expect(sourceFile.layer).toBe(LAYERS.INFRASTRUCTURE) + }) + + it("should detect shared layer from path", () => { + const path = ProjectPath.create("/project/src/shared/utils/helpers.ts", "/project") + const sourceFile = new SourceFile(path, "") + + expect(sourceFile.layer).toBe(LAYERS.SHARED) + }) + + it("should return undefined for unknown layer", () => { + const path = ProjectPath.create("/project/src/unknown/Test.ts", "/project") + const sourceFile = new SourceFile(path, "") + + expect(sourceFile.layer).toBeUndefined() + }) + + it("should handle uppercase layer names in path", () => { + const path = ProjectPath.create("/project/src/DOMAIN/User.ts", "/project") + const sourceFile = new SourceFile(path, "") + + expect(sourceFile.layer).toBe(LAYERS.DOMAIN) + }) + + it("should handle mixed case layer names in path", () => { + const path = ProjectPath.create("/project/src/Application/UseCase.ts", "/project") + const sourceFile = new SourceFile(path, "") + + expect(sourceFile.layer).toBe(LAYERS.APPLICATION) + }) + }) + + describe("path getter", () => { + it("should return the project path", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const sourceFile = new SourceFile(path, "") + + expect(sourceFile.path).toBe(path) + }) + }) + + describe("content getter", () => { + it("should return the file content", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const content = "class User { constructor(public name: string) {} }" + const sourceFile = new SourceFile(path, content) + + expect(sourceFile.content).toBe(content) + }) + }) + + describe("imports getter", () => { + it("should return a copy of imports array", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const imports = ["./BaseEntity", "./ValueObject"] + const sourceFile = new SourceFile(path, "", imports) + + const returnedImports = sourceFile.imports + + expect(returnedImports).toEqual(imports) + expect(returnedImports).not.toBe(imports) + }) + + it("should not allow mutations of internal imports array", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const imports = ["./BaseEntity"] + const sourceFile = new SourceFile(path, "", imports) + + const returnedImports = sourceFile.imports + returnedImports.push("./NewImport") + + expect(sourceFile.imports).toEqual(["./BaseEntity"]) + }) + }) + + describe("exports getter", () => { + it("should return a copy of exports array", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const exports = ["User", "UserProps"] + const sourceFile = new SourceFile(path, "", [], exports) + + const returnedExports = sourceFile.exports + + expect(returnedExports).toEqual(exports) + expect(returnedExports).not.toBe(exports) + }) + + it("should not allow mutations of internal exports array", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const exports = ["User"] + const sourceFile = new SourceFile(path, "", [], exports) + + const returnedExports = sourceFile.exports + returnedExports.push("NewExport") + + expect(sourceFile.exports).toEqual(["User"]) + }) + }) + + describe("addImport", () => { + it("should add a new import to the list", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const sourceFile = new SourceFile(path, "") + + sourceFile.addImport("./BaseEntity") + + expect(sourceFile.imports).toEqual(["./BaseEntity"]) + }) + + it("should not add duplicate imports", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const sourceFile = new SourceFile(path, "", ["./BaseEntity"]) + + sourceFile.addImport("./BaseEntity") + + expect(sourceFile.imports).toEqual(["./BaseEntity"]) + }) + + it("should update updatedAt timestamp when adding new import", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const sourceFile = new SourceFile(path, "") + + const originalUpdatedAt = sourceFile.updatedAt + + setTimeout(() => { + sourceFile.addImport("./BaseEntity") + + expect(sourceFile.updatedAt.getTime()).toBeGreaterThanOrEqual( + originalUpdatedAt.getTime(), + ) + }, 10) + }) + + it("should not update timestamp when adding duplicate import", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const sourceFile = new SourceFile(path, "", ["./BaseEntity"]) + + const originalUpdatedAt = sourceFile.updatedAt + + setTimeout(() => { + sourceFile.addImport("./BaseEntity") + + expect(sourceFile.updatedAt).toBe(originalUpdatedAt) + }, 10) + }) + + it("should add multiple different imports", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const sourceFile = new SourceFile(path, "") + + sourceFile.addImport("./BaseEntity") + sourceFile.addImport("./ValueObject") + sourceFile.addImport("./DomainEvent") + + expect(sourceFile.imports).toEqual(["./BaseEntity", "./ValueObject", "./DomainEvent"]) + }) + }) + + describe("addExport", () => { + it("should add a new export to the list", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const sourceFile = new SourceFile(path, "") + + sourceFile.addExport("User") + + expect(sourceFile.exports).toEqual(["User"]) + }) + + it("should not add duplicate exports", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const sourceFile = new SourceFile(path, "", [], ["User"]) + + sourceFile.addExport("User") + + expect(sourceFile.exports).toEqual(["User"]) + }) + + it("should update updatedAt timestamp when adding new export", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const sourceFile = new SourceFile(path, "") + + const originalUpdatedAt = sourceFile.updatedAt + + setTimeout(() => { + sourceFile.addExport("User") + + expect(sourceFile.updatedAt.getTime()).toBeGreaterThanOrEqual( + originalUpdatedAt.getTime(), + ) + }, 10) + }) + + it("should not update timestamp when adding duplicate export", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const sourceFile = new SourceFile(path, "", [], ["User"]) + + const originalUpdatedAt = sourceFile.updatedAt + + setTimeout(() => { + sourceFile.addExport("User") + + expect(sourceFile.updatedAt).toBe(originalUpdatedAt) + }, 10) + }) + + it("should add multiple different exports", () => { + const path = ProjectPath.create("/project/src/domain/User.ts", "/project") + const sourceFile = new SourceFile(path, "") + + sourceFile.addExport("User") + sourceFile.addExport("UserProps") + sourceFile.addExport("UserFactory") + + expect(sourceFile.exports).toEqual(["User", "UserProps", "UserFactory"]) + }) + }) + + describe("importsFrom", () => { + it("should return true if imports contain the specified layer", () => { + const path = ProjectPath.create("/project/src/application/User.ts", "/project") + const imports = ["../../domain/entities/User", "../use-cases/CreateUser"] + const sourceFile = new SourceFile(path, "", imports) + + expect(sourceFile.importsFrom("domain")).toBe(true) + }) + + it("should return false if imports do not contain the specified layer", () => { + const path = ProjectPath.create("/project/src/application/User.ts", "/project") + const imports = ["../use-cases/CreateUser", "../dtos/UserDto"] + const sourceFile = new SourceFile(path, "", imports) + + expect(sourceFile.importsFrom("domain")).toBe(false) + }) + + it("should be case-insensitive", () => { + const path = ProjectPath.create("/project/src/application/User.ts", "/project") + const imports = ["../../DOMAIN/entities/User"] + const sourceFile = new SourceFile(path, "", imports) + + expect(sourceFile.importsFrom("domain")).toBe(true) + }) + + it("should return false for empty imports", () => { + const path = ProjectPath.create("/project/src/application/User.ts", "/project") + const sourceFile = new SourceFile(path, "") + + expect(sourceFile.importsFrom("domain")).toBe(false) + }) + + it("should handle partial matches in import paths", () => { + const path = ProjectPath.create("/project/src/application/User.ts", "/project") + const imports = ["../../infrastructure/database/UserRepository"] + const sourceFile = new SourceFile(path, "", imports) + + expect(sourceFile.importsFrom("infrastructure")).toBe(true) + expect(sourceFile.importsFrom("domain")).toBe(false) + }) + }) +}) diff --git a/packages/guardian/tests/unit/domain/ValueObject.test.ts b/packages/guardian/tests/unit/domain/ValueObject.test.ts new file mode 100644 index 0000000..a7d3dc4 --- /dev/null +++ b/packages/guardian/tests/unit/domain/ValueObject.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from "vitest" +import { ValueObject } from "../../../src/domain/value-objects/ValueObject" + +interface TestProps { + readonly value: string + readonly count: number +} + +class TestValueObject extends ValueObject { + constructor(value: string, count: number) { + super({ value, count }) + } + + public get value(): string { + return this.props.value + } + + public get count(): number { + return this.props.count + } +} + +interface ComplexProps { + readonly name: string + readonly items: string[] + readonly metadata: { key: string; value: number } +} + +class ComplexValueObject extends ValueObject { + constructor(name: string, items: string[], metadata: { key: string; value: number }) { + super({ name, items, metadata }) + } + + public get name(): string { + return this.props.name + } + + public get items(): string[] { + return this.props.items + } + + public get metadata(): { key: string; value: number } { + return this.props.metadata + } +} + +describe("ValueObject", () => { + describe("constructor", () => { + it("should create a value object with provided properties", () => { + const vo = new TestValueObject("test", 42) + + expect(vo.value).toBe("test") + expect(vo.count).toBe(42) + }) + + it("should freeze the properties object", () => { + const vo = new TestValueObject("test", 42) + + expect(Object.isFrozen(vo["props"])).toBe(true) + }) + + it("should prevent modification of properties", () => { + const vo = new TestValueObject("test", 42) + + expect(() => { + ;(vo["props"] as any).value = "modified" + }).toThrow() + }) + + it("should handle complex nested properties", () => { + const vo = new ComplexValueObject("test", ["item1", "item2"], { + key: "key1", + value: 100, + }) + + expect(vo.name).toBe("test") + expect(vo.items).toEqual(["item1", "item2"]) + expect(vo.metadata).toEqual({ key: "key1", value: 100 }) + }) + }) + + describe("equals", () => { + it("should return true for value objects with identical properties", () => { + const vo1 = new TestValueObject("test", 42) + const vo2 = new TestValueObject("test", 42) + + expect(vo1.equals(vo2)).toBe(true) + }) + + it("should return false for value objects with different values", () => { + const vo1 = new TestValueObject("test1", 42) + const vo2 = new TestValueObject("test2", 42) + + expect(vo1.equals(vo2)).toBe(false) + }) + + it("should return false for value objects with different counts", () => { + const vo1 = new TestValueObject("test", 42) + const vo2 = new TestValueObject("test", 43) + + expect(vo1.equals(vo2)).toBe(false) + }) + + it("should return false when comparing with undefined", () => { + const vo1 = new TestValueObject("test", 42) + + expect(vo1.equals(undefined)).toBe(false) + }) + + it("should return false when comparing with null", () => { + const vo1 = new TestValueObject("test", 42) + + expect(vo1.equals(null as any)).toBe(false) + }) + + it("should handle complex nested property comparisons", () => { + const vo1 = new ComplexValueObject("test", ["item1", "item2"], { + key: "key1", + value: 100, + }) + const vo2 = new ComplexValueObject("test", ["item1", "item2"], { + key: "key1", + value: 100, + }) + + expect(vo1.equals(vo2)).toBe(true) + }) + + it("should detect differences in nested arrays", () => { + const vo1 = new ComplexValueObject("test", ["item1", "item2"], { + key: "key1", + value: 100, + }) + const vo2 = new ComplexValueObject("test", ["item1", "item3"], { + key: "key1", + value: 100, + }) + + expect(vo1.equals(vo2)).toBe(false) + }) + + it("should detect differences in nested objects", () => { + const vo1 = new ComplexValueObject("test", ["item1", "item2"], { + key: "key1", + value: 100, + }) + const vo2 = new ComplexValueObject("test", ["item1", "item2"], { + key: "key2", + value: 100, + }) + + expect(vo1.equals(vo2)).toBe(false) + }) + + it("should return true for same instance", () => { + const vo1 = new TestValueObject("test", 42) + + expect(vo1.equals(vo1)).toBe(true) + }) + + it("should handle empty string values", () => { + const vo1 = new TestValueObject("", 0) + const vo2 = new TestValueObject("", 0) + + expect(vo1.equals(vo2)).toBe(true) + }) + + it("should distinguish between zero and undefined in comparisons", () => { + const vo1 = new TestValueObject("test", 0) + const vo2 = new TestValueObject("test", 0) + + expect(vo1.equals(vo2)).toBe(true) + }) + }) + + describe("immutability", () => { + it("should freeze props object after creation", () => { + const vo = new TestValueObject("original", 42) + + expect(Object.isFrozen(vo["props"])).toBe(true) + }) + + it("should not allow adding new properties", () => { + const vo = new TestValueObject("test", 42) + + expect(() => { + ;(vo["props"] as any).newProp = "new" + }).toThrow() + }) + + it("should not allow deleting properties", () => { + const vo = new TestValueObject("test", 42) + + expect(() => { + delete (vo["props"] as any).value + }).toThrow() + }) + }) +})