javi-forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitignore.template +105 -0
- package/.releaserc +44 -0
- package/README.md +45 -0
- package/ai-config/.skillignore +15 -0
- package/ai-config/AUTO_INVOKE.md +300 -0
- package/ai-config/agents/_TEMPLATE.md +93 -0
- package/ai-config/agents/business/api-designer.md +1657 -0
- package/ai-config/agents/business/business-analyst.md +1331 -0
- package/ai-config/agents/business/product-strategist.md +206 -0
- package/ai-config/agents/business/project-manager.md +178 -0
- package/ai-config/agents/business/requirements-analyst.md +1277 -0
- package/ai-config/agents/business/technical-writer.md +1679 -0
- package/ai-config/agents/creative/ux-designer.md +205 -0
- package/ai-config/agents/data-ai/ai-engineer.md +487 -0
- package/ai-config/agents/data-ai/analytics-engineer.md +953 -0
- package/ai-config/agents/data-ai/data-engineer.md +173 -0
- package/ai-config/agents/data-ai/data-scientist.md +672 -0
- package/ai-config/agents/data-ai/mlops-engineer.md +814 -0
- package/ai-config/agents/data-ai/prompt-engineer.md +772 -0
- package/ai-config/agents/development/angular-expert.md +620 -0
- package/ai-config/agents/development/backend-architect.md +795 -0
- package/ai-config/agents/development/database-specialist.md +212 -0
- package/ai-config/agents/development/frontend-specialist.md +686 -0
- package/ai-config/agents/development/fullstack-engineer.md +668 -0
- package/ai-config/agents/development/golang-pro.md +338 -0
- package/ai-config/agents/development/java-enterprise.md +400 -0
- package/ai-config/agents/development/javascript-pro.md +422 -0
- package/ai-config/agents/development/nextjs-pro.md +474 -0
- package/ai-config/agents/development/python-pro.md +570 -0
- package/ai-config/agents/development/react-pro.md +487 -0
- package/ai-config/agents/development/rust-pro.md +246 -0
- package/ai-config/agents/development/spring-boot-4-expert.md +326 -0
- package/ai-config/agents/development/typescript-pro.md +336 -0
- package/ai-config/agents/development/vue-specialist.md +605 -0
- package/ai-config/agents/infrastructure/cloud-architect.md +472 -0
- package/ai-config/agents/infrastructure/deployment-manager.md +358 -0
- package/ai-config/agents/infrastructure/devops-engineer.md +455 -0
- package/ai-config/agents/infrastructure/incident-responder.md +519 -0
- package/ai-config/agents/infrastructure/kubernetes-expert.md +705 -0
- package/ai-config/agents/infrastructure/monitoring-specialist.md +674 -0
- package/ai-config/agents/infrastructure/performance-engineer.md +658 -0
- package/ai-config/agents/orchestrator.md +241 -0
- package/ai-config/agents/quality/accessibility-auditor.md +1204 -0
- package/ai-config/agents/quality/code-reviewer-compact.md +123 -0
- package/ai-config/agents/quality/code-reviewer.md +363 -0
- package/ai-config/agents/quality/dependency-manager.md +743 -0
- package/ai-config/agents/quality/e2e-test-specialist.md +1005 -0
- package/ai-config/agents/quality/performance-tester.md +1086 -0
- package/ai-config/agents/quality/security-auditor.md +133 -0
- package/ai-config/agents/quality/test-engineer.md +453 -0
- package/ai-config/agents/specialists/api-designer.md +87 -0
- package/ai-config/agents/specialists/backend-architect.md +73 -0
- package/ai-config/agents/specialists/code-reviewer.md +77 -0
- package/ai-config/agents/specialists/db-optimizer.md +75 -0
- package/ai-config/agents/specialists/devops-engineer.md +83 -0
- package/ai-config/agents/specialists/documentation-writer.md +78 -0
- package/ai-config/agents/specialists/frontend-developer.md +75 -0
- package/ai-config/agents/specialists/performance-analyst.md +82 -0
- package/ai-config/agents/specialists/refactor-specialist.md +74 -0
- package/ai-config/agents/specialists/security-auditor.md +74 -0
- package/ai-config/agents/specialists/test-engineer.md +81 -0
- package/ai-config/agents/specialists/ux-consultant.md +76 -0
- package/ai-config/agents/specialized/agent-generator.md +1190 -0
- package/ai-config/agents/specialized/blockchain-developer.md +149 -0
- package/ai-config/agents/specialized/code-migrator.md +892 -0
- package/ai-config/agents/specialized/context-manager.md +978 -0
- package/ai-config/agents/specialized/documentation-writer.md +1078 -0
- package/ai-config/agents/specialized/ecommerce-expert.md +1756 -0
- package/ai-config/agents/specialized/embedded-engineer.md +1714 -0
- package/ai-config/agents/specialized/error-detective.md +1034 -0
- package/ai-config/agents/specialized/fintech-specialist.md +1659 -0
- package/ai-config/agents/specialized/freelance-project-planner-v2.md +1988 -0
- package/ai-config/agents/specialized/freelance-project-planner-v3.md +2136 -0
- package/ai-config/agents/specialized/freelance-project-planner-v4.md +4503 -0
- package/ai-config/agents/specialized/freelance-project-planner.md +722 -0
- package/ai-config/agents/specialized/game-developer.md +1963 -0
- package/ai-config/agents/specialized/healthcare-dev.md +1620 -0
- package/ai-config/agents/specialized/mobile-developer.md +188 -0
- package/ai-config/agents/specialized/parallel-plan-executor.md +506 -0
- package/ai-config/agents/specialized/plan-executor.md +485 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/00-INDEX.md +485 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/01-CORE.md +3493 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/02-SELF-CORRECTION.md +778 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/03-PROGRESSIVE-SETUP.md +918 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/04-DEPLOYMENT.md +1537 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/05-TESTING.md +2633 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/06-OPERATIONS.md +5610 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/INSTALL.md +335 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/QUICK-REFERENCE.txt +215 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/README.md +260 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/START-HERE.md +379 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/WORKFLOW-DIAGRAM.md +355 -0
- package/ai-config/agents/specialized/solo-dev-planner-modular/solo-dev-planner.md +279 -0
- package/ai-config/agents/specialized/template-writer.md +347 -0
- package/ai-config/agents/specialized/test-runner.md +99 -0
- package/ai-config/agents/specialized/vibekanban-smart-worker.md +244 -0
- package/ai-config/agents/specialized/wave-executor.md +138 -0
- package/ai-config/agents/specialized/workflow-optimizer.md +1114 -0
- package/ai-config/commands/git/changelog.md +32 -0
- package/ai-config/commands/git/ci-local.md +70 -0
- package/ai-config/commands/git/commit.md +35 -0
- package/ai-config/commands/git/fix-issue.md +23 -0
- package/ai-config/commands/git/pr-create.md +42 -0
- package/ai-config/commands/git/pr-review.md +50 -0
- package/ai-config/commands/git/worktree.md +39 -0
- package/ai-config/commands/refactoring/cleanup.md +24 -0
- package/ai-config/commands/refactoring/dead-code.md +40 -0
- package/ai-config/commands/refactoring/extract.md +31 -0
- package/ai-config/commands/testing/e2e.md +30 -0
- package/ai-config/commands/testing/tdd.md +36 -0
- package/ai-config/commands/testing/test-coverage.md +30 -0
- package/ai-config/commands/testing/test-fix.md +24 -0
- package/ai-config/commands/workflow/generate-agents-md.md +85 -0
- package/ai-config/commands/workflow/planning.md +47 -0
- package/ai-config/commands/workflows/compound.md +89 -0
- package/ai-config/commands/workflows/plan.md +77 -0
- package/ai-config/commands/workflows/review.md +78 -0
- package/ai-config/commands/workflows/work.md +75 -0
- package/ai-config/config.yaml +18 -0
- package/ai-config/hooks/_TEMPLATE.md +96 -0
- package/ai-config/hooks/block-dangerous-commands.md +75 -0
- package/ai-config/hooks/commit-guard.md +90 -0
- package/ai-config/hooks/context-loader.md +73 -0
- package/ai-config/hooks/improve-prompt.md +91 -0
- package/ai-config/hooks/learning-log.md +72 -0
- package/ai-config/hooks/model-router.md +86 -0
- package/ai-config/hooks/secret-scanner.md +64 -0
- package/ai-config/hooks/skill-validator.md +102 -0
- package/ai-config/hooks/task-artifact.md +114 -0
- package/ai-config/hooks/validate-workflow.md +100 -0
- package/ai-config/prompts/base.md +71 -0
- package/ai-config/prompts/modes/debug.md +34 -0
- package/ai-config/prompts/modes/deploy.md +40 -0
- package/ai-config/prompts/modes/research.md +32 -0
- package/ai-config/prompts/modes/review.md +33 -0
- package/ai-config/prompts/review-policy.md +79 -0
- package/ai-config/skills/_TEMPLATE.md +157 -0
- package/ai-config/skills/backend/api-gateway/SKILL.md +254 -0
- package/ai-config/skills/backend/bff-concepts/SKILL.md +239 -0
- package/ai-config/skills/backend/bff-spring/SKILL.md +364 -0
- package/ai-config/skills/backend/chi-router/SKILL.md +396 -0
- package/ai-config/skills/backend/error-handling/SKILL.md +255 -0
- package/ai-config/skills/backend/exceptions-spring/SKILL.md +323 -0
- package/ai-config/skills/backend/fastapi/SKILL.md +302 -0
- package/ai-config/skills/backend/gateway-spring/SKILL.md +390 -0
- package/ai-config/skills/backend/go-backend/SKILL.md +457 -0
- package/ai-config/skills/backend/gradle-multimodule/SKILL.md +274 -0
- package/ai-config/skills/backend/graphql-concepts/SKILL.md +352 -0
- package/ai-config/skills/backend/graphql-spring/SKILL.md +398 -0
- package/ai-config/skills/backend/grpc-concepts/SKILL.md +283 -0
- package/ai-config/skills/backend/grpc-spring/SKILL.md +445 -0
- package/ai-config/skills/backend/jwt-auth/SKILL.md +412 -0
- package/ai-config/skills/backend/notifications-concepts/SKILL.md +259 -0
- package/ai-config/skills/backend/recommendations-concepts/SKILL.md +261 -0
- package/ai-config/skills/backend/search-concepts/SKILL.md +263 -0
- package/ai-config/skills/backend/search-spring/SKILL.md +375 -0
- package/ai-config/skills/backend/spring-boot-4/SKILL.md +172 -0
- package/ai-config/skills/backend/websockets/SKILL.md +532 -0
- package/ai-config/skills/data-ai/ai-ml/SKILL.md +423 -0
- package/ai-config/skills/data-ai/analytics-concepts/SKILL.md +195 -0
- package/ai-config/skills/data-ai/analytics-spring/SKILL.md +340 -0
- package/ai-config/skills/data-ai/duckdb-analytics/SKILL.md +440 -0
- package/ai-config/skills/data-ai/langchain/SKILL.md +238 -0
- package/ai-config/skills/data-ai/mlflow/SKILL.md +302 -0
- package/ai-config/skills/data-ai/onnx-inference/SKILL.md +290 -0
- package/ai-config/skills/data-ai/powerbi/SKILL.md +352 -0
- package/ai-config/skills/data-ai/pytorch/SKILL.md +274 -0
- package/ai-config/skills/data-ai/scikit-learn/SKILL.md +321 -0
- package/ai-config/skills/data-ai/vector-db/SKILL.md +301 -0
- package/ai-config/skills/database/graph-databases/SKILL.md +218 -0
- package/ai-config/skills/database/graph-spring/SKILL.md +361 -0
- package/ai-config/skills/database/pgx-postgres/SKILL.md +512 -0
- package/ai-config/skills/database/redis-cache/SKILL.md +343 -0
- package/ai-config/skills/database/sqlite-embedded/SKILL.md +388 -0
- package/ai-config/skills/database/timescaledb/SKILL.md +320 -0
- package/ai-config/skills/docs/api-documentation/SKILL.md +293 -0
- package/ai-config/skills/docs/docs-spring/SKILL.md +377 -0
- package/ai-config/skills/docs/mustache-templates/SKILL.md +190 -0
- package/ai-config/skills/docs/technical-docs/SKILL.md +447 -0
- package/ai-config/skills/frontend/astro-ssr/SKILL.md +441 -0
- package/ai-config/skills/frontend/frontend-design/SKILL.md +54 -0
- package/ai-config/skills/frontend/frontend-web/SKILL.md +368 -0
- package/ai-config/skills/frontend/mantine-ui/SKILL.md +396 -0
- package/ai-config/skills/frontend/tanstack-query/SKILL.md +439 -0
- package/ai-config/skills/frontend/zod-validation/SKILL.md +417 -0
- package/ai-config/skills/frontend/zustand-state/SKILL.md +350 -0
- package/ai-config/skills/infrastructure/chaos-engineering/SKILL.md +244 -0
- package/ai-config/skills/infrastructure/chaos-spring/SKILL.md +378 -0
- package/ai-config/skills/infrastructure/devops-infra/SKILL.md +435 -0
- package/ai-config/skills/infrastructure/docker-containers/SKILL.md +420 -0
- package/ai-config/skills/infrastructure/kubernetes/SKILL.md +456 -0
- package/ai-config/skills/infrastructure/opentelemetry/SKILL.md +546 -0
- package/ai-config/skills/infrastructure/traefik-proxy/SKILL.md +474 -0
- package/ai-config/skills/infrastructure/woodpecker-ci/SKILL.md +315 -0
- package/ai-config/skills/mobile/ionic-capacitor/SKILL.md +504 -0
- package/ai-config/skills/mobile/mobile-ionic/SKILL.md +448 -0
- package/ai-config/skills/prompt-improver/SKILL.md +125 -0
- package/ai-config/skills/quality/ghagga-review/SKILL.md +216 -0
- package/ai-config/skills/references/hooks-patterns/SKILL.md +238 -0
- package/ai-config/skills/references/mcp-servers/SKILL.md +275 -0
- package/ai-config/skills/references/plugins-reference/SKILL.md +110 -0
- package/ai-config/skills/references/skills-reference/SKILL.md +420 -0
- package/ai-config/skills/references/subagent-templates/SKILL.md +193 -0
- package/ai-config/skills/systems-iot/modbus-protocol/SKILL.md +410 -0
- package/ai-config/skills/systems-iot/mqtt-rumqttc/SKILL.md +408 -0
- package/ai-config/skills/systems-iot/rust-systems/SKILL.md +386 -0
- package/ai-config/skills/systems-iot/tokio-async/SKILL.md +324 -0
- package/ai-config/skills/testing/playwright-e2e/SKILL.md +289 -0
- package/ai-config/skills/testing/testcontainers/SKILL.md +299 -0
- package/ai-config/skills/testing/vitest-testing/SKILL.md +381 -0
- package/ai-config/skills/workflow/ci-local-guide/SKILL.md +118 -0
- package/ai-config/skills/workflow/claude-automation-recommender/SKILL.md +299 -0
- package/ai-config/skills/workflow/claude-md-improver/SKILL.md +158 -0
- package/ai-config/skills/workflow/finishing-a-development-branch/SKILL.md +117 -0
- package/ai-config/skills/workflow/git-github/SKILL.md +334 -0
- package/ai-config/skills/workflow/git-github/references/examples.md +160 -0
- package/ai-config/skills/workflow/git-workflow/SKILL.md +214 -0
- package/ai-config/skills/workflow/ide-plugins/SKILL.md +277 -0
- package/ai-config/skills/workflow/ide-plugins-intellij/SKILL.md +401 -0
- package/ai-config/skills/workflow/obsidian-brain-workflow/SKILL.md +199 -0
- package/ai-config/skills/workflow/using-git-worktrees/SKILL.md +100 -0
- package/ai-config/skills/workflow/verification-before-completion/SKILL.md +73 -0
- package/ai-config/skills/workflow/wave-workflow/SKILL.md +178 -0
- package/ci-local/README.md +170 -0
- package/ci-local/ci-local.sh +297 -0
- package/ci-local/hooks/commit-msg +74 -0
- package/ci-local/hooks/pre-commit +162 -0
- package/ci-local/hooks/pre-push +41 -0
- package/ci-local/install.sh +49 -0
- package/ci-local/semgrep.yml +214 -0
- package/dist/commands/analyze.d.ts +9 -0
- package/dist/commands/analyze.d.ts.map +1 -0
- package/dist/commands/analyze.js +55 -0
- package/dist/commands/analyze.js.map +1 -0
- package/dist/commands/analyze.test.d.ts +2 -0
- package/dist/commands/analyze.test.d.ts.map +1 -0
- package/dist/commands/analyze.test.js +145 -0
- package/dist/commands/analyze.test.js.map +1 -0
- package/dist/commands/doctor.d.ts +7 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +158 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/doctor.test.d.ts +2 -0
- package/dist/commands/doctor.test.d.ts.map +1 -0
- package/dist/commands/doctor.test.js +200 -0
- package/dist/commands/doctor.test.js.map +1 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +283 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/init.test.d.ts +2 -0
- package/dist/commands/init.test.d.ts.map +1 -0
- package/dist/commands/init.test.js +271 -0
- package/dist/commands/init.test.js.map +1 -0
- package/dist/commands/sync.d.ts +8 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +201 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/constants.d.ts +21 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +57 -0
- package/dist/constants.js.map +1 -0
- package/dist/e2e/aggressive.e2e.test.d.ts +2 -0
- package/dist/e2e/aggressive.e2e.test.d.ts.map +1 -0
- package/dist/e2e/aggressive.e2e.test.js +350 -0
- package/dist/e2e/aggressive.e2e.test.js.map +1 -0
- package/dist/e2e/commands.e2e.test.d.ts +2 -0
- package/dist/e2e/commands.e2e.test.d.ts.map +1 -0
- package/dist/e2e/commands.e2e.test.js +213 -0
- package/dist/e2e/commands.e2e.test.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +82 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/common.d.ts +17 -0
- package/dist/lib/common.d.ts.map +1 -0
- package/dist/lib/common.js +111 -0
- package/dist/lib/common.js.map +1 -0
- package/dist/lib/common.test.d.ts +2 -0
- package/dist/lib/common.test.d.ts.map +1 -0
- package/dist/lib/common.test.js +316 -0
- package/dist/lib/common.test.js.map +1 -0
- package/dist/lib/frontmatter.d.ts +18 -0
- package/dist/lib/frontmatter.d.ts.map +1 -0
- package/dist/lib/frontmatter.js +61 -0
- package/dist/lib/frontmatter.js.map +1 -0
- package/dist/lib/frontmatter.test.d.ts +2 -0
- package/dist/lib/frontmatter.test.d.ts.map +1 -0
- package/dist/lib/frontmatter.test.js +257 -0
- package/dist/lib/frontmatter.test.js.map +1 -0
- package/dist/lib/template.d.ts +24 -0
- package/dist/lib/template.d.ts.map +1 -0
- package/dist/lib/template.js +78 -0
- package/dist/lib/template.js.map +1 -0
- package/dist/lib/template.test.d.ts +2 -0
- package/dist/lib/template.test.d.ts.map +1 -0
- package/dist/lib/template.test.js +201 -0
- package/dist/lib/template.test.js.map +1 -0
- package/dist/types/index.d.ts +48 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/ui/AnalyzeUI.d.ts +7 -0
- package/dist/ui/AnalyzeUI.d.ts.map +1 -0
- package/dist/ui/AnalyzeUI.js +100 -0
- package/dist/ui/AnalyzeUI.js.map +1 -0
- package/dist/ui/App.d.ts +13 -0
- package/dist/ui/App.d.ts.map +1 -0
- package/dist/ui/App.js +100 -0
- package/dist/ui/App.js.map +1 -0
- package/dist/ui/CIContext.d.ts +9 -0
- package/dist/ui/CIContext.d.ts.map +1 -0
- package/dist/ui/CIContext.js +9 -0
- package/dist/ui/CIContext.js.map +1 -0
- package/dist/ui/CISelector.d.ts +8 -0
- package/dist/ui/CISelector.d.ts.map +1 -0
- package/dist/ui/CISelector.js +45 -0
- package/dist/ui/CISelector.js.map +1 -0
- package/dist/ui/Doctor.d.ts +3 -0
- package/dist/ui/Doctor.d.ts.map +1 -0
- package/dist/ui/Doctor.js +89 -0
- package/dist/ui/Doctor.js.map +1 -0
- package/dist/ui/Header.d.ts +8 -0
- package/dist/ui/Header.d.ts.map +1 -0
- package/dist/ui/Header.js +30 -0
- package/dist/ui/Header.js.map +1 -0
- package/dist/ui/MemorySelector.d.ts +8 -0
- package/dist/ui/MemorySelector.d.ts.map +1 -0
- package/dist/ui/MemorySelector.js +46 -0
- package/dist/ui/MemorySelector.js.map +1 -0
- package/dist/ui/NameInput.d.ts +8 -0
- package/dist/ui/NameInput.d.ts.map +1 -0
- package/dist/ui/NameInput.js +69 -0
- package/dist/ui/NameInput.js.map +1 -0
- package/dist/ui/OptionSelector.d.ts +12 -0
- package/dist/ui/OptionSelector.d.ts.map +1 -0
- package/dist/ui/OptionSelector.js +69 -0
- package/dist/ui/OptionSelector.js.map +1 -0
- package/dist/ui/Progress.d.ts +11 -0
- package/dist/ui/Progress.d.ts.map +1 -0
- package/dist/ui/Progress.js +58 -0
- package/dist/ui/Progress.js.map +1 -0
- package/dist/ui/StackSelector.d.ts +9 -0
- package/dist/ui/StackSelector.d.ts.map +1 -0
- package/dist/ui/StackSelector.js +65 -0
- package/dist/ui/StackSelector.js.map +1 -0
- package/dist/ui/Summary.d.ts +12 -0
- package/dist/ui/Summary.d.ts.map +1 -0
- package/dist/ui/Summary.js +114 -0
- package/dist/ui/Summary.js.map +1 -0
- package/dist/ui/SyncUI.d.ts +10 -0
- package/dist/ui/SyncUI.d.ts.map +1 -0
- package/dist/ui/SyncUI.js +64 -0
- package/dist/ui/SyncUI.js.map +1 -0
- package/dist/ui/Welcome.d.ts +7 -0
- package/dist/ui/Welcome.d.ts.map +1 -0
- package/dist/ui/Welcome.js +45 -0
- package/dist/ui/Welcome.js.map +1 -0
- package/dist/ui/theme.d.ts +10 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +9 -0
- package/dist/ui/theme.js.map +1 -0
- package/modules/engram/.gitignore-snippet.txt +6 -0
- package/modules/engram/.mcp-config-snippet.json +11 -0
- package/modules/engram/README.md +146 -0
- package/modules/engram/install-engram.sh +216 -0
- package/modules/ghagga/.env.example +43 -0
- package/modules/ghagga/README.md +153 -0
- package/modules/ghagga/docker-compose.yml +80 -0
- package/modules/ghagga/setup-ghagga.sh +139 -0
- package/modules/memory-simple/.project/NOTES.md +22 -0
- package/modules/memory-simple/README.md +23 -0
- package/modules/obsidian-brain/.obsidian/app.json +23 -0
- package/modules/obsidian-brain/.obsidian/appearance.json +5 -0
- package/modules/obsidian-brain/.obsidian/bookmarks.json +34 -0
- package/modules/obsidian-brain/.obsidian/community-plugins.json +1 -0
- package/modules/obsidian-brain/.obsidian/core-plugins-migration.json +21 -0
- package/modules/obsidian-brain/.obsidian/core-plugins.json +18 -0
- package/modules/obsidian-brain/.obsidian/daily-notes.json +5 -0
- package/modules/obsidian-brain/.obsidian/graph.json +37 -0
- package/modules/obsidian-brain/.obsidian/hotkeys.json +14 -0
- package/modules/obsidian-brain/.obsidian/plugins/dataview/data.json +25 -0
- package/modules/obsidian-brain/.obsidian/plugins/obsidian-kanban/data.json +29 -0
- package/modules/obsidian-brain/.obsidian/plugins/templater-obsidian/data.json +18 -0
- package/modules/obsidian-brain/.obsidian/snippets/project-memory.css +71 -0
- package/modules/obsidian-brain/.obsidian-gitignore-snippet.txt +8 -0
- package/modules/obsidian-brain/.project/Attachments/.gitkeep +0 -0
- package/modules/obsidian-brain/.project/Memory/BLOCKERS.md +78 -0
- package/modules/obsidian-brain/.project/Memory/CONTEXT.md +102 -0
- package/modules/obsidian-brain/.project/Memory/DASHBOARD.md +73 -0
- package/modules/obsidian-brain/.project/Memory/DECISIONS.md +87 -0
- package/modules/obsidian-brain/.project/Memory/KANBAN.md +15 -0
- package/modules/obsidian-brain/.project/Memory/README.md +61 -0
- package/modules/obsidian-brain/.project/Memory/WAVES.md +78 -0
- package/modules/obsidian-brain/.project/Sessions/TEMPLATE.md +99 -0
- package/modules/obsidian-brain/.project/Templates/ADR.md +33 -0
- package/modules/obsidian-brain/.project/Templates/Blocker.md +21 -0
- package/modules/obsidian-brain/.project/Templates/Session.md +88 -0
- package/modules/obsidian-brain/README.md +268 -0
- package/modules/obsidian-brain/new-wave.sh +182 -0
- package/package.json +51 -0
- package/schemas/agent.schema.json +34 -0
- package/schemas/ai-config.schema.json +28 -0
- package/schemas/skill.schema.json +44 -0
- package/src/commands/analyze.test.ts +145 -0
- package/src/commands/analyze.ts +69 -0
- package/src/commands/doctor.test.ts +208 -0
- package/src/commands/doctor.ts +163 -0
- package/src/commands/init.test.ts +298 -0
- package/src/commands/init.ts +285 -0
- package/src/constants.ts +69 -0
- package/src/e2e/aggressive.e2e.test.ts +557 -0
- package/src/e2e/commands.e2e.test.ts +298 -0
- package/src/index.tsx +106 -0
- package/src/lib/common.test.ts +318 -0
- package/src/lib/common.ts +127 -0
- package/src/lib/frontmatter.test.ts +291 -0
- package/src/lib/frontmatter.ts +77 -0
- package/src/lib/template.test.ts +226 -0
- package/src/lib/template.ts +99 -0
- package/src/types/index.ts +53 -0
- package/src/ui/AnalyzeUI.tsx +133 -0
- package/src/ui/App.tsx +175 -0
- package/src/ui/CIContext.tsx +25 -0
- package/src/ui/CISelector.tsx +72 -0
- package/src/ui/Doctor.tsx +122 -0
- package/src/ui/Header.tsx +48 -0
- package/src/ui/MemorySelector.tsx +73 -0
- package/src/ui/NameInput.tsx +82 -0
- package/src/ui/OptionSelector.tsx +100 -0
- package/src/ui/Progress.tsx +88 -0
- package/src/ui/StackSelector.tsx +101 -0
- package/src/ui/Summary.tsx +134 -0
- package/src/ui/Welcome.tsx +54 -0
- package/src/ui/theme.ts +10 -0
- package/stryker.config.json +19 -0
- package/tasks/_TEMPLATE/files-edited.md +3 -0
- package/tasks/_TEMPLATE/plan.md +3 -0
- package/tasks/_TEMPLATE/research.md +3 -0
- package/tasks/_TEMPLATE/verification.md +5 -0
- package/templates/common/dependabot/cargo.yml +11 -0
- package/templates/common/dependabot/github-actions.yml +16 -0
- package/templates/common/dependabot/gomod.yml +15 -0
- package/templates/common/dependabot/gradle.yml +15 -0
- package/templates/common/dependabot/header.yml +3 -0
- package/templates/common/dependabot/maven.yml +15 -0
- package/templates/common/dependabot/npm.yml +20 -0
- package/templates/common/dependabot/pip.yml +11 -0
- package/templates/dependabot.yml +162 -0
- package/templates/github/ci-go.yml +41 -0
- package/templates/github/ci-java.yml +45 -0
- package/templates/github/ci-monorepo.yml +150 -0
- package/templates/github/ci-node.yml +42 -0
- package/templates/github/ci-python.yml +42 -0
- package/templates/github/ci-rust.yml +42 -0
- package/templates/github/dependabot-automerge.yml +40 -0
- package/templates/gitlab/gitlab-ci-go.yml +88 -0
- package/templates/gitlab/gitlab-ci-java.yml +79 -0
- package/templates/gitlab/gitlab-ci-monorepo.yml +126 -0
- package/templates/gitlab/gitlab-ci-node.yml +63 -0
- package/templates/gitlab/gitlab-ci-python.yml +147 -0
- package/templates/gitlab/gitlab-ci-rust.yml +67 -0
- package/templates/global/claude-settings.json +98 -0
- package/templates/global/codex-config.toml +8 -0
- package/templates/global/copilot-instructions/base-rules.instructions.md +13 -0
- package/templates/global/copilot-instructions/sdd-orchestrator.instructions.md +37 -0
- package/templates/global/gemini-commands/cleanup.toml +20 -0
- package/templates/global/gemini-commands/commit.toml +15 -0
- package/templates/global/gemini-commands/dead-code.toml +22 -0
- package/templates/global/gemini-commands/plan.toml +30 -0
- package/templates/global/gemini-commands/review.toml +17 -0
- package/templates/global/gemini-commands/sdd-apply.toml +22 -0
- package/templates/global/gemini-commands/sdd-ff.toml +14 -0
- package/templates/global/gemini-commands/sdd-new.toml +21 -0
- package/templates/global/gemini-commands/sdd-verify.toml +21 -0
- package/templates/global/gemini-commands/tdd.toml +26 -0
- package/templates/global/gemini-settings.json +8 -0
- package/templates/global/opencode-config.json +44 -0
- package/templates/global/sdd-instructions.md +47 -0
- package/templates/global/sdd-orchestrator-claude.md +46 -0
- package/templates/global/sdd-orchestrator-copilot.md +34 -0
- package/templates/renovate.json +69 -0
- package/templates/woodpecker/monorepo/backend.yml +34 -0
- package/templates/woodpecker/monorepo/frontend.yml +34 -0
- package/templates/woodpecker/monorepo/summary.yml +25 -0
- package/templates/woodpecker/woodpecker-go.yml +51 -0
- package/templates/woodpecker/woodpecker-java.yml +67 -0
- package/templates/woodpecker/woodpecker-node.yml +47 -0
- package/templates/woodpecker/woodpecker-python.yml +108 -0
- package/templates/woodpecker/woodpecker-rust.yml +57 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +16 -0
- package/workflows/reusable-build-go.yml +111 -0
- package/workflows/reusable-build-java.yml +120 -0
- package/workflows/reusable-build-node.yml +145 -0
- package/workflows/reusable-build-python.yml +159 -0
- package/workflows/reusable-build-rust.yml +135 -0
- package/workflows/reusable-docker.yml +120 -0
- package/workflows/reusable-ghagga-review.yml +165 -0
- package/workflows/reusable-release.yml +91 -0
|
@@ -0,0 +1,2633 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: solo-dev-planner-testing
|
|
3
|
+
description: "Mรณdulo 5: Testing robusto con Testcontainers"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ๐งช Solo Dev Planner - Testing Strategy
|
|
7
|
+
|
|
8
|
+
> Mรณdulo 5 de 6: Testing robusto con Testcontainers
|
|
9
|
+
|
|
10
|
+
## ๐ Relacionado con:
|
|
11
|
+
- 01-CORE.md (CI/CD integration)
|
|
12
|
+
- 02-SELF-CORRECTION.md (Auto-fix tests)
|
|
13
|
+
- 06-OPERATIONS.md (Mise tasks, DB para tests)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## ๐งช Testing Strategy Completa
|
|
18
|
+
|
|
19
|
+
### Pirรกmide de Testing
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
/\
|
|
23
|
+
/E2E\ โ 10% (costosos, lentos, frรกgiles)
|
|
24
|
+
/------\
|
|
25
|
+
/Integr.\ โ 20% (medios, con DB/API)
|
|
26
|
+
/----------\
|
|
27
|
+
/ Unit \ โ 70% (rรกpidos, baratos, aislados)
|
|
28
|
+
/--------------\
|
|
29
|
+
|
|
30
|
+
Regla de oro: Mientras mรกs bajo en la pirรกmide, mejor ROI
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## ๐ Unit Tests (70% de cobertura)
|
|
36
|
+
|
|
37
|
+
### TypeScript con Bun
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// tests/unit/user.test.ts
|
|
41
|
+
import { test, expect, describe, beforeEach } from 'bun:test';
|
|
42
|
+
import { User } from '@/models/User';
|
|
43
|
+
import { hash } from '@/utils/crypto';
|
|
44
|
+
|
|
45
|
+
describe('User Model', () => {
|
|
46
|
+
describe('validation', () => {
|
|
47
|
+
test('rejects invalid email', () => {
|
|
48
|
+
expect(() => User.create({
|
|
49
|
+
email: 'invalid',
|
|
50
|
+
password: 'pass123'
|
|
51
|
+
})).toThrow('Invalid email format');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('requires password min length', () => {
|
|
55
|
+
expect(() => User.create({
|
|
56
|
+
email: 'test@example.com',
|
|
57
|
+
password: '123'
|
|
58
|
+
})).toThrow('Password must be at least 8 characters');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('accepts valid user data', () => {
|
|
62
|
+
const user = User.create({
|
|
63
|
+
email: 'test@example.com',
|
|
64
|
+
password: 'validpass123'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(user.email).toBe('test@example.com');
|
|
68
|
+
expect(user.password).not.toBe('validpass123'); // Should be hashed
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('authentication', () => {
|
|
73
|
+
test('verifies correct password', async () => {
|
|
74
|
+
const user = await User.create({
|
|
75
|
+
email: 'test@example.com',
|
|
76
|
+
password: 'secret123'
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const isValid = await user.verifyPassword('secret123');
|
|
80
|
+
expect(isValid).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('rejects incorrect password', async () => {
|
|
84
|
+
const user = await User.create({
|
|
85
|
+
email: 'test@example.com',
|
|
86
|
+
password: 'secret123'
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const isValid = await user.verifyPassword('wrong');
|
|
90
|
+
expect(isValid).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Python con pytest
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
# tests/unit/test_user.py
|
|
100
|
+
import pytest
|
|
101
|
+
from app.models.user import User
|
|
102
|
+
from app.exceptions import ValidationError
|
|
103
|
+
|
|
104
|
+
class TestUserModel:
|
|
105
|
+
"""Test suite for User model"""
|
|
106
|
+
|
|
107
|
+
def test_rejects_invalid_email(self):
|
|
108
|
+
with pytest.raises(ValidationError, match="Invalid email"):
|
|
109
|
+
User.create(email="invalid", password="pass123")
|
|
110
|
+
|
|
111
|
+
def test_requires_password_min_length(self):
|
|
112
|
+
with pytest.raises(ValidationError, match="at least 8 characters"):
|
|
113
|
+
User.create(email="test@example.com", password="123")
|
|
114
|
+
|
|
115
|
+
def test_accepts_valid_user_data(self):
|
|
116
|
+
user = User.create(
|
|
117
|
+
email="test@example.com",
|
|
118
|
+
password="validpass123"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
assert user.email == "test@example.com"
|
|
122
|
+
assert user.password != "validpass123" # Should be hashed
|
|
123
|
+
|
|
124
|
+
@pytest.mark.asyncio
|
|
125
|
+
async def test_verifies_correct_password(self):
|
|
126
|
+
user = await User.create(
|
|
127
|
+
email="test@example.com",
|
|
128
|
+
password="secret123"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
is_valid = await user.verify_password("secret123")
|
|
132
|
+
assert is_valid is True
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
async def test_rejects_incorrect_password(self):
|
|
136
|
+
user = await User.create(
|
|
137
|
+
email="test@example.com",
|
|
138
|
+
password="secret123"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
is_valid = await user.verify_password("wrong")
|
|
142
|
+
assert is_valid is False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Fixtures en conftest.py
|
|
146
|
+
# tests/conftest.py
|
|
147
|
+
import pytest
|
|
148
|
+
from app.database import engine, SessionLocal
|
|
149
|
+
from app.models import Base
|
|
150
|
+
|
|
151
|
+
@pytest.fixture(scope="function")
|
|
152
|
+
async def db_session():
|
|
153
|
+
"""Create a fresh database for each test"""
|
|
154
|
+
async with engine.begin() as conn:
|
|
155
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
156
|
+
|
|
157
|
+
session = SessionLocal()
|
|
158
|
+
try:
|
|
159
|
+
yield session
|
|
160
|
+
finally:
|
|
161
|
+
session.close()
|
|
162
|
+
async with engine.begin() as conn:
|
|
163
|
+
await conn.run_sync(Base.metadata.drop_all)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Go con testify
|
|
167
|
+
|
|
168
|
+
```go
|
|
169
|
+
// internal/models/user_test.go
|
|
170
|
+
package models
|
|
171
|
+
|
|
172
|
+
import (
|
|
173
|
+
"testing"
|
|
174
|
+
"github.com/stretchr/testify/assert"
|
|
175
|
+
"github.com/stretchr/testify/require"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
func TestUser_Create_ValidatesEmail(t *testing.T) {
|
|
179
|
+
tests := []struct {
|
|
180
|
+
name string
|
|
181
|
+
email string
|
|
182
|
+
wantErr bool
|
|
183
|
+
}{
|
|
184
|
+
{
|
|
185
|
+
name: "valid email",
|
|
186
|
+
email: "test@example.com",
|
|
187
|
+
wantErr: false,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "invalid email - no @",
|
|
191
|
+
email: "invalid",
|
|
192
|
+
wantErr: true,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: "invalid email - no domain",
|
|
196
|
+
email: "test@",
|
|
197
|
+
wantErr: true,
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for _, tt := range tests {
|
|
202
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
203
|
+
user, err := NewUser(tt.email, "password123")
|
|
204
|
+
|
|
205
|
+
if tt.wantErr {
|
|
206
|
+
assert.Error(t, err)
|
|
207
|
+
assert.Nil(t, user)
|
|
208
|
+
} else {
|
|
209
|
+
assert.NoError(t, err)
|
|
210
|
+
assert.NotNil(t, user)
|
|
211
|
+
assert.Equal(t, tt.email, user.Email)
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
func TestUser_VerifyPassword(t *testing.T) {
|
|
218
|
+
user, err := NewUser("test@example.com", "secret123")
|
|
219
|
+
require.NoError(t, err)
|
|
220
|
+
|
|
221
|
+
t.Run("correct password", func(t *testing.T) {
|
|
222
|
+
valid := user.VerifyPassword("secret123")
|
|
223
|
+
assert.True(t, valid)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
t.Run("incorrect password", func(t *testing.T) {
|
|
227
|
+
valid := user.VerifyPassword("wrong")
|
|
228
|
+
assert.False(t, valid)
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Test Data Factories (TypeScript)
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
// tests/factories/user.factory.ts
|
|
237
|
+
import { faker } from '@faker-js/faker';
|
|
238
|
+
import { db } from '@/db';
|
|
239
|
+
|
|
240
|
+
export const UserFactory = {
|
|
241
|
+
/**
|
|
242
|
+
* Build user data without saving to DB
|
|
243
|
+
*/
|
|
244
|
+
build: (overrides: Partial<User> = {}) => ({
|
|
245
|
+
email: faker.internet.email(),
|
|
246
|
+
name: faker.person.fullName(),
|
|
247
|
+
password: faker.internet.password({ length: 12 }),
|
|
248
|
+
role: 'user',
|
|
249
|
+
createdAt: new Date(),
|
|
250
|
+
...overrides,
|
|
251
|
+
}),
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Create user in database
|
|
255
|
+
*/
|
|
256
|
+
create: async (overrides: Partial<User> = {}) => {
|
|
257
|
+
const data = UserFactory.build(overrides);
|
|
258
|
+
return await db.user.create({ data });
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Create multiple users
|
|
263
|
+
*/
|
|
264
|
+
createMany: async (count: number, overrides: Partial<User> = {}) => {
|
|
265
|
+
return Promise.all(
|
|
266
|
+
Array.from({ length: count }, () => UserFactory.create(overrides))
|
|
267
|
+
);
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Uso en tests
|
|
272
|
+
test('can list users', async () => {
|
|
273
|
+
await UserFactory.createMany(5);
|
|
274
|
+
|
|
275
|
+
const users = await User.findAll();
|
|
276
|
+
expect(users).toHaveLength(5);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('can create admin user', async () => {
|
|
280
|
+
const admin = await UserFactory.create({ role: 'admin' });
|
|
281
|
+
expect(admin.role).toBe('admin');
|
|
282
|
+
});
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## ๐ Integration Tests (20%)
|
|
288
|
+
|
|
289
|
+
### API Integration Tests (TypeScript + Hono)
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
// tests/integration/auth.test.ts
|
|
293
|
+
import { test, expect, beforeAll, afterAll } from 'bun:test';
|
|
294
|
+
import { app } from '@/index';
|
|
295
|
+
import { db } from '@/db';
|
|
296
|
+
import { UserFactory } from '../factories/user.factory';
|
|
297
|
+
|
|
298
|
+
// Setup test database
|
|
299
|
+
beforeAll(async () => {
|
|
300
|
+
await db.migrate.latest();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
afterAll(async () => {
|
|
304
|
+
await db.migrate.rollback();
|
|
305
|
+
await db.destroy();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('POST /auth/register creates user', async () => {
|
|
309
|
+
const res = await app.request('/auth/register', {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
headers: { 'Content-Type': 'application/json' },
|
|
312
|
+
body: JSON.stringify({
|
|
313
|
+
email: 'newuser@example.com',
|
|
314
|
+
password: 'password123',
|
|
315
|
+
name: 'New User',
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(res.status).toBe(201);
|
|
320
|
+
|
|
321
|
+
const json = await res.json();
|
|
322
|
+
expect(json.user.email).toBe('newuser@example.com');
|
|
323
|
+
expect(json.token).toBeDefined();
|
|
324
|
+
|
|
325
|
+
// Verify user in database
|
|
326
|
+
const user = await db.user.findUnique({
|
|
327
|
+
where: { email: 'newuser@example.com' },
|
|
328
|
+
});
|
|
329
|
+
expect(user).toBeDefined();
|
|
330
|
+
expect(user!.name).toBe('New User');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('POST /auth/login returns token for valid credentials', async () => {
|
|
334
|
+
// Arrange: Create user
|
|
335
|
+
const password = 'secret123';
|
|
336
|
+
const user = await UserFactory.create({ password });
|
|
337
|
+
|
|
338
|
+
// Act: Login
|
|
339
|
+
const res = await app.request('/auth/login', {
|
|
340
|
+
method: 'POST',
|
|
341
|
+
headers: { 'Content-Type': 'application/json' },
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
email: user.email,
|
|
344
|
+
password,
|
|
345
|
+
}),
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Assert
|
|
349
|
+
expect(res.status).toBe(200);
|
|
350
|
+
|
|
351
|
+
const json = await res.json();
|
|
352
|
+
expect(json.token).toBeDefined();
|
|
353
|
+
expect(json.user.id).toBe(user.id);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('protected routes require authentication', async () => {
|
|
357
|
+
const res = await app.request('/api/profile', {
|
|
358
|
+
method: 'GET',
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(res.status).toBe(401);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('protected routes accept valid token', async () => {
|
|
365
|
+
// Create user and get token
|
|
366
|
+
const user = await UserFactory.create();
|
|
367
|
+
const token = await generateToken(user);
|
|
368
|
+
|
|
369
|
+
const res = await app.request('/api/profile', {
|
|
370
|
+
method: 'GET',
|
|
371
|
+
headers: {
|
|
372
|
+
'Authorization': `Bearer ${token}`,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(res.status).toBe(200);
|
|
377
|
+
|
|
378
|
+
const json = await res.json();
|
|
379
|
+
expect(json.email).toBe(user.email);
|
|
380
|
+
});
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Database Integration Tests (Python)
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
# tests/integration/test_user_repository.py
|
|
387
|
+
import pytest
|
|
388
|
+
from app.repositories.user_repository import UserRepository
|
|
389
|
+
from app.models.user import User
|
|
390
|
+
|
|
391
|
+
@pytest.mark.asyncio
|
|
392
|
+
class TestUserRepository:
|
|
393
|
+
async def test_create_and_find_user(self, db_session):
|
|
394
|
+
repo = UserRepository(db_session)
|
|
395
|
+
|
|
396
|
+
# Create user
|
|
397
|
+
user = await repo.create(
|
|
398
|
+
email="test@example.com",
|
|
399
|
+
password="password123"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
assert user.id is not None
|
|
403
|
+
assert user.email == "test@example.com"
|
|
404
|
+
|
|
405
|
+
# Find user
|
|
406
|
+
found = await repo.find_by_email("test@example.com")
|
|
407
|
+
assert found is not None
|
|
408
|
+
assert found.id == user.id
|
|
409
|
+
|
|
410
|
+
async def test_update_user(self, db_session):
|
|
411
|
+
repo = UserRepository(db_session)
|
|
412
|
+
|
|
413
|
+
user = await repo.create(
|
|
414
|
+
email="test@example.com",
|
|
415
|
+
password="password123"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# Update
|
|
419
|
+
updated = await repo.update(
|
|
420
|
+
user.id,
|
|
421
|
+
name="Updated Name"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
assert updated.name == "Updated Name"
|
|
425
|
+
assert updated.email == "test@example.com"
|
|
426
|
+
|
|
427
|
+
async def test_delete_user(self, db_session):
|
|
428
|
+
repo = UserRepository(db_session)
|
|
429
|
+
|
|
430
|
+
user = await repo.create(
|
|
431
|
+
email="test@example.com",
|
|
432
|
+
password="password123"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Delete
|
|
436
|
+
await repo.delete(user.id)
|
|
437
|
+
|
|
438
|
+
# Verify deleted
|
|
439
|
+
found = await repo.find_by_id(user.id)
|
|
440
|
+
assert found is None
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
---
|
|
444
|
+
|
|
445
|
+
## ๐ E2E Tests (10%)
|
|
446
|
+
|
|
447
|
+
### Playwright Setup
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
// playwright.config.ts
|
|
451
|
+
import { defineConfig } from '@playwright/test';
|
|
452
|
+
|
|
453
|
+
export default defineConfig({
|
|
454
|
+
testDir: './tests/e2e',
|
|
455
|
+
fullyParallel: true,
|
|
456
|
+
forbidOnly: !!process.env.CI,
|
|
457
|
+
retries: process.env.CI ? 2 : 0,
|
|
458
|
+
workers: process.env.CI ? 1 : undefined,
|
|
459
|
+
|
|
460
|
+
use: {
|
|
461
|
+
baseURL: 'http://localhost:3000',
|
|
462
|
+
trace: 'on-first-retry',
|
|
463
|
+
screenshot: 'only-on-failure',
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
webServer: {
|
|
467
|
+
command: 'mise run dev',
|
|
468
|
+
port: 3000,
|
|
469
|
+
reuseExistingServer: !process.env.CI,
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### E2E Test Examples
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
// tests/e2e/auth-flow.spec.ts
|
|
478
|
+
import { test, expect } from '@playwright/test';
|
|
479
|
+
|
|
480
|
+
test.describe('Complete Authentication Flow', () => {
|
|
481
|
+
test('user can register, login, and access protected pages', async ({ page }) => {
|
|
482
|
+
// Register
|
|
483
|
+
await page.goto('/register');
|
|
484
|
+
await page.fill('[name="email"]', 'user@test.com');
|
|
485
|
+
await page.fill('[name="password"]', 'password123');
|
|
486
|
+
await page.fill('[name="name"]', 'Test User');
|
|
487
|
+
await page.click('button[type="submit"]');
|
|
488
|
+
|
|
489
|
+
// Should redirect to dashboard
|
|
490
|
+
await expect(page).toHaveURL('/dashboard');
|
|
491
|
+
await expect(page.locator('h1')).toContainText('Welcome, Test User');
|
|
492
|
+
|
|
493
|
+
// Logout
|
|
494
|
+
await page.click('[data-testid="logout-button"]');
|
|
495
|
+
await expect(page).toHaveURL('/login');
|
|
496
|
+
|
|
497
|
+
// Login again
|
|
498
|
+
await page.fill('[name="email"]', 'user@test.com');
|
|
499
|
+
await page.fill('[name="password"]', 'password123');
|
|
500
|
+
await page.click('button[type="submit"]');
|
|
501
|
+
|
|
502
|
+
// Should be logged in
|
|
503
|
+
await expect(page).toHaveURL('/dashboard');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('shows error for invalid credentials', async ({ page }) => {
|
|
507
|
+
await page.goto('/login');
|
|
508
|
+
await page.fill('[name="email"]', 'wrong@test.com');
|
|
509
|
+
await page.fill('[name="password"]', 'wrongpass');
|
|
510
|
+
await page.click('button[type="submit"]');
|
|
511
|
+
|
|
512
|
+
await expect(page.locator('[role="alert"]'))
|
|
513
|
+
.toContainText('Invalid credentials');
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// tests/e2e/task-management.spec.ts
|
|
518
|
+
test.describe('Task Management', () => {
|
|
519
|
+
test.beforeEach(async ({ page }) => {
|
|
520
|
+
// Login before each test
|
|
521
|
+
await page.goto('/login');
|
|
522
|
+
await page.fill('[name="email"]', 'user@test.com');
|
|
523
|
+
await page.fill('[name="password"]', 'password123');
|
|
524
|
+
await page.click('button[type="submit"]');
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test('can create, edit, and delete task', async ({ page }) => {
|
|
528
|
+
// Create task
|
|
529
|
+
await page.goto('/tasks');
|
|
530
|
+
await page.click('[data-testid="new-task"]');
|
|
531
|
+
await page.fill('[name="title"]', 'My New Task');
|
|
532
|
+
await page.fill('[name="description"]', 'Task description');
|
|
533
|
+
await page.click('button[type="submit"]');
|
|
534
|
+
|
|
535
|
+
await expect(page.locator('[data-testid="task-item"]'))
|
|
536
|
+
.toContainText('My New Task');
|
|
537
|
+
|
|
538
|
+
// Edit task
|
|
539
|
+
await page.click('[data-testid="edit-task"]');
|
|
540
|
+
await page.fill('[name="title"]', 'Updated Task');
|
|
541
|
+
await page.click('button[type="submit"]');
|
|
542
|
+
|
|
543
|
+
await expect(page.locator('[data-testid="task-item"]'))
|
|
544
|
+
.toContainText('Updated Task');
|
|
545
|
+
|
|
546
|
+
// Delete task
|
|
547
|
+
await page.click('[data-testid="delete-task"]');
|
|
548
|
+
await page.click('[data-testid="confirm-delete"]');
|
|
549
|
+
|
|
550
|
+
await expect(page.locator('[data-testid="task-item"]'))
|
|
551
|
+
.not.toBeVisible();
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
---
|
|
557
|
+
|
|
558
|
+
## ๐ฏ Mise Tasks para Testing
|
|
559
|
+
|
|
560
|
+
```toml
|
|
561
|
+
# .mise.toml
|
|
562
|
+
|
|
563
|
+
[tasks."test:unit"]
|
|
564
|
+
description = "Run unit tests"
|
|
565
|
+
run = """
|
|
566
|
+
#!/usr/bin/env bash
|
|
567
|
+
if mise current node &> /dev/null; then
|
|
568
|
+
bun test tests/unit/
|
|
569
|
+
elif mise current python &> /dev/null; then
|
|
570
|
+
pytest tests/unit/ -v
|
|
571
|
+
elif mise current go &> /dev/null; then
|
|
572
|
+
go test ./... -short
|
|
573
|
+
fi
|
|
574
|
+
"""
|
|
575
|
+
alias = "tu"
|
|
576
|
+
|
|
577
|
+
[tasks."test:integration"]
|
|
578
|
+
description = "Run integration tests (requires DB)"
|
|
579
|
+
run = """
|
|
580
|
+
#!/usr/bin/env bash
|
|
581
|
+
|
|
582
|
+
# Start test database
|
|
583
|
+
docker compose -f docker-compose.test.yml up -d
|
|
584
|
+
echo "โณ Waiting for database..."
|
|
585
|
+
sleep 5
|
|
586
|
+
|
|
587
|
+
# Run migrations
|
|
588
|
+
mise run db:migrate
|
|
589
|
+
|
|
590
|
+
# Run tests
|
|
591
|
+
if mise current node &> /dev/null; then
|
|
592
|
+
bun test tests/integration/
|
|
593
|
+
elif mise current python &> /dev/null; then
|
|
594
|
+
pytest tests/integration/ -v
|
|
595
|
+
elif mise current go &> /dev/null; then
|
|
596
|
+
go test ./... -run Integration
|
|
597
|
+
fi
|
|
598
|
+
|
|
599
|
+
# Cleanup
|
|
600
|
+
docker compose -f docker-compose.test.yml down
|
|
601
|
+
"""
|
|
602
|
+
alias = "ti"
|
|
603
|
+
|
|
604
|
+
[tasks."test:e2e"]
|
|
605
|
+
description = "Run E2E tests with Playwright"
|
|
606
|
+
run = """
|
|
607
|
+
#!/usr/bin/env bash
|
|
608
|
+
# Start app in background
|
|
609
|
+
mise run dev &
|
|
610
|
+
APP_PID=$!
|
|
611
|
+
|
|
612
|
+
# Wait for app to be ready
|
|
613
|
+
sleep 5
|
|
614
|
+
|
|
615
|
+
# Run E2E tests
|
|
616
|
+
playwright test
|
|
617
|
+
|
|
618
|
+
# Cleanup
|
|
619
|
+
kill $APP_PID
|
|
620
|
+
"""
|
|
621
|
+
alias = "te"
|
|
622
|
+
|
|
623
|
+
[tasks."test:watch"]
|
|
624
|
+
description = "Run tests in watch mode"
|
|
625
|
+
run = """
|
|
626
|
+
#!/usr/bin/env bash
|
|
627
|
+
if mise current node &> /dev/null; then
|
|
628
|
+
bun test --watch
|
|
629
|
+
elif mise current python &> /dev/null; then
|
|
630
|
+
ptw tests/ --
|
|
631
|
+
elif mise current go &> /dev/null; then
|
|
632
|
+
gotestsum --watch
|
|
633
|
+
fi
|
|
634
|
+
"""
|
|
635
|
+
alias = "tw"
|
|
636
|
+
|
|
637
|
+
[tasks."test:coverage"]
|
|
638
|
+
description = "Run tests with coverage report"
|
|
639
|
+
run = """
|
|
640
|
+
#!/usr/bin/env bash
|
|
641
|
+
if mise current node &> /dev/null; then
|
|
642
|
+
bun test --coverage
|
|
643
|
+
elif mise current python &> /dev/null; then
|
|
644
|
+
pytest --cov=app --cov-report=html --cov-report=term
|
|
645
|
+
elif mise current go &> /dev/null; then
|
|
646
|
+
go test -coverprofile=coverage.out ./...
|
|
647
|
+
go tool cover -html=coverage.out -o coverage.html
|
|
648
|
+
fi
|
|
649
|
+
|
|
650
|
+
echo "โ
Coverage report generated"
|
|
651
|
+
"""
|
|
652
|
+
alias = "tc"
|
|
653
|
+
|
|
654
|
+
[tasks.test]
|
|
655
|
+
description = "Run all tests (unit + integration)"
|
|
656
|
+
run = """
|
|
657
|
+
mise run test:unit
|
|
658
|
+
mise run test:integration
|
|
659
|
+
"""
|
|
660
|
+
alias = "t"
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
### Docker Compose para Tests
|
|
664
|
+
|
|
665
|
+
```yaml
|
|
666
|
+
# docker-compose.test.yml
|
|
667
|
+
version: '3.8'
|
|
668
|
+
|
|
669
|
+
services:
|
|
670
|
+
db-test:
|
|
671
|
+
image: postgres:16-alpine
|
|
672
|
+
environment:
|
|
673
|
+
POSTGRES_DB: testdb
|
|
674
|
+
POSTGRES_USER: testuser
|
|
675
|
+
POSTGRES_PASSWORD: testpass
|
|
676
|
+
ports:
|
|
677
|
+
- "5433:5432"
|
|
678
|
+
tmpfs:
|
|
679
|
+
- /var/lib/postgresql/data # Usar RAM para tests (mรกs rรกpido)
|
|
680
|
+
|
|
681
|
+
redis-test:
|
|
682
|
+
image: redis:7-alpine
|
|
683
|
+
ports:
|
|
684
|
+
- "6380:6379"
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
---
|
|
688
|
+
|
|
689
|
+
## ๐ Coverage Configuration
|
|
690
|
+
|
|
691
|
+
### TypeScript (Bun)
|
|
692
|
+
|
|
693
|
+
```json
|
|
694
|
+
// package.json
|
|
695
|
+
{
|
|
696
|
+
"scripts": {
|
|
697
|
+
"test:coverage": "bun test --coverage"
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
### Python (pytest)
|
|
703
|
+
|
|
704
|
+
```ini
|
|
705
|
+
# pytest.ini
|
|
706
|
+
[pytest]
|
|
707
|
+
testpaths = tests
|
|
708
|
+
python_files = test_*.py
|
|
709
|
+
python_classes = Test*
|
|
710
|
+
python_functions = test_*
|
|
711
|
+
|
|
712
|
+
addopts =
|
|
713
|
+
-v
|
|
714
|
+
--strict-markers
|
|
715
|
+
--cov=app
|
|
716
|
+
--cov-report=html
|
|
717
|
+
--cov-report=term-missing
|
|
718
|
+
--cov-fail-under=80
|
|
719
|
+
|
|
720
|
+
markers =
|
|
721
|
+
slow: marks tests as slow
|
|
722
|
+
integration: marks tests as integration tests
|
|
723
|
+
e2e: marks tests as end-to-end tests
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
### Go
|
|
727
|
+
|
|
728
|
+
```bash
|
|
729
|
+
# Makefile o mise task
|
|
730
|
+
go test -coverprofile=coverage.out ./...
|
|
731
|
+
go tool cover -func=coverage.out
|
|
732
|
+
go tool cover -html=coverage.out -o coverage.html
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
---
|
|
736
|
+
|
|
737
|
+
## ๐ฏ Best Practices
|
|
738
|
+
|
|
739
|
+
### 1. Test Naming Conventions
|
|
740
|
+
|
|
741
|
+
```typescript
|
|
742
|
+
// โ Bad
|
|
743
|
+
test('test1', () => {});
|
|
744
|
+
|
|
745
|
+
// โ
Good
|
|
746
|
+
test('UserService.create rejects invalid email', () => {});
|
|
747
|
+
test('POST /users returns 201 for valid data', () => {});
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
### 2. Arrange-Act-Assert Pattern
|
|
751
|
+
|
|
752
|
+
```typescript
|
|
753
|
+
test('user can update profile', async () => {
|
|
754
|
+
// Arrange
|
|
755
|
+
const user = await UserFactory.create();
|
|
756
|
+
const token = await generateToken(user);
|
|
757
|
+
|
|
758
|
+
// Act
|
|
759
|
+
const res = await app.request('/api/profile', {
|
|
760
|
+
method: 'PATCH',
|
|
761
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
762
|
+
body: JSON.stringify({ name: 'New Name' }),
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// Assert
|
|
766
|
+
expect(res.status).toBe(200);
|
|
767
|
+
const updated = await res.json();
|
|
768
|
+
expect(updated.name).toBe('New Name');
|
|
769
|
+
});
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
### 3. Test Isolation
|
|
773
|
+
|
|
774
|
+
```typescript
|
|
775
|
+
// โ Bad: Tests depend on each other
|
|
776
|
+
let userId: string;
|
|
777
|
+
|
|
778
|
+
test('create user', async () => {
|
|
779
|
+
const res = await createUser();
|
|
780
|
+
userId = res.id; // Shared state!
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test('update user', async () => {
|
|
784
|
+
await updateUser(userId); // Depends on previous test
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// โ
Good: Each test is independent
|
|
788
|
+
test('can update user', async () => {
|
|
789
|
+
const user = await UserFactory.create(); // Fresh user
|
|
790
|
+
await updateUser(user.id);
|
|
791
|
+
});
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
### 4. Mock External Services
|
|
795
|
+
|
|
796
|
+
```typescript
|
|
797
|
+
// tests/mocks/email.mock.ts
|
|
798
|
+
export const emailService = {
|
|
799
|
+
send: vi.fn().mockResolvedValue({ success: true }),
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
// In test
|
|
803
|
+
test('sends welcome email on registration', async () => {
|
|
804
|
+
await registerUser({ email: 'test@example.com' });
|
|
805
|
+
|
|
806
|
+
expect(emailService.send).toHaveBeenCalledWith({
|
|
807
|
+
to: 'test@example.com',
|
|
808
|
+
template: 'welcome',
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
---
|
|
814
|
+
|
|
815
|
+
## ๐งช Testcontainers (Tests Reales sin Mocks)
|
|
816
|
+
|
|
817
|
+
### Filosofรญa: Tests Reales > Mocks Frรกgiles
|
|
818
|
+
|
|
819
|
+
**Problema con Mocks:**
|
|
820
|
+
```typescript
|
|
821
|
+
โ Frรกgiles - Se rompen al cambiar implementaciรณn
|
|
822
|
+
โ No prueban queries SQL reales
|
|
823
|
+
โ Mantenimiento costoso (mock de cada mรฉtodo)
|
|
824
|
+
โ Falsa sensaciรณn de seguridad
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
**Con Testcontainers:**
|
|
828
|
+
```typescript
|
|
829
|
+
โ
Prueban contra base de datos REAL
|
|
830
|
+
โ
Queries SQL ejecutados realmente
|
|
831
|
+
โ
Menos cรณdigo de test (no mockear)
|
|
832
|
+
โ
Mayor confianza en tests
|
|
833
|
+
โ Mรกs lentos (pero cacheables)
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
### Cuรกndo Usar Cada Enfoque
|
|
837
|
+
|
|
838
|
+
```
|
|
839
|
+
Unit Tests (70%):
|
|
840
|
+
โ
Lรณgica de negocio pura
|
|
841
|
+
โ
Funciones sin side effects
|
|
842
|
+
โ
Validaciones
|
|
843
|
+
โ NO necesitan DB ni mocks
|
|
844
|
+
|
|
845
|
+
Integration Tests (20%):
|
|
846
|
+
โ
Repository layer
|
|
847
|
+
โ
Queries complejas
|
|
848
|
+
โ
Transactions
|
|
849
|
+
โ Testcontainers
|
|
850
|
+
|
|
851
|
+
E2E Tests (10%):
|
|
852
|
+
โ
User flows completos
|
|
853
|
+
โ Playwright + Testcontainers
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
---
|
|
857
|
+
|
|
858
|
+
## TypeScript + Bun + Testcontainers
|
|
859
|
+
|
|
860
|
+
### Setup
|
|
861
|
+
|
|
862
|
+
```bash
|
|
863
|
+
# Instalar
|
|
864
|
+
bun add -d @testcontainers/postgresql
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
### Test Setup
|
|
868
|
+
|
|
869
|
+
```typescript
|
|
870
|
+
// tests/integration/setup.ts
|
|
871
|
+
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
|
|
872
|
+
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
873
|
+
import postgres from 'postgres';
|
|
874
|
+
import * as schema from '@/db/schema';
|
|
875
|
+
|
|
876
|
+
let container: StartedPostgreSqlContainer;
|
|
877
|
+
let db: ReturnType<typeof drizzle>;
|
|
878
|
+
|
|
879
|
+
export async function setupTestDB() {
|
|
880
|
+
// Iniciar container (reutilizable entre tests)
|
|
881
|
+
container = await new PostgreSqlContainer('postgres:16-alpine')
|
|
882
|
+
.withDatabase('testdb')
|
|
883
|
+
.withUsername('test')
|
|
884
|
+
.withPassword('test')
|
|
885
|
+
.withReuse() // โ IMPORTANTE: Reutilizar = mรกs rรกpido
|
|
886
|
+
.start();
|
|
887
|
+
|
|
888
|
+
// Conectar
|
|
889
|
+
const connectionString = container.getConnectionUri();
|
|
890
|
+
const client = postgres(connectionString);
|
|
891
|
+
db = drizzle(client, { schema });
|
|
892
|
+
|
|
893
|
+
// Aplicar migraciones
|
|
894
|
+
await runMigrations(db);
|
|
895
|
+
|
|
896
|
+
return { db, connectionString };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export async function teardownTestDB() {
|
|
900
|
+
await container.stop();
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export async function resetTestDB() {
|
|
904
|
+
// Limpiar todas las tablas entre tests
|
|
905
|
+
await db.delete(schema.users);
|
|
906
|
+
await db.delete(schema.tasks);
|
|
907
|
+
}
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
### Tests de Integraciรณn
|
|
911
|
+
|
|
912
|
+
```typescript
|
|
913
|
+
// tests/integration/user-repository.test.ts
|
|
914
|
+
import { test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
915
|
+
import { setupTestDB, teardownTestDB, resetTestDB } from './setup';
|
|
916
|
+
import { UserRepository } from '@/repositories/UserRepository';
|
|
917
|
+
|
|
918
|
+
let db: any;
|
|
919
|
+
let userRepo: UserRepository;
|
|
920
|
+
|
|
921
|
+
beforeAll(async () => {
|
|
922
|
+
const setup = await setupTestDB();
|
|
923
|
+
db = setup.db;
|
|
924
|
+
userRepo = new UserRepository(db);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
afterAll(async () => {
|
|
928
|
+
await teardownTestDB();
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
beforeEach(async () => {
|
|
932
|
+
await resetTestDB(); // Fresh DB para cada test
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test('UserRepository.create inserts into real database', async () => {
|
|
936
|
+
// Arrange
|
|
937
|
+
const userData = {
|
|
938
|
+
email: 'test@example.com',
|
|
939
|
+
name: 'Test User',
|
|
940
|
+
password: 'hashed_password',
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
// Act
|
|
944
|
+
const user = await userRepo.create(userData);
|
|
945
|
+
|
|
946
|
+
// Assert
|
|
947
|
+
expect(user.id).toBeDefined();
|
|
948
|
+
expect(user.email).toBe('test@example.com');
|
|
949
|
+
|
|
950
|
+
// Verificar en DB REAL (no mock)
|
|
951
|
+
const found = await userRepo.findById(user.id);
|
|
952
|
+
expect(found).toBeDefined();
|
|
953
|
+
expect(found!.name).toBe('Test User');
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test('complex query with joins works correctly', async () => {
|
|
957
|
+
// Arrange
|
|
958
|
+
const user = await userRepo.create({
|
|
959
|
+
email: 'test@example.com',
|
|
960
|
+
name: 'Test',
|
|
961
|
+
password: 'pass'
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
const task = await taskRepo.create({
|
|
965
|
+
userId: user.id,
|
|
966
|
+
title: 'Test Task',
|
|
967
|
+
completed: false,
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
// Act - Query complejo con JOIN
|
|
971
|
+
const userWithTasks = await userRepo.findWithTasks(user.id);
|
|
972
|
+
|
|
973
|
+
// Assert - Verifica query SQL real
|
|
974
|
+
expect(userWithTasks.tasks).toHaveLength(1);
|
|
975
|
+
expect(userWithTasks.tasks[0].title).toBe('Test Task');
|
|
976
|
+
});
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
---
|
|
980
|
+
|
|
981
|
+
## Python + pytest + Testcontainers
|
|
982
|
+
|
|
983
|
+
### Setup
|
|
984
|
+
|
|
985
|
+
```bash
|
|
986
|
+
# Instalar
|
|
987
|
+
uv add --dev testcontainers
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
### Test Setup
|
|
991
|
+
|
|
992
|
+
```python
|
|
993
|
+
# tests/integration/conftest.py
|
|
994
|
+
import pytest
|
|
995
|
+
from testcontainers.postgres import PostgresContainer
|
|
996
|
+
from sqlalchemy import create_engine
|
|
997
|
+
from sqlalchemy.orm import sessionmaker
|
|
998
|
+
from app.models import Base
|
|
999
|
+
|
|
1000
|
+
@pytest.fixture(scope="session")
|
|
1001
|
+
def postgres_container():
|
|
1002
|
+
"""PostgreSQL container (reutilizado en toda la sesiรณn)"""
|
|
1003
|
+
with PostgresContainer("postgres:16-alpine") as postgres:
|
|
1004
|
+
yield postgres
|
|
1005
|
+
|
|
1006
|
+
@pytest.fixture(scope="session")
|
|
1007
|
+
def engine(postgres_container):
|
|
1008
|
+
"""SQLAlchemy engine"""
|
|
1009
|
+
engine = create_engine(postgres_container.get_connection_url())
|
|
1010
|
+
Base.metadata.create_all(engine)
|
|
1011
|
+
return engine
|
|
1012
|
+
|
|
1013
|
+
@pytest.fixture(scope="function")
|
|
1014
|
+
def db_session(engine):
|
|
1015
|
+
"""Fresh database session para cada test"""
|
|
1016
|
+
Session = sessionmaker(bind=engine)
|
|
1017
|
+
session = Session()
|
|
1018
|
+
|
|
1019
|
+
try:
|
|
1020
|
+
yield session
|
|
1021
|
+
finally:
|
|
1022
|
+
session.rollback()
|
|
1023
|
+
session.close()
|
|
1024
|
+
|
|
1025
|
+
# Limpiar tablas
|
|
1026
|
+
for table in reversed(Base.metadata.sorted_tables):
|
|
1027
|
+
engine.execute(table.delete())
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
### Tests de Integraciรณn
|
|
1031
|
+
|
|
1032
|
+
```python
|
|
1033
|
+
# tests/integration/test_user_repository.py
|
|
1034
|
+
import pytest
|
|
1035
|
+
from app.repositories.user_repository import UserRepository
|
|
1036
|
+
|
|
1037
|
+
class TestUserRepository:
|
|
1038
|
+
def test_create_inserts_into_real_database(self, db_session):
|
|
1039
|
+
# Arrange
|
|
1040
|
+
repo = UserRepository(db_session)
|
|
1041
|
+
user_data = {
|
|
1042
|
+
"email": "test@example.com",
|
|
1043
|
+
"name": "Test User",
|
|
1044
|
+
"password": "hashed"
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
# Act
|
|
1048
|
+
user = repo.create(**user_data)
|
|
1049
|
+
db_session.commit()
|
|
1050
|
+
|
|
1051
|
+
# Assert
|
|
1052
|
+
assert user.id is not None
|
|
1053
|
+
|
|
1054
|
+
# Verificar en DB real
|
|
1055
|
+
found = repo.find_by_id(user.id)
|
|
1056
|
+
assert found.email == "test@example.com"
|
|
1057
|
+
|
|
1058
|
+
def test_complex_query_with_joins(self, db_session):
|
|
1059
|
+
repo = UserRepository(db_session)
|
|
1060
|
+
task_repo = TaskRepository(db_session)
|
|
1061
|
+
|
|
1062
|
+
# Create user y tasks
|
|
1063
|
+
user = repo.create(email="test@example.com", name="Test", password="pass")
|
|
1064
|
+
task = task_repo.create(user_id=user.id, title="Task 1")
|
|
1065
|
+
db_session.commit()
|
|
1066
|
+
|
|
1067
|
+
# Query con JOIN
|
|
1068
|
+
user_with_tasks = repo.find_with_tasks(user.id)
|
|
1069
|
+
|
|
1070
|
+
# Assert SQL real ejecutado
|
|
1071
|
+
assert len(user_with_tasks.tasks) == 1
|
|
1072
|
+
assert user_with_tasks.tasks[0].title == "Task 1"
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
---
|
|
1076
|
+
|
|
1077
|
+
## Go + testcontainers-go
|
|
1078
|
+
|
|
1079
|
+
### Setup
|
|
1080
|
+
|
|
1081
|
+
```bash
|
|
1082
|
+
go get github.com/testcontainers/testcontainers-go
|
|
1083
|
+
go get github.com/testcontainers/testcontainers-go/modules/postgres
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
### Test Setup
|
|
1087
|
+
|
|
1088
|
+
```go
|
|
1089
|
+
// internal/repository/setup_test.go
|
|
1090
|
+
package repository
|
|
1091
|
+
|
|
1092
|
+
import (
|
|
1093
|
+
"context"
|
|
1094
|
+
"database/sql"
|
|
1095
|
+
"testing"
|
|
1096
|
+
|
|
1097
|
+
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
var testDB *sql.DB
|
|
1101
|
+
|
|
1102
|
+
func setupTestDB(t *testing.T) *sql.DB {
|
|
1103
|
+
ctx := context.Background()
|
|
1104
|
+
|
|
1105
|
+
// Start container
|
|
1106
|
+
pgContainer, err := postgres.RunContainer(ctx,
|
|
1107
|
+
testcontainers.WithImage("postgres:16-alpine"),
|
|
1108
|
+
postgres.WithDatabase("testdb"),
|
|
1109
|
+
postgres.WithUsername("test"),
|
|
1110
|
+
postgres.WithPassword("test"),
|
|
1111
|
+
)
|
|
1112
|
+
if err != nil {
|
|
1113
|
+
t.Fatal(err)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
t.Cleanup(func() {
|
|
1117
|
+
pgContainer.Terminate(ctx)
|
|
1118
|
+
})
|
|
1119
|
+
|
|
1120
|
+
// Connect
|
|
1121
|
+
connStr, _ := pgContainer.ConnectionString(ctx, "sslmode=disable")
|
|
1122
|
+
db, err := sql.Open("postgres", connStr)
|
|
1123
|
+
if err != nil {
|
|
1124
|
+
t.Fatal(err)
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Run migrations
|
|
1128
|
+
runMigrations(db)
|
|
1129
|
+
|
|
1130
|
+
return db
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
func resetDB(t *testing.T, db *sql.DB) {
|
|
1134
|
+
_, err := db.Exec("TRUNCATE users, tasks CASCADE")
|
|
1135
|
+
if err != nil {
|
|
1136
|
+
t.Fatal(err)
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
### Tests
|
|
1142
|
+
|
|
1143
|
+
```go
|
|
1144
|
+
// internal/repository/user_repository_test.go
|
|
1145
|
+
func TestUserRepository_Create(t *testing.T) {
|
|
1146
|
+
db := setupTestDB(t)
|
|
1147
|
+
defer db.Close()
|
|
1148
|
+
resetDB(t, db)
|
|
1149
|
+
|
|
1150
|
+
repo := NewUserRepository(db)
|
|
1151
|
+
|
|
1152
|
+
// Arrange
|
|
1153
|
+
user := &User{
|
|
1154
|
+
Email: "test@example.com",
|
|
1155
|
+
Name: "Test User",
|
|
1156
|
+
Password: "hashed",
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Act
|
|
1160
|
+
err := repo.Create(context.Background(), user)
|
|
1161
|
+
|
|
1162
|
+
// Assert
|
|
1163
|
+
assert.NoError(t, err)
|
|
1164
|
+
assert.NotZero(t, user.ID)
|
|
1165
|
+
|
|
1166
|
+
// Verify en DB real
|
|
1167
|
+
found, err := repo.FindByID(context.Background(), user.ID)
|
|
1168
|
+
require.NoError(t, err)
|
|
1169
|
+
assert.Equal(t, "test@example.com", found.Email)
|
|
1170
|
+
}
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
---
|
|
1174
|
+
|
|
1175
|
+
## Performance Tips
|
|
1176
|
+
|
|
1177
|
+
### 1. Reutilizar Containers
|
|
1178
|
+
|
|
1179
|
+
```typescript
|
|
1180
|
+
// โ Lento (inicia container por cada test suite)
|
|
1181
|
+
const container = await new PostgreSqlContainer().start();
|
|
1182
|
+
|
|
1183
|
+
// โ
Rรกpido (reutiliza container)
|
|
1184
|
+
const container = await new PostgreSqlContainer()
|
|
1185
|
+
.withReuse() // โ IMPORTANTE
|
|
1186
|
+
.start();
|
|
1187
|
+
|
|
1188
|
+
// Mejora: 5-10s โ 1-2s por suite
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
### 2. Usar tmpfs para Datos
|
|
1192
|
+
|
|
1193
|
+
```yaml
|
|
1194
|
+
# docker-compose.test.yml
|
|
1195
|
+
services:
|
|
1196
|
+
test-db:
|
|
1197
|
+
image: postgres:16-alpine
|
|
1198
|
+
tmpfs:
|
|
1199
|
+
- /var/lib/postgresql/data # RAM disk = mรกs rรกpido
|
|
1200
|
+
|
|
1201
|
+
# Mejora: 30-50% mรกs rรกpido
|
|
1202
|
+
```
|
|
1203
|
+
|
|
1204
|
+
### 3. Parallel Tests con Mรบltiples Containers
|
|
1205
|
+
|
|
1206
|
+
```typescript
|
|
1207
|
+
// Cada worker de test obtiene su propio container
|
|
1208
|
+
test.concurrent('test 1', async () => {
|
|
1209
|
+
const container = await getOrCreateContainer();
|
|
1210
|
+
// ...
|
|
1211
|
+
});
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
---
|
|
1215
|
+
|
|
1216
|
+
## Mise Tasks para Testcontainers
|
|
1217
|
+
|
|
1218
|
+
```toml
|
|
1219
|
+
# .mise.toml
|
|
1220
|
+
|
|
1221
|
+
[tasks."test:integration"]
|
|
1222
|
+
description = "Run integration tests with Testcontainers"
|
|
1223
|
+
run = """
|
|
1224
|
+
#!/usr/bin/env bash
|
|
1225
|
+
|
|
1226
|
+
# Verificar Docker
|
|
1227
|
+
if ! docker info > /dev/null 2>&1; then
|
|
1228
|
+
echo "โ Docker no estรก corriendo"
|
|
1229
|
+
echo "Inicia Docker y vuelve a intentar"
|
|
1230
|
+
exit 1
|
|
1231
|
+
fi
|
|
1232
|
+
|
|
1233
|
+
echo "๐ณ Starting Testcontainers..."
|
|
1234
|
+
|
|
1235
|
+
if [ -f "package.json" ]; then
|
|
1236
|
+
bun test tests/integration/
|
|
1237
|
+
elif [ -f "pyproject.toml" ]; then
|
|
1238
|
+
pytest tests/integration/ -v
|
|
1239
|
+
elif [ -f "go.mod" ]; then
|
|
1240
|
+
go test ./internal/... -tags=integration
|
|
1241
|
+
fi
|
|
1242
|
+
|
|
1243
|
+
echo "โ
Integration tests completed"
|
|
1244
|
+
"""
|
|
1245
|
+
|
|
1246
|
+
[tasks."test:integration:watch"]
|
|
1247
|
+
description = "Watch mode for integration tests"
|
|
1248
|
+
run = """
|
|
1249
|
+
if [ -f "package.json" ]; then
|
|
1250
|
+
bun test --watch tests/integration/
|
|
1251
|
+
elif [ -f "pyproject.toml" ]; then
|
|
1252
|
+
ptw tests/integration/
|
|
1253
|
+
fi
|
|
1254
|
+
"""
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
---
|
|
1258
|
+
|
|
1259
|
+
## Comparaciรณn: Mocks vs Testcontainers
|
|
1260
|
+
|
|
1261
|
+
| Aspecto | Mocks | Testcontainers |
|
|
1262
|
+
|---------|-------|----------------|
|
|
1263
|
+
| **Velocidad** | โก 1-2ms | ๐ข 100-500ms (con cache: 10-50ms) |
|
|
1264
|
+
| **Confianza** | ๐ก Media | โ
Alta |
|
|
1265
|
+
| **Mantenimiento** | โ Alto | โ
Bajo |
|
|
1266
|
+
| **Queries reales** | โ No | โ
Sรญ |
|
|
1267
|
+
| **Setup** | ๐ก Medio | โ
Simple |
|
|
1268
|
+
| **CI/CD** | โ
Rรกpido | ๐ก Necesita Docker |
|
|
1269
|
+
|
|
1270
|
+
### Recomendaciรณn
|
|
1271
|
+
|
|
1272
|
+
```
|
|
1273
|
+
Unit tests (70%): Sin DB, sin mocks
|
|
1274
|
+
Integration tests (20%): Testcontainers
|
|
1275
|
+
E2E tests (10%): Testcontainers + Playwright
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
---
|
|
1279
|
+
|
|
1280
|
+
## ๐งช Testcontainers - Tests Reales Sin Mocks
|
|
1281
|
+
|
|
1282
|
+
### Filosofรญa: Tests Reales > Mocks Frรกgiles
|
|
1283
|
+
|
|
1284
|
+
**Problema con Mocks:**
|
|
1285
|
+
```typescript
|
|
1286
|
+
โ Frรกgiles - Se rompen con cambios de implementaciรณn
|
|
1287
|
+
โ No prueban queries SQL reales
|
|
1288
|
+
โ Mantenimiento costoso (mock hell)
|
|
1289
|
+
โ Falsa sensaciรณn de seguridad
|
|
1290
|
+
โ Diferencias entre mock y DB real
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
**Con Testcontainers:**
|
|
1294
|
+
```typescript
|
|
1295
|
+
โ
Prueban contra DB real en Docker
|
|
1296
|
+
โ
Queries SQL reales ejecutadas
|
|
1297
|
+
โ
Menos mantenimiento a largo plazo
|
|
1298
|
+
โ
Mayor confianza en producciรณn
|
|
1299
|
+
โ
Detecta problemas de performance
|
|
1300
|
+
โ ๏ธ Mรกs lentos (pero con cache ~2 segundos)
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
### Cuรกndo Usar Cada Estrategia
|
|
1304
|
+
|
|
1305
|
+
```
|
|
1306
|
+
Unit Tests (70%):
|
|
1307
|
+
โ
Lรณgica de negocio pura
|
|
1308
|
+
โ
Funciones sin side effects
|
|
1309
|
+
โ
Validaciones
|
|
1310
|
+
โ NO mocks, NO DB, SOLO lรณgica
|
|
1311
|
+
|
|
1312
|
+
Integration Tests (20%):
|
|
1313
|
+
โ
Repository layer
|
|
1314
|
+
โ
Database queries
|
|
1315
|
+
โ
API endpoints completos
|
|
1316
|
+
โ TESTCONTAINERS (DB real)
|
|
1317
|
+
|
|
1318
|
+
E2E Tests (10%):
|
|
1319
|
+
โ
User flows completos
|
|
1320
|
+
โ
UI + API + DB
|
|
1321
|
+
โ Playwright + Testcontainers
|
|
1322
|
+
```
|
|
1323
|
+
|
|
1324
|
+
---
|
|
1325
|
+
|
|
1326
|
+
## TypeScript + Bun + PostgreSQL + Testcontainers
|
|
1327
|
+
|
|
1328
|
+
### Instalaciรณn
|
|
1329
|
+
|
|
1330
|
+
```bash
|
|
1331
|
+
bun add -d @testcontainers/postgresql testcontainers
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
### Setup de Tests Integrados
|
|
1335
|
+
|
|
1336
|
+
```typescript
|
|
1337
|
+
// tests/integration/setup.ts
|
|
1338
|
+
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
|
|
1339
|
+
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
1340
|
+
import postgres from 'postgres';
|
|
1341
|
+
import * as schema from '@/db/schema';
|
|
1342
|
+
|
|
1343
|
+
let container: StartedPostgreSqlContainer;
|
|
1344
|
+
let db: ReturnType<typeof drizzle>;
|
|
1345
|
+
|
|
1346
|
+
export async function setupTestDB() {
|
|
1347
|
+
console.log('๐ณ Starting PostgreSQL container...');
|
|
1348
|
+
|
|
1349
|
+
// Iniciar container (con reuse para speed)
|
|
1350
|
+
container = await new PostgreSqlContainer('postgres:16-alpine')
|
|
1351
|
+
.withDatabase('testdb')
|
|
1352
|
+
.withUsername('test')
|
|
1353
|
+
.withPassword('test')
|
|
1354
|
+
.withReuse() // โ IMPORTANTE: Reutiliza container entre test suites
|
|
1355
|
+
.start();
|
|
1356
|
+
|
|
1357
|
+
console.log('โ
PostgreSQL container started');
|
|
1358
|
+
|
|
1359
|
+
// Conectar a la DB
|
|
1360
|
+
const connectionString = container.getConnectionUri();
|
|
1361
|
+
const client = postgres(connectionString);
|
|
1362
|
+
db = drizzle(client, { schema });
|
|
1363
|
+
|
|
1364
|
+
// Aplicar migraciones
|
|
1365
|
+
console.log('๐ Applying migrations...');
|
|
1366
|
+
await runMigrations(db);
|
|
1367
|
+
console.log('โ
Migrations applied');
|
|
1368
|
+
|
|
1369
|
+
return { db, connectionString };
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
export async function teardownTestDB() {
|
|
1373
|
+
console.log('๐ Stopping PostgreSQL container...');
|
|
1374
|
+
await container.stop();
|
|
1375
|
+
console.log('โ
Container stopped');
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
export async function resetTestDB() {
|
|
1379
|
+
// Limpiar todas las tablas para cada test
|
|
1380
|
+
await db.delete(schema.users);
|
|
1381
|
+
await db.delete(schema.tasks);
|
|
1382
|
+
await db.delete(schema.sessions);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
async function runMigrations(db: any) {
|
|
1386
|
+
// Aplicar migraciones desde carpeta migrations/
|
|
1387
|
+
const { migrate } = await import('drizzle-orm/postgres-js/migrator');
|
|
1388
|
+
await migrate(db, { migrationsFolder: './migrations' });
|
|
1389
|
+
}
|
|
1390
|
+
```
|
|
1391
|
+
|
|
1392
|
+
### Ejemplo de Test con DB Real
|
|
1393
|
+
|
|
1394
|
+
```typescript
|
|
1395
|
+
// tests/integration/user-repository.test.ts
|
|
1396
|
+
import { test, expect, beforeAll, afterAll, beforeEach, describe } from 'bun:test';
|
|
1397
|
+
import { setupTestDB, teardownTestDB, resetTestDB } from './setup';
|
|
1398
|
+
import { UserRepository } from '@/repositories/UserRepository';
|
|
1399
|
+
|
|
1400
|
+
let db: any;
|
|
1401
|
+
let userRepo: UserRepository;
|
|
1402
|
+
|
|
1403
|
+
beforeAll(async () => {
|
|
1404
|
+
const setup = await setupTestDB();
|
|
1405
|
+
db = setup.db;
|
|
1406
|
+
userRepo = new UserRepository(db);
|
|
1407
|
+
}, 30000); // Timeout mรกs alto para container startup
|
|
1408
|
+
|
|
1409
|
+
afterAll(async () => {
|
|
1410
|
+
await teardownTestDB();
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
beforeEach(async () => {
|
|
1414
|
+
await resetTestDB(); // Fresh DB para cada test
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
describe('UserRepository', () => {
|
|
1418
|
+
test('create inserts user into database', async () => {
|
|
1419
|
+
// Arrange
|
|
1420
|
+
const userData = {
|
|
1421
|
+
email: 'test@example.com',
|
|
1422
|
+
name: 'Test User',
|
|
1423
|
+
password: 'hashed_password_here',
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1426
|
+
// Act
|
|
1427
|
+
const user = await userRepo.create(userData);
|
|
1428
|
+
|
|
1429
|
+
// Assert
|
|
1430
|
+
expect(user.id).toBeDefined();
|
|
1431
|
+
expect(user.email).toBe('test@example.com');
|
|
1432
|
+
expect(user.name).toBe('Test User');
|
|
1433
|
+
|
|
1434
|
+
// Verify en DB REAL (no mock!)
|
|
1435
|
+
const found = await userRepo.findById(user.id);
|
|
1436
|
+
expect(found).toBeDefined();
|
|
1437
|
+
expect(found!.email).toBe('test@example.com');
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
test('findByEmail returns null for non-existent user', async () => {
|
|
1441
|
+
const user = await userRepo.findByEmail('notfound@example.com');
|
|
1442
|
+
expect(user).toBeNull();
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
test('findByEmail returns user when exists', async () => {
|
|
1446
|
+
// Arrange - Crear usuario primero
|
|
1447
|
+
await userRepo.create({
|
|
1448
|
+
email: 'exists@example.com',
|
|
1449
|
+
name: 'Exists',
|
|
1450
|
+
password: 'hashed',
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
// Act
|
|
1454
|
+
const found = await userRepo.findByEmail('exists@example.com');
|
|
1455
|
+
|
|
1456
|
+
// Assert
|
|
1457
|
+
expect(found).toBeDefined();
|
|
1458
|
+
expect(found!.name).toBe('Exists');
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
test('update modifies user data', async () => {
|
|
1462
|
+
// Arrange
|
|
1463
|
+
const user = await userRepo.create({
|
|
1464
|
+
email: 'test@example.com',
|
|
1465
|
+
name: 'Original Name',
|
|
1466
|
+
password: 'pass',
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
// Act
|
|
1470
|
+
const updated = await userRepo.update(user.id, {
|
|
1471
|
+
name: 'Updated Name',
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
// Assert
|
|
1475
|
+
expect(updated.name).toBe('Updated Name');
|
|
1476
|
+
expect(updated.email).toBe('test@example.com'); // No cambiรณ
|
|
1477
|
+
|
|
1478
|
+
// Verify en DB
|
|
1479
|
+
const verified = await userRepo.findById(user.id);
|
|
1480
|
+
expect(verified!.name).toBe('Updated Name');
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
test('delete removes user from database', async () => {
|
|
1484
|
+
// Arrange
|
|
1485
|
+
const user = await userRepo.create({
|
|
1486
|
+
email: 'delete@example.com',
|
|
1487
|
+
name: 'To Delete',
|
|
1488
|
+
password: 'pass',
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
// Act
|
|
1492
|
+
await userRepo.delete(user.id);
|
|
1493
|
+
|
|
1494
|
+
// Assert
|
|
1495
|
+
const found = await userRepo.findById(user.id);
|
|
1496
|
+
expect(found).toBeNull();
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
test('query performance is acceptable', async () => {
|
|
1500
|
+
// Arrange - Crear 100 usuarios
|
|
1501
|
+
const users = await Promise.all(
|
|
1502
|
+
Array.from({ length: 100 }, (_, i) =>
|
|
1503
|
+
userRepo.create({
|
|
1504
|
+
email: `user${i}@example.com`,
|
|
1505
|
+
name: `User ${i}`,
|
|
1506
|
+
password: 'pass',
|
|
1507
|
+
})
|
|
1508
|
+
)
|
|
1509
|
+
);
|
|
1510
|
+
|
|
1511
|
+
// Act - Buscar todos
|
|
1512
|
+
const start = Date.now();
|
|
1513
|
+
const allUsers = await userRepo.findAll();
|
|
1514
|
+
const duration = Date.now() - start;
|
|
1515
|
+
|
|
1516
|
+
// Assert
|
|
1517
|
+
expect(allUsers.length).toBe(100);
|
|
1518
|
+
expect(duration).toBeLessThan(100); // Menos de 100ms
|
|
1519
|
+
});
|
|
1520
|
+
});
|
|
1521
|
+
```
|
|
1522
|
+
|
|
1523
|
+
---
|
|
1524
|
+
|
|
1525
|
+
## Python + pytest + PostgreSQL + Testcontainers
|
|
1526
|
+
|
|
1527
|
+
### Instalaciรณn
|
|
1528
|
+
|
|
1529
|
+
```bash
|
|
1530
|
+
uv add --dev testcontainers pytest-asyncio
|
|
1531
|
+
```
|
|
1532
|
+
|
|
1533
|
+
### Setup
|
|
1534
|
+
|
|
1535
|
+
```python
|
|
1536
|
+
# tests/integration/conftest.py
|
|
1537
|
+
import pytest
|
|
1538
|
+
from testcontainers.postgres import PostgresContainer
|
|
1539
|
+
from sqlalchemy import create_engine
|
|
1540
|
+
from sqlalchemy.orm import sessionmaker
|
|
1541
|
+
from app.models import Base
|
|
1542
|
+
|
|
1543
|
+
@pytest.fixture(scope="session")
|
|
1544
|
+
def postgres_container():
|
|
1545
|
+
"""
|
|
1546
|
+
Start PostgreSQL container (reused across all tests in session)
|
|
1547
|
+
"""
|
|
1548
|
+
with PostgresContainer("postgres:16-alpine") as postgres:
|
|
1549
|
+
yield postgres
|
|
1550
|
+
|
|
1551
|
+
@pytest.fixture(scope="session")
|
|
1552
|
+
def engine(postgres_container):
|
|
1553
|
+
"""
|
|
1554
|
+
Create SQLAlchemy engine
|
|
1555
|
+
"""
|
|
1556
|
+
engine = create_engine(postgres_container.get_connection_url())
|
|
1557
|
+
|
|
1558
|
+
# Create all tables
|
|
1559
|
+
Base.metadata.create_all(engine)
|
|
1560
|
+
|
|
1561
|
+
return engine
|
|
1562
|
+
|
|
1563
|
+
@pytest.fixture(scope="function")
|
|
1564
|
+
def db_session(engine):
|
|
1565
|
+
"""
|
|
1566
|
+
Fresh database session for each test
|
|
1567
|
+
"""
|
|
1568
|
+
Session = sessionmaker(bind=engine)
|
|
1569
|
+
session = Session()
|
|
1570
|
+
|
|
1571
|
+
try:
|
|
1572
|
+
yield session
|
|
1573
|
+
finally:
|
|
1574
|
+
session.rollback()
|
|
1575
|
+
session.close()
|
|
1576
|
+
|
|
1577
|
+
# Limpiar todas las tablas para next test
|
|
1578
|
+
for table in reversed(Base.metadata.sorted_tables):
|
|
1579
|
+
engine.execute(table.delete())
|
|
1580
|
+
```
|
|
1581
|
+
|
|
1582
|
+
### Ejemplo de Test
|
|
1583
|
+
|
|
1584
|
+
```python
|
|
1585
|
+
# tests/integration/test_user_repository.py
|
|
1586
|
+
import pytest
|
|
1587
|
+
from app.repositories.user_repository import UserRepository
|
|
1588
|
+
from app.models.user import User
|
|
1589
|
+
|
|
1590
|
+
class TestUserRepository:
|
|
1591
|
+
"""Integration tests para UserRepository con DB real"""
|
|
1592
|
+
|
|
1593
|
+
def test_create_inserts_user_into_database(self, db_session):
|
|
1594
|
+
# Arrange
|
|
1595
|
+
repo = UserRepository(db_session)
|
|
1596
|
+
user_data = {
|
|
1597
|
+
"email": "test@example.com",
|
|
1598
|
+
"name": "Test User",
|
|
1599
|
+
"password": "hashed_password"
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
# Act
|
|
1603
|
+
user = repo.create(**user_data)
|
|
1604
|
+
db_session.commit()
|
|
1605
|
+
|
|
1606
|
+
# Assert
|
|
1607
|
+
assert user.id is not None
|
|
1608
|
+
assert user.email == "test@example.com"
|
|
1609
|
+
|
|
1610
|
+
# Verify en DB REAL
|
|
1611
|
+
found = repo.find_by_id(user.id)
|
|
1612
|
+
assert found is not None
|
|
1613
|
+
assert found.name == "Test User"
|
|
1614
|
+
|
|
1615
|
+
def test_find_by_email_returns_none_for_nonexistent(self, db_session):
|
|
1616
|
+
repo = UserRepository(db_session)
|
|
1617
|
+
user = repo.find_by_email("notfound@example.com")
|
|
1618
|
+
assert user is None
|
|
1619
|
+
|
|
1620
|
+
def test_find_by_email_returns_user_when_exists(self, db_session):
|
|
1621
|
+
# Arrange
|
|
1622
|
+
repo = UserRepository(db_session)
|
|
1623
|
+
created = repo.create(
|
|
1624
|
+
email="exists@example.com",
|
|
1625
|
+
name="Exists",
|
|
1626
|
+
password="hashed"
|
|
1627
|
+
)
|
|
1628
|
+
db_session.commit()
|
|
1629
|
+
|
|
1630
|
+
# Act
|
|
1631
|
+
found = repo.find_by_email("exists@example.com")
|
|
1632
|
+
|
|
1633
|
+
# Assert
|
|
1634
|
+
assert found is not None
|
|
1635
|
+
assert found.id == created.id
|
|
1636
|
+
assert found.name == "Exists"
|
|
1637
|
+
|
|
1638
|
+
def test_update_modifies_user_data(self, db_session):
|
|
1639
|
+
# Arrange
|
|
1640
|
+
repo = UserRepository(db_session)
|
|
1641
|
+
user = repo.create(
|
|
1642
|
+
email="test@example.com",
|
|
1643
|
+
name="Original",
|
|
1644
|
+
password="pass"
|
|
1645
|
+
)
|
|
1646
|
+
db_session.commit()
|
|
1647
|
+
|
|
1648
|
+
# Act
|
|
1649
|
+
updated = repo.update(user.id, name="Updated")
|
|
1650
|
+
db_session.commit()
|
|
1651
|
+
|
|
1652
|
+
# Assert
|
|
1653
|
+
assert updated.name == "Updated"
|
|
1654
|
+
assert updated.email == "test@example.com"
|
|
1655
|
+
|
|
1656
|
+
@pytest.mark.parametrize("count", [10, 50, 100])
|
|
1657
|
+
def test_bulk_operations_performance(self, db_session, count):
|
|
1658
|
+
"""Test performance con diferentes volรบmenes"""
|
|
1659
|
+
import time
|
|
1660
|
+
|
|
1661
|
+
repo = UserRepository(db_session)
|
|
1662
|
+
|
|
1663
|
+
# Arrange - Crear mรบltiples usuarios
|
|
1664
|
+
start = time.time()
|
|
1665
|
+
users = [
|
|
1666
|
+
repo.create(
|
|
1667
|
+
email=f"user{i}@example.com",
|
|
1668
|
+
name=f"User {i}",
|
|
1669
|
+
password="pass"
|
|
1670
|
+
)
|
|
1671
|
+
for i in range(count)
|
|
1672
|
+
]
|
|
1673
|
+
db_session.commit()
|
|
1674
|
+
duration = time.time() - start
|
|
1675
|
+
|
|
1676
|
+
# Assert
|
|
1677
|
+
assert len(users) == count
|
|
1678
|
+
assert duration < (count * 0.01) # < 10ms por usuario
|
|
1679
|
+
```
|
|
1680
|
+
|
|
1681
|
+
---
|
|
1682
|
+
|
|
1683
|
+
## Go + testcontainers-go
|
|
1684
|
+
|
|
1685
|
+
### Instalaciรณn
|
|
1686
|
+
|
|
1687
|
+
```bash
|
|
1688
|
+
go get github.com/testcontainers/testcontainers-go
|
|
1689
|
+
go get github.com/testcontainers/testcontainers-go/modules/postgres
|
|
1690
|
+
```
|
|
1691
|
+
|
|
1692
|
+
### Setup
|
|
1693
|
+
|
|
1694
|
+
```go
|
|
1695
|
+
// internal/repository/repository_test.go
|
|
1696
|
+
package repository
|
|
1697
|
+
|
|
1698
|
+
import (
|
|
1699
|
+
"context"
|
|
1700
|
+
"database/sql"
|
|
1701
|
+
"testing"
|
|
1702
|
+
"time"
|
|
1703
|
+
|
|
1704
|
+
"github.com/stretchr/testify/assert"
|
|
1705
|
+
"github.com/stretchr/testify/require"
|
|
1706
|
+
"github.com/testcontainers/testcontainers-go"
|
|
1707
|
+
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
1708
|
+
"github.com/testcontainers/testcontainers-go/wait"
|
|
1709
|
+
)
|
|
1710
|
+
|
|
1711
|
+
var (
|
|
1712
|
+
testDB *sql.DB
|
|
1713
|
+
repo *UserRepository
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
func TestMain(m *testing.M) {
|
|
1717
|
+
ctx := context.Background()
|
|
1718
|
+
|
|
1719
|
+
// Start PostgreSQL container
|
|
1720
|
+
pgContainer, err := postgres.RunContainer(ctx,
|
|
1721
|
+
testcontainers.WithImage("postgres:16-alpine"),
|
|
1722
|
+
postgres.WithDatabase("testdb"),
|
|
1723
|
+
postgres.WithUsername("test"),
|
|
1724
|
+
postgres.WithPassword("test"),
|
|
1725
|
+
testcontainers.WithWaitStrategy(
|
|
1726
|
+
wait.ForLog("database system is ready to accept connections").
|
|
1727
|
+
WithOccurrence(2).
|
|
1728
|
+
WithStartupTimeout(5*time.Second),
|
|
1729
|
+
),
|
|
1730
|
+
)
|
|
1731
|
+
if err != nil {
|
|
1732
|
+
panic(err)
|
|
1733
|
+
}
|
|
1734
|
+
defer pgContainer.Terminate(ctx)
|
|
1735
|
+
|
|
1736
|
+
// Connect to database
|
|
1737
|
+
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
|
|
1738
|
+
if err != nil {
|
|
1739
|
+
panic(err)
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
testDB, err = sql.Open("postgres", connStr)
|
|
1743
|
+
if err != nil {
|
|
1744
|
+
panic(err)
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Run migrations
|
|
1748
|
+
if err := runMigrations(testDB); err != nil {
|
|
1749
|
+
panic(err)
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// Initialize repository
|
|
1753
|
+
repo = NewUserRepository(testDB)
|
|
1754
|
+
|
|
1755
|
+
// Run tests
|
|
1756
|
+
code := m.Run()
|
|
1757
|
+
|
|
1758
|
+
// Cleanup
|
|
1759
|
+
testDB.Close()
|
|
1760
|
+
os.Exit(code)
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
func resetDB(t *testing.T) {
|
|
1764
|
+
_, err := testDB.Exec("TRUNCATE users, tasks CASCADE")
|
|
1765
|
+
require.NoError(t, err)
|
|
1766
|
+
}
|
|
1767
|
+
```
|
|
1768
|
+
|
|
1769
|
+
### Ejemplo de Test
|
|
1770
|
+
|
|
1771
|
+
```go
|
|
1772
|
+
// internal/repository/user_repository_test.go
|
|
1773
|
+
package repository
|
|
1774
|
+
|
|
1775
|
+
import (
|
|
1776
|
+
"context"
|
|
1777
|
+
"testing"
|
|
1778
|
+
|
|
1779
|
+
"github.com/stretchr/testify/assert"
|
|
1780
|
+
"github.com/stretchr/testify/require"
|
|
1781
|
+
)
|
|
1782
|
+
|
|
1783
|
+
func TestUserRepository_Create(t *testing.T) {
|
|
1784
|
+
resetDB(t)
|
|
1785
|
+
ctx := context.Background()
|
|
1786
|
+
|
|
1787
|
+
// Arrange
|
|
1788
|
+
user := &User{
|
|
1789
|
+
Email: "test@example.com",
|
|
1790
|
+
Name: "Test User",
|
|
1791
|
+
Password: "hashed_password",
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// Act
|
|
1795
|
+
err := repo.Create(ctx, user)
|
|
1796
|
+
|
|
1797
|
+
// Assert
|
|
1798
|
+
assert.NoError(t, err)
|
|
1799
|
+
assert.NotZero(t, user.ID)
|
|
1800
|
+
|
|
1801
|
+
// Verify en DB REAL
|
|
1802
|
+
found, err := repo.FindByID(ctx, user.ID)
|
|
1803
|
+
require.NoError(t, err)
|
|
1804
|
+
assert.Equal(t, "test@example.com", found.Email)
|
|
1805
|
+
assert.Equal(t, "Test User", found.Name)
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
func TestUserRepository_FindByEmail(t *testing.T) {
|
|
1809
|
+
resetDB(t)
|
|
1810
|
+
ctx := context.Background()
|
|
1811
|
+
|
|
1812
|
+
t.Run("returns nil for non-existent user", func(t *testing.T) {
|
|
1813
|
+
user, err := repo.FindByEmail(ctx, "notfound@example.com")
|
|
1814
|
+
assert.NoError(t, err)
|
|
1815
|
+
assert.Nil(t, user)
|
|
1816
|
+
})
|
|
1817
|
+
|
|
1818
|
+
t.Run("returns user when exists", func(t *testing.T) {
|
|
1819
|
+
// Arrange
|
|
1820
|
+
created := &User{
|
|
1821
|
+
Email: "exists@example.com",
|
|
1822
|
+
Name: "Exists",
|
|
1823
|
+
Password: "pass",
|
|
1824
|
+
}
|
|
1825
|
+
require.NoError(t, repo.Create(ctx, created))
|
|
1826
|
+
|
|
1827
|
+
// Act
|
|
1828
|
+
found, err := repo.FindByEmail(ctx, "exists@example.com")
|
|
1829
|
+
|
|
1830
|
+
// Assert
|
|
1831
|
+
assert.NoError(t, err)
|
|
1832
|
+
assert.NotNil(t, found)
|
|
1833
|
+
assert.Equal(t, created.ID, found.ID)
|
|
1834
|
+
})
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
func BenchmarkUserRepository_Create(b *testing.B) {
|
|
1838
|
+
resetDB(&testing.T{})
|
|
1839
|
+
ctx := context.Background()
|
|
1840
|
+
|
|
1841
|
+
b.ResetTimer()
|
|
1842
|
+
for i := 0; i < b.N; i++ {
|
|
1843
|
+
user := &User{
|
|
1844
|
+
Email: fmt.Sprintf("bench%d@example.com", i),
|
|
1845
|
+
Name: "Bench User",
|
|
1846
|
+
Password: "pass",
|
|
1847
|
+
}
|
|
1848
|
+
_ = repo.Create(ctx, user)
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
```
|
|
1852
|
+
|
|
1853
|
+
---
|
|
1854
|
+
|
|
1855
|
+
## ๐ฏ Mise Tasks para Testcontainers
|
|
1856
|
+
|
|
1857
|
+
```toml
|
|
1858
|
+
# .mise.toml
|
|
1859
|
+
|
|
1860
|
+
[tasks."test:integration:tc"]
|
|
1861
|
+
description = "Run integration tests with Testcontainers"
|
|
1862
|
+
run = """
|
|
1863
|
+
#!/usr/bin/env bash
|
|
1864
|
+
|
|
1865
|
+
# Verificar que Docker estรก corriendo
|
|
1866
|
+
if ! docker info > /dev/null 2>&1; then
|
|
1867
|
+
echo "โ Docker no estรก corriendo"
|
|
1868
|
+
echo "Por favor inicia Docker Desktop"
|
|
1869
|
+
exit 1
|
|
1870
|
+
fi
|
|
1871
|
+
|
|
1872
|
+
echo "๐ณ Running integration tests with Testcontainers..."
|
|
1873
|
+
echo "(This may take 5-10 seconds on first run to download images)"
|
|
1874
|
+
echo ""
|
|
1875
|
+
|
|
1876
|
+
if [ -f "package.json" ]; then
|
|
1877
|
+
bun test tests/integration/
|
|
1878
|
+
elif [ -f "pyproject.toml" ]; then
|
|
1879
|
+
pytest tests/integration/ -v --tb=short
|
|
1880
|
+
elif [ -f "go.mod" ]; then
|
|
1881
|
+
go test ./internal/... -tags=integration -v
|
|
1882
|
+
fi
|
|
1883
|
+
|
|
1884
|
+
echo ""
|
|
1885
|
+
echo "โ
Integration tests with Testcontainers completed"
|
|
1886
|
+
"""
|
|
1887
|
+
|
|
1888
|
+
[tasks."test:integration:tc:watch"]
|
|
1889
|
+
description = "Watch mode for integration tests"
|
|
1890
|
+
run = """
|
|
1891
|
+
if [ -f "package.json" ]; then
|
|
1892
|
+
bun test --watch tests/integration/
|
|
1893
|
+
elif [ -f "pyproject.toml" ]; then
|
|
1894
|
+
ptw tests/integration/ -- -v
|
|
1895
|
+
fi
|
|
1896
|
+
"""
|
|
1897
|
+
```
|
|
1898
|
+
|
|
1899
|
+
---
|
|
1900
|
+
|
|
1901
|
+
## โก Performance Tips
|
|
1902
|
+
|
|
1903
|
+
### 1. Reuse Containers (MรS IMPORTANTE)
|
|
1904
|
+
|
|
1905
|
+
```typescript
|
|
1906
|
+
// โ
CON REUSE: ~1-2 segundos por test suite
|
|
1907
|
+
const container = await new PostgreSqlContainer()
|
|
1908
|
+
.withReuse() // โ CRรTICO
|
|
1909
|
+
.start();
|
|
1910
|
+
|
|
1911
|
+
// โ SIN REUSE: ~5-10 segundos por test suite
|
|
1912
|
+
```
|
|
1913
|
+
|
|
1914
|
+
### 2. Use tmpfs para DB en RAM
|
|
1915
|
+
|
|
1916
|
+
```typescript
|
|
1917
|
+
const container = await new PostgreSqlContainer()
|
|
1918
|
+
.withTmpFs({ '/var/lib/postgresql/data': 'rw' }) // RAM disk
|
|
1919
|
+
.start();
|
|
1920
|
+
|
|
1921
|
+
// 2-3x mรกs rรกpido para tests
|
|
1922
|
+
```
|
|
1923
|
+
|
|
1924
|
+
### 3. Parallel Test Execution
|
|
1925
|
+
|
|
1926
|
+
```bash
|
|
1927
|
+
# Bun (paralelo por defecto)
|
|
1928
|
+
bun test --concurrent
|
|
1929
|
+
|
|
1930
|
+
# Pytest
|
|
1931
|
+
pytest -n auto # Usa todos los cores
|
|
1932
|
+
|
|
1933
|
+
# Go
|
|
1934
|
+
go test -parallel 4 ./...
|
|
1935
|
+
```
|
|
1936
|
+
|
|
1937
|
+
### 4. Cleanup Eficiente
|
|
1938
|
+
|
|
1939
|
+
```typescript
|
|
1940
|
+
// โ
Mejor: Truncar tablas (rรกpido)
|
|
1941
|
+
beforeEach(async () => {
|
|
1942
|
+
await db.delete(schema.users);
|
|
1943
|
+
await db.delete(schema.tasks);
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
// โ Lento: Recrear DB entera
|
|
1947
|
+
beforeEach(async () => {
|
|
1948
|
+
await dropDatabase();
|
|
1949
|
+
await createDatabase();
|
|
1950
|
+
await runMigrations();
|
|
1951
|
+
});
|
|
1952
|
+
```
|
|
1953
|
+
|
|
1954
|
+
---
|
|
1955
|
+
|
|
1956
|
+
## ๐ Benchmarks Reales
|
|
1957
|
+
|
|
1958
|
+
```
|
|
1959
|
+
Setup (primera vez):
|
|
1960
|
+
Download image: ~30 segundos
|
|
1961
|
+
Start container: ~3 segundos
|
|
1962
|
+
Apply migrations: ~1 segundo
|
|
1963
|
+
Total: ~34 segundos (solo primera vez)
|
|
1964
|
+
|
|
1965
|
+
Subsequent runs (con reuse):
|
|
1966
|
+
Start container: ~1 segundo
|
|
1967
|
+
Apply migrations: ~0.5 segundos
|
|
1968
|
+
Per test: ~50-100ms
|
|
1969
|
+
Total suite (50): ~5 segundos
|
|
1970
|
+
|
|
1971
|
+
Sin Testcontainers (mocks):
|
|
1972
|
+
Setup: 0 segundos
|
|
1973
|
+
Per test: ~5ms
|
|
1974
|
+
Total suite (50): ~250ms
|
|
1975
|
+
|
|
1976
|
+
Trade-off:
|
|
1977
|
+
โ
20x mรกs lento pero 100x mรกs confianza
|
|
1978
|
+
โ
Detecta bugs reales que mocks no detectan
|
|
1979
|
+
โ
Menos mantenimiento a largo plazo
|
|
1980
|
+
```
|
|
1981
|
+
|
|
1982
|
+
---
|
|
1983
|
+
|
|
1984
|
+
## ๐๏ธ Database Migrations Strategy
|
|
1985
|
+
|
|
1986
|
+
### Filosofรญa de Migraciones
|
|
1987
|
+
|
|
1988
|
+
```
|
|
1989
|
+
โ
DO:
|
|
1990
|
+
- Migraciones son cรณdigo (versiรณn controlada)
|
|
1991
|
+
- Siempre hacia adelante (no editar migraciones existentes)
|
|
1992
|
+
- Rollback strategy clara
|
|
1993
|
+
- Probar en staging primero
|
|
1994
|
+
|
|
1995
|
+
โ DON'T:
|
|
1996
|
+
- Editar migraciones despuรฉs de merge
|
|
1997
|
+
- Rollback manual en producciรณn
|
|
1998
|
+
- Migrations que dependen de datos
|
|
1999
|
+
```
|
|
2000
|
+
|
|
2001
|
+
---
|
|
2002
|
+
|
|
2003
|
+
## TypeScript: Drizzle ORM (Recomendado)
|
|
2004
|
+
|
|
2005
|
+
### Setup
|
|
2006
|
+
|
|
2007
|
+
```bash
|
|
2008
|
+
# Instalar
|
|
2009
|
+
bun add drizzle-orm postgres
|
|
2010
|
+
bun add -d drizzle-kit
|
|
2011
|
+
```
|
|
2012
|
+
|
|
2013
|
+
```typescript
|
|
2014
|
+
// drizzle.config.ts
|
|
2015
|
+
import type { Config } from 'drizzle-kit';
|
|
2016
|
+
|
|
2017
|
+
export default {
|
|
2018
|
+
schema: './src/db/schema.ts',
|
|
2019
|
+
out: './migrations',
|
|
2020
|
+
driver: 'pg',
|
|
2021
|
+
dbCredentials: {
|
|
2022
|
+
connectionString: process.env.DATABASE_URL!,
|
|
2023
|
+
},
|
|
2024
|
+
} satisfies Config;
|
|
2025
|
+
```
|
|
2026
|
+
|
|
2027
|
+
### Schema Definition
|
|
2028
|
+
|
|
2029
|
+
```typescript
|
|
2030
|
+
// src/db/schema.ts
|
|
2031
|
+
import { pgTable, serial, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
|
|
2032
|
+
|
|
2033
|
+
export const users = pgTable('users', {
|
|
2034
|
+
id: serial('id').primaryKey(),
|
|
2035
|
+
email: text('email').notNull().unique(),
|
|
2036
|
+
name: text('name'),
|
|
2037
|
+
password: text('password').notNull(),
|
|
2038
|
+
emailVerified: boolean('email_verified').default(false),
|
|
2039
|
+
createdAt: timestamp('created_at').defaultNow(),
|
|
2040
|
+
updatedAt: timestamp('updated_at').defaultNow(),
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
export const tasks = pgTable('tasks', {
|
|
2044
|
+
id: serial('id').primaryKey(),
|
|
2045
|
+
title: text('title').notNull(),
|
|
2046
|
+
description: text('description'),
|
|
2047
|
+
completed: boolean('completed').default(false),
|
|
2048
|
+
userId: integer('user_id')
|
|
2049
|
+
.references(() => users.id, { onDelete: 'cascade' })
|
|
2050
|
+
.notNull(),
|
|
2051
|
+
createdAt: timestamp('created_at').defaultNow(),
|
|
2052
|
+
updatedAt: timestamp('updated_at').defaultNow(),
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
// Type inference
|
|
2056
|
+
export type User = typeof users.$inferSelect;
|
|
2057
|
+
export type NewUser = typeof users.$inferInsert;
|
|
2058
|
+
export type Task = typeof tasks.$inferSelect;
|
|
2059
|
+
export type NewTask = typeof tasks.$inferInsert;
|
|
2060
|
+
```
|
|
2061
|
+
|
|
2062
|
+
### Client Setup
|
|
2063
|
+
|
|
2064
|
+
```typescript
|
|
2065
|
+
// src/db/index.ts
|
|
2066
|
+
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
2067
|
+
import postgres from 'postgres';
|
|
2068
|
+
import * as schema from './schema';
|
|
2069
|
+
|
|
2070
|
+
const connectionString = process.env.DATABASE_URL!;
|
|
2071
|
+
|
|
2072
|
+
// Para queries
|
|
2073
|
+
const queryClient = postgres(connectionString);
|
|
2074
|
+
export const db = drizzle(queryClient, { schema });
|
|
2075
|
+
|
|
2076
|
+
// Para migrations
|
|
2077
|
+
export const migrationClient = postgres(connectionString, { max: 1 });
|
|
2078
|
+
```
|
|
2079
|
+
|
|
2080
|
+
### Workflow de Migraciones
|
|
2081
|
+
|
|
2082
|
+
```bash
|
|
2083
|
+
# 1. Cambiar schema.ts (agregar columna, tabla, etc.)
|
|
2084
|
+
|
|
2085
|
+
# 2. Generar migration
|
|
2086
|
+
mise run db:generate
|
|
2087
|
+
|
|
2088
|
+
# 3. Revisar SQL generado en migrations/
|
|
2089
|
+
# migrations/0001_add_email_verified.sql
|
|
2090
|
+
|
|
2091
|
+
# 4. Aplicar migration
|
|
2092
|
+
mise run db:migrate
|
|
2093
|
+
|
|
2094
|
+
# 5. Rollback si algo falla (manual)
|
|
2095
|
+
# Editar migration SQL o crear nueva para revertir
|
|
2096
|
+
```
|
|
2097
|
+
|
|
2098
|
+
### Migrations con Datos
|
|
2099
|
+
|
|
2100
|
+
```typescript
|
|
2101
|
+
// migrations/0002_seed_default_roles.ts
|
|
2102
|
+
import { db } from '../src/db';
|
|
2103
|
+
import { roles } from '../src/db/schema';
|
|
2104
|
+
|
|
2105
|
+
export async function up() {
|
|
2106
|
+
await db.insert(roles).values([
|
|
2107
|
+
{ name: 'admin', permissions: ['all'] },
|
|
2108
|
+
{ name: 'user', permissions: ['read', 'write'] },
|
|
2109
|
+
{ name: 'guest', permissions: ['read'] },
|
|
2110
|
+
]);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
export async function down() {
|
|
2114
|
+
await db.delete(roles);
|
|
2115
|
+
}
|
|
2116
|
+
```
|
|
2117
|
+
|
|
2118
|
+
---
|
|
2119
|
+
|
|
2120
|
+
## Python: Alembic (con SQLAlchemy)
|
|
2121
|
+
|
|
2122
|
+
### Setup
|
|
2123
|
+
|
|
2124
|
+
```bash
|
|
2125
|
+
# Instalar
|
|
2126
|
+
uv add alembic sqlalchemy psycopg2-binary
|
|
2127
|
+
|
|
2128
|
+
# Inicializar
|
|
2129
|
+
alembic init migrations
|
|
2130
|
+
```
|
|
2131
|
+
|
|
2132
|
+
### Configuration
|
|
2133
|
+
|
|
2134
|
+
```python
|
|
2135
|
+
# alembic.ini
|
|
2136
|
+
[alembic]
|
|
2137
|
+
script_location = migrations
|
|
2138
|
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
2139
|
+
|
|
2140
|
+
# Use env variable
|
|
2141
|
+
# sqlalchemy.url =
|
|
2142
|
+
|
|
2143
|
+
# migrations/env.py
|
|
2144
|
+
from logging.config import fileConfig
|
|
2145
|
+
from sqlalchemy import engine_from_config, pool
|
|
2146
|
+
from alembic import context
|
|
2147
|
+
import os
|
|
2148
|
+
|
|
2149
|
+
# Import your models
|
|
2150
|
+
from app.models import Base
|
|
2151
|
+
|
|
2152
|
+
config = context.config
|
|
2153
|
+
|
|
2154
|
+
# Override sqlalchemy.url from environment
|
|
2155
|
+
config.set_main_option(
|
|
2156
|
+
'sqlalchemy.url',
|
|
2157
|
+
os.getenv('DATABASE_URL')
|
|
2158
|
+
)
|
|
2159
|
+
|
|
2160
|
+
target_metadata = Base.metadata
|
|
2161
|
+
|
|
2162
|
+
def run_migrations_online():
|
|
2163
|
+
connectable = engine_from_config(
|
|
2164
|
+
config.get_section(config.config_ini_section),
|
|
2165
|
+
prefix='sqlalchemy.',
|
|
2166
|
+
poolclass=pool.NullPool,
|
|
2167
|
+
)
|
|
2168
|
+
|
|
2169
|
+
with connectable.connect() as connection:
|
|
2170
|
+
context.configure(
|
|
2171
|
+
connection=connection,
|
|
2172
|
+
target_metadata=target_metadata
|
|
2173
|
+
)
|
|
2174
|
+
|
|
2175
|
+
with context.begin_transaction():
|
|
2176
|
+
context.run_migrations()
|
|
2177
|
+
|
|
2178
|
+
run_migrations_online()
|
|
2179
|
+
```
|
|
2180
|
+
|
|
2181
|
+
### Models Definition
|
|
2182
|
+
|
|
2183
|
+
```python
|
|
2184
|
+
# app/models/user.py
|
|
2185
|
+
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
|
2186
|
+
from sqlalchemy.sql import func
|
|
2187
|
+
from app.database import Base
|
|
2188
|
+
|
|
2189
|
+
class User(Base):
|
|
2190
|
+
__tablename__ = "users"
|
|
2191
|
+
|
|
2192
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
2193
|
+
email = Column(String, unique=True, index=True, nullable=False)
|
|
2194
|
+
name = Column(String)
|
|
2195
|
+
password = Column(String, nullable=False)
|
|
2196
|
+
email_verified = Column(Boolean, default=False)
|
|
2197
|
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
2198
|
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
2199
|
+
|
|
2200
|
+
# Relationships
|
|
2201
|
+
tasks = relationship("Task", back_populates="user", cascade="all, delete-orphan")
|
|
2202
|
+
```
|
|
2203
|
+
|
|
2204
|
+
### Workflow de Migraciones
|
|
2205
|
+
|
|
2206
|
+
```bash
|
|
2207
|
+
# 1. Modificar models en app/models/
|
|
2208
|
+
|
|
2209
|
+
# 2. Generar migration automรกtica
|
|
2210
|
+
alembic revision --autogenerate -m "add email_verified column"
|
|
2211
|
+
|
|
2212
|
+
# 3. Revisar migration generada
|
|
2213
|
+
# migrations/versions/xxxx_add_email_verified.py
|
|
2214
|
+
|
|
2215
|
+
# 4. Aplicar migration
|
|
2216
|
+
alembic upgrade head
|
|
2217
|
+
|
|
2218
|
+
# 5. Rollback si es necesario
|
|
2219
|
+
alembic downgrade -1
|
|
2220
|
+
```
|
|
2221
|
+
|
|
2222
|
+
### Migration Example
|
|
2223
|
+
|
|
2224
|
+
```python
|
|
2225
|
+
# migrations/versions/0001_create_users_table.py
|
|
2226
|
+
"""create users table
|
|
2227
|
+
|
|
2228
|
+
Revision ID: 0001
|
|
2229
|
+
Revises:
|
|
2230
|
+
Create Date: 2025-12-23
|
|
2231
|
+
"""
|
|
2232
|
+
from alembic import op
|
|
2233
|
+
import sqlalchemy as sa
|
|
2234
|
+
|
|
2235
|
+
revision = '0001'
|
|
2236
|
+
down_revision = None
|
|
2237
|
+
branch_labels = None
|
|
2238
|
+
depends_on = None
|
|
2239
|
+
|
|
2240
|
+
def upgrade():
|
|
2241
|
+
op.create_table(
|
|
2242
|
+
'users',
|
|
2243
|
+
sa.Column('id', sa.Integer(), nullable=False),
|
|
2244
|
+
sa.Column('email', sa.String(), nullable=False),
|
|
2245
|
+
sa.Column('name', sa.String()),
|
|
2246
|
+
sa.Column('password', sa.String(), nullable=False),
|
|
2247
|
+
sa.Column('email_verified', sa.Boolean(), server_default='false'),
|
|
2248
|
+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')),
|
|
2249
|
+
sa.Column('updated_at', sa.DateTime(timezone=True)),
|
|
2250
|
+
sa.PrimaryKeyConstraint('id')
|
|
2251
|
+
)
|
|
2252
|
+
op.create_index('ix_users_email', 'users', ['email'], unique=True)
|
|
2253
|
+
|
|
2254
|
+
def downgrade():
|
|
2255
|
+
op.drop_index('ix_users_email', table_name='users')
|
|
2256
|
+
op.drop_table('users')
|
|
2257
|
+
```
|
|
2258
|
+
|
|
2259
|
+
---
|
|
2260
|
+
|
|
2261
|
+
## Go: golang-migrate
|
|
2262
|
+
|
|
2263
|
+
### Setup
|
|
2264
|
+
|
|
2265
|
+
```bash
|
|
2266
|
+
# Instalar CLI
|
|
2267
|
+
brew install golang-migrate
|
|
2268
|
+
|
|
2269
|
+
# O como Go tool
|
|
2270
|
+
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
|
2271
|
+
```
|
|
2272
|
+
|
|
2273
|
+
### Estructura
|
|
2274
|
+
|
|
2275
|
+
```
|
|
2276
|
+
migrations/
|
|
2277
|
+
โโโ 000001_create_users_table.up.sql
|
|
2278
|
+
โโโ 000001_create_users_table.down.sql
|
|
2279
|
+
โโโ 000002_create_tasks_table.up.sql
|
|
2280
|
+
โโโ 000002_create_tasks_table.down.sql
|
|
2281
|
+
```
|
|
2282
|
+
|
|
2283
|
+
### Migration Files
|
|
2284
|
+
|
|
2285
|
+
```sql
|
|
2286
|
+
-- migrations/000001_create_users_table.up.sql
|
|
2287
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
2288
|
+
id SERIAL PRIMARY KEY,
|
|
2289
|
+
email VARCHAR(255) NOT NULL UNIQUE,
|
|
2290
|
+
name VARCHAR(255),
|
|
2291
|
+
password VARCHAR(255) NOT NULL,
|
|
2292
|
+
email_verified BOOLEAN DEFAULT FALSE,
|
|
2293
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
2294
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
2295
|
+
);
|
|
2296
|
+
|
|
2297
|
+
CREATE INDEX idx_users_email ON users(email);
|
|
2298
|
+
|
|
2299
|
+
-- migrations/000001_create_users_table.down.sql
|
|
2300
|
+
DROP INDEX IF EXISTS idx_users_email;
|
|
2301
|
+
DROP TABLE IF EXISTS users;
|
|
2302
|
+
```
|
|
2303
|
+
|
|
2304
|
+
```sql
|
|
2305
|
+
-- migrations/000002_create_tasks_table.up.sql
|
|
2306
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
2307
|
+
id SERIAL PRIMARY KEY,
|
|
2308
|
+
title VARCHAR(255) NOT NULL,
|
|
2309
|
+
description TEXT,
|
|
2310
|
+
completed BOOLEAN DEFAULT FALSE,
|
|
2311
|
+
user_id INTEGER NOT NULL,
|
|
2312
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
2313
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
2314
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
2315
|
+
);
|
|
2316
|
+
|
|
2317
|
+
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
|
|
2318
|
+
|
|
2319
|
+
-- migrations/000002_create_tasks_table.down.sql
|
|
2320
|
+
DROP INDEX IF EXISTS idx_tasks_user_id;
|
|
2321
|
+
DROP TABLE IF EXISTS tasks;
|
|
2322
|
+
```
|
|
2323
|
+
|
|
2324
|
+
### Programmatic Usage
|
|
2325
|
+
|
|
2326
|
+
```go
|
|
2327
|
+
// internal/database/migrate.go
|
|
2328
|
+
package database
|
|
2329
|
+
|
|
2330
|
+
import (
|
|
2331
|
+
"database/sql"
|
|
2332
|
+
"github.com/golang-migrate/migrate/v4"
|
|
2333
|
+
"github.com/golang-migrate/migrate/v4/database/postgres"
|
|
2334
|
+
_ "github.com/golang-migrate/migrate/v4/source/file"
|
|
2335
|
+
)
|
|
2336
|
+
|
|
2337
|
+
func RunMigrations(db *sql.DB) error {
|
|
2338
|
+
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
|
2339
|
+
if err != nil {
|
|
2340
|
+
return err
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
m, err := migrate.NewWithDatabaseInstance(
|
|
2344
|
+
"file://migrations",
|
|
2345
|
+
"postgres",
|
|
2346
|
+
driver,
|
|
2347
|
+
)
|
|
2348
|
+
if err != nil {
|
|
2349
|
+
return err
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
|
2353
|
+
return err
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
return nil
|
|
2357
|
+
}
|
|
2358
|
+
```
|
|
2359
|
+
|
|
2360
|
+
### Workflow
|
|
2361
|
+
|
|
2362
|
+
```bash
|
|
2363
|
+
# Crear nueva migration
|
|
2364
|
+
migrate create -ext sql -dir migrations -seq create_tasks_table
|
|
2365
|
+
|
|
2366
|
+
# Aplicar migrations
|
|
2367
|
+
migrate -path migrations -database "$DATABASE_URL" up
|
|
2368
|
+
|
|
2369
|
+
# Rollback
|
|
2370
|
+
migrate -path migrations -database "$DATABASE_URL" down 1
|
|
2371
|
+
|
|
2372
|
+
# Ver estado
|
|
2373
|
+
migrate -path migrations -database "$DATABASE_URL" version
|
|
2374
|
+
```
|
|
2375
|
+
|
|
2376
|
+
---
|
|
2377
|
+
|
|
2378
|
+
## Java/Kotlin: Flyway
|
|
2379
|
+
|
|
2380
|
+
### Setup (Gradle)
|
|
2381
|
+
|
|
2382
|
+
```kotlin
|
|
2383
|
+
// build.gradle.kts
|
|
2384
|
+
plugins {
|
|
2385
|
+
id("org.flywaydb.flyway") version "10.4.1"
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
dependencies {
|
|
2389
|
+
implementation("org.flywaydb:flyway-core:10.4.1")
|
|
2390
|
+
implementation("org.flywaydb:flyway-database-postgresql:10.4.1")
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
flyway {
|
|
2394
|
+
url = "jdbc:postgresql://localhost:5432/mydb"
|
|
2395
|
+
user = "postgres"
|
|
2396
|
+
password = "postgres"
|
|
2397
|
+
locations = arrayOf("classpath:db/migration")
|
|
2398
|
+
}
|
|
2399
|
+
```
|
|
2400
|
+
|
|
2401
|
+
### Migration Files
|
|
2402
|
+
|
|
2403
|
+
```
|
|
2404
|
+
src/main/resources/db/migration/
|
|
2405
|
+
โโโ V1__create_users_table.sql
|
|
2406
|
+
โโโ V2__create_tasks_table.sql
|
|
2407
|
+
โโโ V3__add_email_verified.sql
|
|
2408
|
+
```
|
|
2409
|
+
|
|
2410
|
+
```sql
|
|
2411
|
+
-- V1__create_users_table.sql
|
|
2412
|
+
CREATE TABLE users (
|
|
2413
|
+
id SERIAL PRIMARY KEY,
|
|
2414
|
+
email VARCHAR(255) NOT NULL UNIQUE,
|
|
2415
|
+
name VARCHAR(255),
|
|
2416
|
+
password VARCHAR(255) NOT NULL,
|
|
2417
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
2418
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
2419
|
+
);
|
|
2420
|
+
|
|
2421
|
+
CREATE INDEX idx_users_email ON users(email);
|
|
2422
|
+
```
|
|
2423
|
+
|
|
2424
|
+
### Programmatic Usage
|
|
2425
|
+
|
|
2426
|
+
```kotlin
|
|
2427
|
+
// src/main/kotlin/com/example/config/DatabaseConfig.kt
|
|
2428
|
+
import org.flywaydb.core.Flyway
|
|
2429
|
+
import org.springframework.context.annotation.Bean
|
|
2430
|
+
import org.springframework.context.annotation.Configuration
|
|
2431
|
+
import javax.sql.DataSource
|
|
2432
|
+
|
|
2433
|
+
@Configuration
|
|
2434
|
+
class DatabaseConfig {
|
|
2435
|
+
|
|
2436
|
+
@Bean
|
|
2437
|
+
fun flyway(dataSource: DataSource): Flyway {
|
|
2438
|
+
val flyway = Flyway.configure()
|
|
2439
|
+
.dataSource(dataSource)
|
|
2440
|
+
.locations("classpath:db/migration")
|
|
2441
|
+
.load()
|
|
2442
|
+
|
|
2443
|
+
flyway.migrate()
|
|
2444
|
+
return flyway
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
```
|
|
2448
|
+
|
|
2449
|
+
---
|
|
2450
|
+
|
|
2451
|
+
## ๐ฏ Mise Tasks Universales para Migraciones
|
|
2452
|
+
|
|
2453
|
+
```toml
|
|
2454
|
+
# .mise.toml
|
|
2455
|
+
|
|
2456
|
+
[tasks."db:generate"]
|
|
2457
|
+
description = "Generate new migration"
|
|
2458
|
+
run = """
|
|
2459
|
+
#!/usr/bin/env bash
|
|
2460
|
+
set -e
|
|
2461
|
+
|
|
2462
|
+
if [ -f "drizzle.config.ts" ]; then
|
|
2463
|
+
# TypeScript + Drizzle
|
|
2464
|
+
echo "๐ Generating Drizzle migration..."
|
|
2465
|
+
bun drizzle-kit generate:pg
|
|
2466
|
+
|
|
2467
|
+
elif [ -f "alembic.ini" ]; then
|
|
2468
|
+
# Python + Alembic
|
|
2469
|
+
echo "๐ Generating Alembic migration..."
|
|
2470
|
+
read -p "Migration message: " message
|
|
2471
|
+
alembic revision --autogenerate -m "$message"
|
|
2472
|
+
|
|
2473
|
+
elif [ -f "go.mod" ]; then
|
|
2474
|
+
# Go + golang-migrate
|
|
2475
|
+
echo "๐ Creating golang-migrate migration..."
|
|
2476
|
+
read -p "Migration name: " name
|
|
2477
|
+
migrate create -ext sql -dir migrations -seq "$name"
|
|
2478
|
+
|
|
2479
|
+
elif [ -f "build.gradle.kts" ]; then
|
|
2480
|
+
# Java + Flyway
|
|
2481
|
+
echo "๐ Creating Flyway migration..."
|
|
2482
|
+
read -p "Migration name: " name
|
|
2483
|
+
touch "src/main/resources/db/migration/V$(date +%Y%m%d%H%M%S)__${name}.sql"
|
|
2484
|
+
fi
|
|
2485
|
+
|
|
2486
|
+
echo "โ
Migration generated. Review before applying!"
|
|
2487
|
+
"""
|
|
2488
|
+
|
|
2489
|
+
[tasks."db:migrate"]
|
|
2490
|
+
description = "Apply pending migrations"
|
|
2491
|
+
run = """
|
|
2492
|
+
#!/usr/bin/env bash
|
|
2493
|
+
set -e
|
|
2494
|
+
|
|
2495
|
+
if [ -f "drizzle.config.ts" ]; then
|
|
2496
|
+
echo "๐ Applying Drizzle migrations..."
|
|
2497
|
+
bun drizzle-kit push:pg
|
|
2498
|
+
|
|
2499
|
+
elif [ -f "alembic.ini" ]; then
|
|
2500
|
+
echo "๐ Applying Alembic migrations..."
|
|
2501
|
+
alembic upgrade head
|
|
2502
|
+
|
|
2503
|
+
elif [ -f "go.mod" ]; then
|
|
2504
|
+
echo "๐ Applying golang-migrate migrations..."
|
|
2505
|
+
migrate -path migrations -database "$DATABASE_URL" up
|
|
2506
|
+
|
|
2507
|
+
elif [ -f "build.gradle.kts" ]; then
|
|
2508
|
+
echo "๐ Applying Flyway migrations..."
|
|
2509
|
+
./gradlew flywayMigrate
|
|
2510
|
+
fi
|
|
2511
|
+
|
|
2512
|
+
echo "โ
Migrations applied successfully!"
|
|
2513
|
+
"""
|
|
2514
|
+
|
|
2515
|
+
[tasks."db:rollback"]
|
|
2516
|
+
description = "Rollback last migration"
|
|
2517
|
+
run = """
|
|
2518
|
+
#!/usr/bin/env bash
|
|
2519
|
+
set -e
|
|
2520
|
+
|
|
2521
|
+
echo "โ ๏ธ Rolling back last migration..."
|
|
2522
|
+
|
|
2523
|
+
if [ -f "alembic.ini" ]; then
|
|
2524
|
+
alembic downgrade -1
|
|
2525
|
+
|
|
2526
|
+
elif [ -f "go.mod" ]; then
|
|
2527
|
+
migrate -path migrations -database "$DATABASE_URL" down 1
|
|
2528
|
+
|
|
2529
|
+
elif [ -f "build.gradle.kts" ]; then
|
|
2530
|
+
./gradlew flywayUndo
|
|
2531
|
+
|
|
2532
|
+
else
|
|
2533
|
+
echo "โ Rollback not supported for this stack"
|
|
2534
|
+
echo "๐ก Consider creating a new migration to revert changes"
|
|
2535
|
+
exit 1
|
|
2536
|
+
fi
|
|
2537
|
+
|
|
2538
|
+
echo "โ
Rollback complete"
|
|
2539
|
+
"""
|
|
2540
|
+
|
|
2541
|
+
[tasks."db:status"]
|
|
2542
|
+
description = "Show migration status"
|
|
2543
|
+
run = """
|
|
2544
|
+
#!/usr/bin/env bash
|
|
2545
|
+
|
|
2546
|
+
if [ -f "alembic.ini" ]; then
|
|
2547
|
+
alembic current
|
|
2548
|
+
alembic history
|
|
2549
|
+
|
|
2550
|
+
elif [ -f "go.mod" ]; then
|
|
2551
|
+
migrate -path migrations -database "$DATABASE_URL" version
|
|
2552
|
+
|
|
2553
|
+
elif [ -f "build.gradle.kts" ]; then
|
|
2554
|
+
./gradlew flywayInfo
|
|
2555
|
+
fi
|
|
2556
|
+
"""
|
|
2557
|
+
|
|
2558
|
+
[tasks."db:reset"]
|
|
2559
|
+
description = "Drop all tables and re-run migrations (DEV ONLY)"
|
|
2560
|
+
run = """
|
|
2561
|
+
#!/usr/bin/env bash
|
|
2562
|
+
set -e
|
|
2563
|
+
|
|
2564
|
+
if [ "$NODE_ENV" = "production" ]; then
|
|
2565
|
+
echo "โ Cannot reset database in production!"
|
|
2566
|
+
exit 1
|
|
2567
|
+
fi
|
|
2568
|
+
|
|
2569
|
+
echo "โ ๏ธ This will DELETE ALL DATA. Are you sure? (yes/no)"
|
|
2570
|
+
read -r confirm
|
|
2571
|
+
|
|
2572
|
+
if [ "$confirm" != "yes" ]; then
|
|
2573
|
+
echo "Cancelled"
|
|
2574
|
+
exit 0
|
|
2575
|
+
fi
|
|
2576
|
+
|
|
2577
|
+
# Drop database
|
|
2578
|
+
psql "$DATABASE_URL" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
|
2579
|
+
|
|
2580
|
+
# Re-run migrations
|
|
2581
|
+
mise run db:migrate
|
|
2582
|
+
|
|
2583
|
+
echo "โ
Database reset complete"
|
|
2584
|
+
"""
|
|
2585
|
+
```
|
|
2586
|
+
|
|
2587
|
+
---
|
|
2588
|
+
|
|
2589
|
+
## ๐ Secrets Management
|
|
2590
|
+
|
|
2591
|
+
### Niveles de Secrets
|
|
2592
|
+
|
|
2593
|
+
```
|
|
2594
|
+
Level 1: Local Dev โ .env (not committed)
|
|
2595
|
+
Level 2: Team Shared โ Doppler/Infisical
|
|
2596
|
+
Level 3: CI/CD โ GitHub Secrets
|
|
2597
|
+
Level 4: Production โ AWS Secrets Manager / Vault
|
|
2598
|
+
```
|
|
2599
|
+
|
|
2600
|
+
---
|
|
2601
|
+
|
|
2602
|
+
## ๐ Local Development
|
|
2603
|
+
|
|
2604
|
+
### Opciรณn 1: .env Simple (Para empezar)
|
|
2605
|
+
|
|
2606
|
+
```bash
|
|
2607
|
+
# .env.example (COMMITTED al repo)
|
|
2608
|
+
# Copiar y renombrar a .env
|
|
2609
|
+
DATABASE_URL=postgresql://localhost:5432/mydb
|
|
2610
|
+
REDIS_URL=redis://localhost:6379
|
|
2611
|
+
JWT_SECRET=change-me-in-development
|
|
2612
|
+
API_KEY=
|
|
2613
|
+
|
|
2614
|
+
# Production services (dejar vacรญo en local)
|
|
2615
|
+
STRIPE_SECRET_KEY=
|
|
2616
|
+
SENDGRID_API_KEY=
|
|
2617
|
+
AWS_ACCESS_KEY_ID=
|
|
2618
|
+
AWS_SECRET_ACCESS_KEY=
|
|
2619
|
+
```
|
|
2620
|
+
|
|
2621
|
+
```bash
|
|
2622
|
+
# .env (NOT COMMITTED - en .gitignore)
|
|
2623
|
+
DATABASE_URL=postgresql://localhost:5432/mydb
|
|
2624
|
+
REDIS_URL=redis://localhost:6379
|
|
2625
|
+
JWT_SECRET=local-dev-secret-key-123
|
|
2626
|
+
API_KEY=sk-test-1234567890
|
|
2627
|
+
|
|
2628
|
+
# Real API keys para testing
|
|
2629
|
+
STRIPE_SECRET_KEY=sk_test_real_key_here
|
|
2630
|
+
SENDGRID_API_KEY=SG.real_key_here
|
|
2631
|
+
```
|
|
2632
|
+
|
|
2633
|
+
```bash
|