From 51ec9b2caa9898155d485f8abd27a32dd6a09e3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:55:48 +0000 Subject: [PATCH 1/5] Phase 0: Add coverage infrastructure - @vitest/coverage-v8, config, CI workflow Agent-Logs-Url: https://github.com/Optikt/optikt-app/sessions/ba46b228-9632-4493-aa44-c4bd330bbeca Co-authored-by: NanezX <81595884+NanezX@users.noreply.github.com> --- .github/workflows/tests.yml | 12 ++- package.json | 4 +- pnpm-lock.yaml | 155 ++++++++++++++++++++++++++++++++++++ vite.config.ts | 12 +++ 4 files changed, 180 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a98c5bb..de44e3c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,11 +32,19 @@ jobs: - name: Install Playwright browsers run: pnpm playwright install --with-deps chromium - - name: Run unit tests - run: pnpm test:unit + - name: Run unit tests with coverage + run: pnpm test:coverage env: DATABASE_URL: postgresql://ci:ci@localhost:5432/ci # Dummy for import resolution + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage/ + retention-days: 30 + e2e-tests: name: E2E Tests runs-on: ubuntu-latest diff --git a/package.json b/package.json index ee4ead8e..b3fa95c4 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", "db:seed": "tsx scripts/seed-db.ts", - "svelte:doctor": "svelte-doctor check" + "svelte:doctor": "svelte-doctor check", + "test:coverage": "vitest --run --coverage" }, "devDependencies": { "@eslint/compat": "^1.4.0", @@ -35,6 +36,7 @@ "@tailwindcss/vite": "^4.1.17", "@types/node": "^22", "@vitest/browser-playwright": "^4.0.15", + "@vitest/coverage-v8": "^4.1.4", "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.45.0", "eslint": "^9.39.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f04a79d..09bfd94f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@vitest/browser-playwright': specifier: ^4.0.15 version: 4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.0.16) + '@vitest/coverage-v8': + specifier: ^4.1.4 + version: 4.1.4(vitest@4.0.16) drizzle-kit: specifier: ^0.31.8 version: 0.31.8 @@ -148,6 +151,27 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1512,6 +1536,15 @@ packages: peerDependencies: vitest: 4.0.16 + '@vitest/coverage-v8@4.1.4': + resolution: {integrity: sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==} + peerDependencies: + '@vitest/browser': 4.1.4 + vitest: 4.1.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.16': resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} @@ -1529,6 +1562,9 @@ packages: '@vitest/pretty-format@4.0.16': resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + '@vitest/pretty-format@4.1.4': + resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + '@vitest/runner@4.0.16': resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} @@ -1541,6 +1577,9 @@ packages: '@vitest/utils@4.0.16': resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + '@vitest/utils@4.1.4': + resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + '@yr/monotone-cubic-spline@1.0.3': resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==} @@ -1583,6 +1622,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -1652,6 +1694,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -2025,6 +2070,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2077,10 +2125,25 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -2211,6 +2274,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2544,6 +2614,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + stdin-discarder@0.3.1: resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==} engines: {node: '>=18'} @@ -2653,6 +2726,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2854,6 +2931,21 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.7.1': @@ -3856,6 +3948,20 @@ snapshots: - utf-8-validate - vite + '@vitest/coverage-v8@4.1.4(vitest@4.0.16)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.4 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.0.0 + tinyrainbow: 3.1.0 + vitest: 4.0.16(@types/node@22.19.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/expect@4.0.16': dependencies: '@standard-schema/spec': 1.1.0 @@ -3877,6 +3983,10 @@ snapshots: dependencies: tinyrainbow: 3.0.3 + '@vitest/pretty-format@4.1.4': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@4.0.16': dependencies: '@vitest/utils': 4.0.16 @@ -3895,6 +4005,12 @@ snapshots: '@vitest/pretty-format': 4.0.16 tinyrainbow: 3.0.3 + '@vitest/utils@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@yr/monotone-cubic-spline@1.0.3': {} acorn-jsx@5.3.2(acorn@8.15.0): @@ -3933,6 +4049,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + axobject-query@4.1.0: {} balanced-match@1.0.2: {} @@ -3987,6 +4109,8 @@ snapshots: concat-map@0.0.1: {} + convert-source-map@2.0.0: {} + cookie@0.6.0: {} cross-spawn@7.0.6: @@ -4381,6 +4505,8 @@ snapshots: dependencies: function-bind: 1.1.2 + html-escaper@2.0.2: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -4420,8 +4546,23 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@2.6.1: {} + js-tokens@10.0.0: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -4536,6 +4677,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + merge2@1.4.1: {} micromatch@4.0.8: @@ -4837,6 +4988,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.0.0: {} + stdin-discarder@0.3.1: {} string-width@8.2.0: @@ -4969,6 +5122,8 @@ snapshots: tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 diff --git a/vite.config.ts b/vite.config.ts index 4dc99b95..ba17aa5f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,18 @@ export default defineConfig({ test: { expect: { requireAssertions: true }, + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary', 'lcov', 'json-summary'], + include: ['src/lib/**/*.ts'], + exclude: [ + '**/*.spec.ts', + '**/*.test.ts', + '**/index.ts', + 'src/lib/server/db/schema/**' + ] + }, + projects: [ { extends: './vite.config.ts', From d2a76d9ff0461150a4ccdb48708c537ed675ef3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:58:20 +0000 Subject: [PATCH 2/5] Add comprehensive tests for 5 untested utility files Add spec files for opticalRange, prescription, products, csv, and generateUUID utils with 70 tests covering all exports, edge cases, and null handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: NanezX <81595884+NanezX@users.noreply.github.com> --- src/lib/utils/csv.spec.ts | 84 +++++++++++ src/lib/utils/generateUUID.spec.ts | 66 +++++++++ src/lib/utils/opticalRange.spec.ts | 205 ++++++++++++++++++++++++++ src/lib/utils/prescription.spec.ts | 225 +++++++++++++++++++++++++++++ src/lib/utils/products.spec.ts | 50 +++++++ 5 files changed, 630 insertions(+) create mode 100644 src/lib/utils/csv.spec.ts create mode 100644 src/lib/utils/generateUUID.spec.ts create mode 100644 src/lib/utils/opticalRange.spec.ts create mode 100644 src/lib/utils/prescription.spec.ts create mode 100644 src/lib/utils/products.spec.ts diff --git a/src/lib/utils/csv.spec.ts b/src/lib/utils/csv.spec.ts new file mode 100644 index 00000000..881ea8a6 --- /dev/null +++ b/src/lib/utils/csv.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('export-to-csv', () => { + const mockDownload = vi.fn(); + const mockGenerateCsvInner = vi.fn(() => 'csv-output'); + + return { + mkConfig: vi.fn((cfg) => cfg), + generateCsv: vi.fn(() => mockGenerateCsvInner), + download: vi.fn(() => mockDownload) + }; +}); + +import { downloadCsv } from './csv'; +import { mkConfig, generateCsv, download } from 'export-to-csv'; + +describe('downloadCsv', () => { + it('calls mkConfig with correct filename (strips .csv extension)', () => { + downloadCsv('report.csv', ['Name'], [['Alice']]); + + expect(mkConfig).toHaveBeenCalledWith( + expect.objectContaining({ filename: 'report' }) + ); + }); + + it('calls mkConfig with useBom enabled', () => { + downloadCsv('data', ['Col'], [['val']]); + + expect(mkConfig).toHaveBeenCalledWith( + expect.objectContaining({ useBom: true }) + ); + }); + + it('maps headers to columnHeaders with key/displayLabel pairs', () => { + downloadCsv('test', ['First', 'Second'], [['a', 'b']]); + + expect(mkConfig).toHaveBeenCalledWith( + expect.objectContaining({ + columnHeaders: [ + { key: 'col0', displayLabel: 'First' }, + { key: 'col1', displayLabel: 'Second' } + ] + }) + ); + }); + + it('calls generateCsv with row data mapped to col keys', () => { + downloadCsv('test', ['H1', 'H2'], [['r1c1', 'r1c2'], ['r2c1', 'r2c2']]); + + const generateCsvFn = vi.mocked(generateCsv); + const innerFn = generateCsvFn.mock.results[generateCsvFn.mock.results.length - 1].value; + + expect(innerFn).toHaveBeenCalledWith([ + { col0: 'r1c1', col1: 'r1c2' }, + { col0: 'r2c1', col1: 'r2c2' } + ]); + }); + + it('calls download with the generated csv output', () => { + downloadCsv('test', ['H'], [['v']]); + + const downloadFn = vi.mocked(download); + const innerDownload = downloadFn.mock.results[downloadFn.mock.results.length - 1].value; + + expect(innerDownload).toHaveBeenCalledWith('csv-output'); + }); + + it('handles filename without .csv extension', () => { + downloadCsv('my-export', ['A'], [['1']]); + + expect(mkConfig).toHaveBeenCalledWith( + expect.objectContaining({ filename: 'my-export' }) + ); + }); + + it('handles empty rows', () => { + downloadCsv('empty', ['H1'], []); + + const generateCsvFn = vi.mocked(generateCsv); + const innerFn = generateCsvFn.mock.results[generateCsvFn.mock.results.length - 1].value; + + expect(innerFn).toHaveBeenCalledWith([]); + }); +}); diff --git a/src/lib/utils/generateUUID.spec.ts b/src/lib/utils/generateUUID.spec.ts new file mode 100644 index 00000000..edb78855 --- /dev/null +++ b/src/lib/utils/generateUUID.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from 'vitest'; +import { generateUUID } from './generateUUID'; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('generateUUID', () => { + it('returns a string', () => { + expect(typeof generateUUID()).toBe('string'); + }); + + it('returns a valid UUID v4 format', () => { + const uuid = generateUUID(); + + expect(uuid).toMatch(UUID_REGEX); + }); + + it('returns unique values on successive calls', () => { + const a = generateUUID(); + const b = generateUUID(); + + expect(a).not.toBe(b); + }); + + it('has the correct length (36 characters)', () => { + expect(generateUUID()).toHaveLength(36); + }); + + it('always has version 4 indicator at position 14', () => { + for (let i = 0; i < 20; i++) { + const uuid = generateUUID(); + expect(uuid[14]).toBe('4'); + } + }); + + it('always has variant bits (8, 9, a, b) at position 19', () => { + for (let i = 0; i < 20; i++) { + const uuid = generateUUID(); + expect(['8', '9', 'a', 'b']).toContain(uuid[19]); + } + }); + + describe('fallback path (no crypto.randomUUID)', () => { + it('generates valid UUID v4 when crypto.randomUUID is unavailable', () => { + const originalRandomUUID = crypto.randomUUID; + try { + // Force the fallback by removing randomUUID + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (crypto as any).randomUUID = undefined; + + const uuid = generateUUID(); + + expect(uuid).toMatch(UUID_REGEX); + } finally { + crypto.randomUUID = originalRandomUUID; + } + }); + }); +}); + +describe('default export', () => { + it('exports generateUUID as default', async () => { + const mod = await import('./generateUUID'); + + expect(mod.default).toBe(mod.generateUUID); + }); +}); diff --git a/src/lib/utils/opticalRange.spec.ts b/src/lib/utils/opticalRange.spec.ts new file mode 100644 index 00000000..ce1b7d50 --- /dev/null +++ b/src/lib/utils/opticalRange.spec.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; +import { + formatDiopter, + formatRange, + formatCylinderRange, + formatSymmetricSphere, + collapseRangesForDisplay +} from './opticalRange'; + +describe('formatDiopter', () => { + it('prepends + sign for positive numbers', () => { + expect(formatDiopter(1.5)).toBe('+1.50'); + }); + + it('prepends + sign for zero', () => { + expect(formatDiopter(0)).toBe('+0.00'); + }); + + it('keeps - sign for negative numbers', () => { + expect(formatDiopter(-2.25)).toBe('-2.25'); + }); + + it('formats to two decimal places', () => { + expect(formatDiopter(3)).toBe('+3.00'); + }); + + it('rounds to two decimals', () => { + expect(formatDiopter(1.125)).toBe('+1.13'); + }); + + it('handles small negative values', () => { + expect(formatDiopter(-0.25)).toBe('-0.25'); + }); +}); + +describe('formatRange', () => { + it('returns em-dash when both min and max are null', () => { + expect(formatRange(null, null)).toBe('—'); + }); + + it('formats full range with both min and max', () => { + expect(formatRange(-6, 6)).toBe('-6.00 a +6.00'); + }); + + it('formats from-only when max is null', () => { + expect(formatRange(-2, null)).toBe('desde -2.00'); + }); + + it('formats to-only when min is null', () => { + expect(formatRange(null, 4)).toBe('hasta +4.00'); + }); + + it('handles zero values', () => { + expect(formatRange(0, 0)).toBe('+0.00 a +0.00'); + }); + + it('handles negative-only range', () => { + expect(formatRange(-8, -2)).toBe('-8.00 a -2.00'); + }); +}); + +describe('formatCylinderRange', () => { + it('swaps min and max for cylinder display', () => { + // cylinder min=-6, max=-0.25 → formatRange(max, min) → "-0.25 a -6.00" + expect(formatCylinderRange(-6, -0.25)).toBe('-0.25 a -6.00'); + }); + + it('returns em-dash when both are null', () => { + expect(formatCylinderRange(null, null)).toBe('—'); + }); + + it('handles only min provided (shows as hasta)', () => { + // formatRange(null, min) → "hasta formatDiopter(min)" + expect(formatCylinderRange(-4, null)).toBe('hasta -4.00'); + }); + + it('handles only max provided (shows as desde)', () => { + // formatRange(max, null) → "desde formatDiopter(max)" + expect(formatCylinderRange(null, -0.25)).toBe('desde -0.25'); + }); +}); + +describe('formatSymmetricSphere', () => { + it('formats ± notation when absMin is 0', () => { + expect(formatSymmetricSphere(0, 6)).toBe('±6.00'); + }); + + it('formats range ± notation when absMin > 0', () => { + expect(formatSymmetricSphere(2, 8)).toBe('±2.00 a ±8.00'); + }); + + it('handles equal absMin and absMax', () => { + expect(formatSymmetricSphere(4, 4)).toBe('±4.00 a ±4.00'); + }); +}); + +describe('collapseRangesForDisplay', () => { + it('returns empty array for empty input', () => { + expect(collapseRangesForDisplay([])).toEqual([]); + }); + + it('maps a range with all fields populated', () => { + const ranges = [ + { + id: 'r1', + lensCatalogItemId: 'item1', + sphereMin: -6, + sphereMax: 6, + cylinderMin: -4, + cylinderMax: -0.25, + additionMin: 0.75, + additionMax: 3.5, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z' + } + ]; + + const result = collapseRangesForDisplay(ranges); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('r1'); + expect(result[0].symmetric).toBe(false); + expect(result[0].sphereLabel).toBe('-6.00 a +6.00'); + expect(result[0].cylinderLabel).toBe('-0.25 a -4.00'); + expect(result[0].additionLabel).toBe('+0.75 a +3.50'); + }); + + it('returns null for cylinder and addition when not provided', () => { + const ranges = [ + { + id: 'r2', + lensCatalogItemId: 'item2', + sphereMin: -2, + sphereMax: 2, + cylinderMin: null, + cylinderMax: null, + additionMin: null, + additionMax: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z' + } + ]; + + const result = collapseRangesForDisplay(ranges); + + expect(result[0].cylinderLabel).toBeNull(); + expect(result[0].additionLabel).toBeNull(); + }); + + it('handles partially defined cylinder (only min)', () => { + const ranges = [ + { + id: 'r3', + lensCatalogItemId: 'item3', + sphereMin: 0, + sphereMax: 4, + cylinderMin: -2, + cylinderMax: null, + additionMin: null, + additionMax: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z' + } + ]; + + const result = collapseRangesForDisplay(ranges); + + expect(result[0].cylinderLabel).toBe('hasta -2.00'); + }); + + it('handles multiple ranges', () => { + const ranges = [ + { + id: 'a', + lensCatalogItemId: 'x', + sphereMin: -1, + sphereMax: 1, + cylinderMin: null, + cylinderMax: null, + additionMin: null, + additionMax: null, + createdAt: '', + updatedAt: '' + }, + { + id: 'b', + lensCatalogItemId: 'x', + sphereMin: -4, + sphereMax: 4, + cylinderMin: null, + cylinderMax: null, + additionMin: null, + additionMax: null, + createdAt: '', + updatedAt: '' + } + ]; + + const result = collapseRangesForDisplay(ranges); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('a'); + expect(result[1].id).toBe('b'); + }); +}); diff --git a/src/lib/utils/prescription.spec.ts b/src/lib/utils/prescription.spec.ts new file mode 100644 index 00000000..4a968be6 --- /dev/null +++ b/src/lib/utils/prescription.spec.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeOpticalValue, buildTreatments, toPrescriptionInsert } from './prescription'; + +describe('normalizeOpticalValue', () => { + it('returns null for null input', () => { + expect(normalizeOpticalValue(null)).toBeNull(); + }); + + it('returns null for undefined input', () => { + expect(normalizeOpticalValue(undefined)).toBeNull(); + }); + + it('returns null for zero', () => { + expect(normalizeOpticalValue(0)).toBeNull(); + }); + + it('preserves positive values', () => { + expect(normalizeOpticalValue(1.5)).toBe(1.5); + }); + + it('preserves negative values', () => { + expect(normalizeOpticalValue(-2.25)).toBe(-2.25); + }); + + it('preserves small non-zero values', () => { + expect(normalizeOpticalValue(0.25)).toBe(0.25); + }); +}); + +describe('buildTreatments', () => { + it('returns null when no treatments are selected', () => { + expect(buildTreatments({})).toBeNull(); + }); + + it('returns null when all booleans are false and no other text', () => { + expect( + buildTreatments({ + treatmentAntiReflective: false, + treatmentBlueBlock: false, + treatmentPhotochromic: false, + treatmentOther: '' + }) + ).toBeNull(); + }); + + it('builds treatments when antiReflective is true', () => { + const result = buildTreatments({ treatmentAntiReflective: true }); + + expect(result).not.toBeNull(); + expect(result!.antiReflective).toBe(true); + expect(result!.blueBlock).toBe(false); + expect(result!.photochromic).toBe(false); + expect(result!.other).toBeNull(); + }); + + it('builds treatments when blueBlock is true', () => { + const result = buildTreatments({ treatmentBlueBlock: true }); + + expect(result).not.toBeNull(); + expect(result!.blueBlock).toBe(true); + }); + + it('builds treatments when photochromic is true', () => { + const result = buildTreatments({ treatmentPhotochromic: true }); + + expect(result).not.toBeNull(); + expect(result!.photochromic).toBe(true); + }); + + it('builds treatments when other text is provided', () => { + const result = buildTreatments({ treatmentOther: 'Tinted' }); + + expect(result).not.toBeNull(); + expect(result!.other).toBe('Tinted'); + }); + + it('builds treatments with all fields set', () => { + const result = buildTreatments({ + treatmentAntiReflective: true, + treatmentBlueBlock: true, + treatmentPhotochromic: true, + treatmentOther: 'UV protection' + }); + + expect(result).toEqual({ + antiReflective: true, + blueBlock: true, + photochromic: true, + other: 'UV protection' + }); + }); +}); + +describe('toPrescriptionInsert', () => { + const minimalData = { + prescriptionDate: '2024-06-15' + }; + + it('sets customerId from argument', () => { + const result = toPrescriptionInsert('cust-123', minimalData); + + expect(result.customerId).toBe('cust-123'); + }); + + it('sets prescriptionDate from data', () => { + const result = toPrescriptionInsert('cust-1', minimalData); + + expect(result.prescriptionDate).toBe('2024-06-15'); + }); + + it('normalizes zero optical values to null', () => { + const data = { + prescriptionDate: '2024-01-01', + odSphere: 0, + odCylinder: 0, + odAxis: 0, + odAddition: 0, + osSphere: 0, + osCylinder: 0, + osAxis: 0, + osAddition: 0 + }; + + const result = toPrescriptionInsert('cust-1', data); + + expect(result.odSphere).toBeNull(); + expect(result.odCylinder).toBeNull(); + expect(result.odAxis).toBeNull(); + expect(result.odAddition).toBeNull(); + expect(result.osSphere).toBeNull(); + expect(result.osCylinder).toBeNull(); + expect(result.osAxis).toBeNull(); + expect(result.osAddition).toBeNull(); + }); + + it('preserves non-zero optical values', () => { + const data = { + prescriptionDate: '2024-01-01', + odSphere: -2.5, + odCylinder: -1.25, + odAxis: 90, + osSphere: -3.0, + osCylinder: -0.75, + osAxis: 180 + }; + + const result = toPrescriptionInsert('cust-1', data); + + expect(result.odSphere).toBe(-2.5); + expect(result.odCylinder).toBe(-1.25); + expect(result.odAxis).toBe(90); + expect(result.osSphere).toBe(-3.0); + expect(result.osCylinder).toBe(-0.75); + expect(result.osAxis).toBe(180); + }); + + it('defaults nullable fields to null when not provided', () => { + const result = toPrescriptionInsert('cust-1', minimalData); + + expect(result.dp).toBeNull(); + expect(result.npRight).toBeNull(); + expect(result.npLeft).toBeNull(); + expect(result.altura).toBeNull(); + expect(result.recommendedLensType).toBeNull(); + expect(result.notes).toBeNull(); + expect(result.doctorName).toBeNull(); + }); + + it('passes through optional fields when provided', () => { + const data = { + prescriptionDate: '2024-01-01', + dp: 64, + npRight: 32, + npLeft: 32, + altura: 22, + recommendedLensType: 'progressive' as const, + notes: 'Check again in 6 months', + doctorName: 'Dr. García' + }; + + const result = toPrescriptionInsert('cust-1', data); + + expect(result.dp).toBe(64); + expect(result.npRight).toBe(32); + expect(result.npLeft).toBe(32); + expect(result.altura).toBe(22); + expect(result.recommendedLensType).toBe('progressive'); + expect(result.notes).toBe('Check again in 6 months'); + expect(result.doctorName).toBe('Dr. García'); + }); + + it('builds treatments from form data', () => { + const data = { + prescriptionDate: '2024-01-01', + treatmentAntiReflective: true, + treatmentBlueBlock: true + }; + + const result = toPrescriptionInsert('cust-1', data); + + expect(result.treatments).not.toBeNull(); + expect(result.treatments!.antiReflective).toBe(true); + expect(result.treatments!.blueBlock).toBe(true); + }); + + it('sets treatments to null when no treatments selected', () => { + const result = toPrescriptionInsert('cust-1', minimalData); + + expect(result.treatments).toBeNull(); + }); + + it('defaults isCurrent to false when not provided', () => { + const result = toPrescriptionInsert('cust-1', minimalData); + + expect(result.isCurrent).toBe(false); + }); + + it('respects isCurrent when provided', () => { + const data = { prescriptionDate: '2024-01-01', isCurrent: true }; + + const result = toPrescriptionInsert('cust-1', data); + + expect(result.isCurrent).toBe(true); + }); +}); diff --git a/src/lib/utils/products.spec.ts b/src/lib/utils/products.spec.ts new file mode 100644 index 00000000..3654e504 --- /dev/null +++ b/src/lib/utils/products.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { isLowStock } from './products'; + +function makeProduct(overrides: { stock?: number | null; minStock?: number | null }) { + return { + id: 'prod-1', + name: 'Test Product', + sku: 'SKU-001', + stock: overrides.stock ?? null, + minStock: overrides.minStock ?? null, + // Satisfy ProductWithRelations shape minimally + brand: null, + supplier: null, + material: null + } as Parameters[0]; +} + +describe('isLowStock', () => { + it('returns false when stock is null', () => { + expect(isLowStock(makeProduct({ stock: null, minStock: 5 }))).toBe(false); + }); + + it('returns false when minStock is null', () => { + expect(isLowStock(makeProduct({ stock: 10, minStock: null }))).toBe(false); + }); + + it('returns false when both stock and minStock are null', () => { + expect(isLowStock(makeProduct({ stock: null, minStock: null }))).toBe(false); + }); + + it('returns true when stock equals minStock', () => { + expect(isLowStock(makeProduct({ stock: 5, minStock: 5 }))).toBe(true); + }); + + it('returns true when stock is below minStock', () => { + expect(isLowStock(makeProduct({ stock: 2, minStock: 5 }))).toBe(true); + }); + + it('returns false when stock is above minStock', () => { + expect(isLowStock(makeProduct({ stock: 10, minStock: 5 }))).toBe(false); + }); + + it('returns true when stock is zero and minStock is zero', () => { + expect(isLowStock(makeProduct({ stock: 0, minStock: 0 }))).toBe(true); + }); + + it('returns true when stock is zero and minStock is positive', () => { + expect(isLowStock(makeProduct({ stock: 0, minStock: 3 }))).toBe(true); + }); +}); From 3347a4bc7f2125248dda21dd3ec4dc32b7ea58e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:04:58 +0000 Subject: [PATCH 3/5] test: add comprehensive tests for 10 Zod schema files Add test coverage for auth, brands, customers, exchangeRates, lenses, reports, search, settings, suppliers, and users schemas. Tests cover: - Valid inputs with safeParse success - Required field validation - Invalid types and boundary lengths - Default value application - Transform behavior (trim, lowercase, coercion) - Refinements and cross-field validation - Edge cases (empty strings, boundary values) 220 tests across 10 new spec files, all passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: NanezX <81595884+NanezX@users.noreply.github.com> --- src/lib/schemas/auth.spec.ts | 81 ++++++ src/lib/schemas/brands.spec.ts | 158 +++++++++++ src/lib/schemas/customers.spec.ts | 192 +++++++++++++ src/lib/schemas/exchangeRates.spec.ts | 167 ++++++++++++ src/lib/schemas/lenses.spec.ts | 372 ++++++++++++++++++++++++++ src/lib/schemas/reports.spec.ts | 47 ++++ src/lib/schemas/search.spec.ts | 31 +++ src/lib/schemas/settings.spec.ts | 151 +++++++++++ src/lib/schemas/suppliers.spec.ts | 336 +++++++++++++++++++++++ src/lib/schemas/users.spec.ts | 226 ++++++++++++++++ 10 files changed, 1761 insertions(+) create mode 100644 src/lib/schemas/auth.spec.ts create mode 100644 src/lib/schemas/brands.spec.ts create mode 100644 src/lib/schemas/customers.spec.ts create mode 100644 src/lib/schemas/exchangeRates.spec.ts create mode 100644 src/lib/schemas/lenses.spec.ts create mode 100644 src/lib/schemas/reports.spec.ts create mode 100644 src/lib/schemas/search.spec.ts create mode 100644 src/lib/schemas/settings.spec.ts create mode 100644 src/lib/schemas/suppliers.spec.ts create mode 100644 src/lib/schemas/users.spec.ts diff --git a/src/lib/schemas/auth.spec.ts b/src/lib/schemas/auth.spec.ts new file mode 100644 index 00000000..8f6b3b92 --- /dev/null +++ b/src/lib/schemas/auth.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { loginSchema } from '$lib/schemas/auth'; + +// ── Helpers ───────────────────────────────────────────────────────────── + +function makeLogin(overrides: Record = {}) { + return { + identifier: 'admin_user', + password: 'securePass1', + ...overrides + }; +} + +// ── loginSchema ───────────────────────────────────────────────────────── + +describe('loginSchema', () => { + it('accepts valid credentials', () => { + const result = loginSchema.safeParse(makeLogin()); + expect(result.success).toBe(true); + }); + + it('trims and lowercases the identifier', () => { + const result = loginSchema.safeParse(makeLogin({ identifier: ' ADMIN_USER ' })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.identifier).toBe('admin_user'); + } + }); + + it('rejects identifier shorter than 4 characters', () => { + const result = loginSchema.safeParse(makeLogin({ identifier: 'abc' })); + expect(result.success).toBe(false); + }); + + it('accepts identifier with exactly 4 characters', () => { + const result = loginSchema.safeParse(makeLogin({ identifier: 'abcd' })); + expect(result.success).toBe(true); + }); + + it('rejects identifier longer than 255 characters', () => { + const result = loginSchema.safeParse(makeLogin({ identifier: 'a'.repeat(256) })); + expect(result.success).toBe(false); + }); + + it('rejects empty identifier', () => { + const result = loginSchema.safeParse(makeLogin({ identifier: '' })); + expect(result.success).toBe(false); + }); + + it('rejects missing identifier', () => { + const { identifier: _, ...rest } = makeLogin(); + const result = loginSchema.safeParse(rest); + expect(result.success).toBe(false); + }); + + it('rejects password shorter than 8 characters', () => { + const result = loginSchema.safeParse(makeLogin({ password: 'short' })); + expect(result.success).toBe(false); + }); + + it('rejects password longer than 24 characters', () => { + const result = loginSchema.safeParse(makeLogin({ password: 'a'.repeat(25) })); + expect(result.success).toBe(false); + }); + + it('rejects missing password', () => { + const { password: _, ...rest } = makeLogin(); + const result = loginSchema.safeParse(rest); + expect(result.success).toBe(false); + }); + + it('accepts password with exactly 8 characters', () => { + const result = loginSchema.safeParse(makeLogin({ password: '12345678' })); + expect(result.success).toBe(true); + }); + + it('accepts password with exactly 24 characters', () => { + const result = loginSchema.safeParse(makeLogin({ password: 'a'.repeat(24) })); + expect(result.success).toBe(true); + }); +}); diff --git a/src/lib/schemas/brands.spec.ts b/src/lib/schemas/brands.spec.ts new file mode 100644 index 00000000..0e1cc1eb --- /dev/null +++ b/src/lib/schemas/brands.spec.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest'; +import { + ListBrandsSchema, + CreateBrandSchema, + UpdateBrandSchema, + BrandIdSchema, + ReactivateBrandSchema, + QuickCreateBrandSchema +} from '$lib/schemas/brands'; + +// ── Helpers ───────────────────────────────────────────────────────────── + +function makeBrand(overrides: Record = {}) { + return { + name: 'Ray-Ban', + description: 'Luxury eyewear brand', + country: 'Italy', + website: 'https://www.ray-ban.com', + ...overrides + }; +} + +// ── ListBrandsSchema ──────────────────────────────────────────────────── + +describe('ListBrandsSchema', () => { + it('applies defaults for page, perPage and includeDeleted', () => { + const result = ListBrandsSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.page).toBe(1); + expect(result.data.perPage).toBe(10); + expect(result.data.includeDeleted).toBe(false); + } + }); + + it('accepts explicit pagination values', () => { + const result = ListBrandsSchema.safeParse({ page: 2, perPage: 50, includeDeleted: true }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.page).toBe(2); + expect(result.data.includeDeleted).toBe(true); + } + }); + + it('rejects page less than 1', () => { + const result = ListBrandsSchema.safeParse({ page: 0 }); + expect(result.success).toBe(false); + }); + + it('rejects perPage greater than 100', () => { + const result = ListBrandsSchema.safeParse({ perPage: 101 }); + expect(result.success).toBe(false); + }); +}); + +// ── CreateBrandSchema ─────────────────────────────────────────────────── + +describe('CreateBrandSchema', () => { + it('accepts a valid brand', () => { + const result = CreateBrandSchema.safeParse(makeBrand()); + expect(result.success).toBe(true); + }); + + it('accepts a brand with only required fields', () => { + const result = CreateBrandSchema.safeParse({ name: 'Oakley', website: '' }); + expect(result.success).toBe(true); + }); + + it('rejects empty name', () => { + const result = CreateBrandSchema.safeParse(makeBrand({ name: '' })); + expect(result.success).toBe(false); + }); + + it('rejects missing name', () => { + const { name: _, ...rest } = makeBrand(); + const result = CreateBrandSchema.safeParse(rest); + expect(result.success).toBe(false); + }); + + it('rejects name exceeding 100 characters', () => { + const result = CreateBrandSchema.safeParse(makeBrand({ name: 'A'.repeat(101) })); + expect(result.success).toBe(false); + }); + + it('rejects invalid website URL', () => { + const result = CreateBrandSchema.safeParse(makeBrand({ website: 'not-a-url' })); + expect(result.success).toBe(false); + }); + + it('accepts empty string as website (optional URL)', () => { + const result = CreateBrandSchema.safeParse(makeBrand({ website: '' })); + expect(result.success).toBe(true); + }); +}); + +// ── UpdateBrandSchema ─────────────────────────────────────────────────── + +describe('UpdateBrandSchema', () => { + it('accepts a valid update with id and partial fields', () => { + const result = UpdateBrandSchema.safeParse({ + id: crypto.randomUUID(), + name: 'Updated Brand' + }); + expect(result.success).toBe(true); + }); + + it('requires a valid UUID for id', () => { + const result = UpdateBrandSchema.safeParse({ id: 'not-a-uuid', name: 'Test' }); + expect(result.success).toBe(false); + }); + + it('accepts update with only id (all fields optional)', () => { + const result = UpdateBrandSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); +}); + +// ── BrandIdSchema ─────────────────────────────────────────────────────── + +describe('BrandIdSchema', () => { + it('accepts a valid UUID', () => { + const result = BrandIdSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); + + it('rejects invalid UUID', () => { + const result = BrandIdSchema.safeParse({ id: 'abc' }); + expect(result.success).toBe(false); + }); +}); + +// ── ReactivateBrandSchema ─────────────────────────────────────────────── + +describe('ReactivateBrandSchema', () => { + it('accepts a valid UUID for deletedBrandId', () => { + const result = ReactivateBrandSchema.safeParse({ deletedBrandId: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); + + it('rejects missing deletedBrandId', () => { + const result = ReactivateBrandSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects invalid UUID for deletedBrandId', () => { + const result = ReactivateBrandSchema.safeParse({ deletedBrandId: 'bad' }); + expect(result.success).toBe(false); + }); +}); + +// ── QuickCreateBrandSchema ────────────────────────────────────────────── + +describe('QuickCreateBrandSchema', () => { + it('is the same schema as CreateBrandSchema', () => { + const result = QuickCreateBrandSchema.safeParse({ name: 'Quick Brand', website: '' }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/lib/schemas/customers.spec.ts b/src/lib/schemas/customers.spec.ts new file mode 100644 index 00000000..8c1757a4 --- /dev/null +++ b/src/lib/schemas/customers.spec.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'vitest'; +import { + ListCustomersSchema, + CustomerIdSchema, + CreateCustomerSchema, + UpdateCustomerSchema, + CreateCustomerWithPrescriptionSchema, + ReactivateCustomerSchema +} from '$lib/schemas/customers'; + +// ── Helpers ───────────────────────────────────────────────────────────── + +function makeCustomer(overrides: Record = {}) { + return { + firstName: 'Juan', + lastName: 'Pérez', + idNumber: 'V-12345678', + birthDate: '1990-05-15', + primaryPhone: '+584121234567', + email: 'juan@example.com', + address: 'Calle 1', + notes: '', + ...overrides + }; +} + +// ── ListCustomersSchema ───────────────────────────────────────────────── + +describe('ListCustomersSchema', () => { + it('applies defaults for pagination', () => { + const result = ListCustomersSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.page).toBe(1); + expect(result.data.perPage).toBe(10); + expect(result.data.includeDeleted).toBe(false); + } + }); +}); + +// ── CustomerIdSchema ──────────────────────────────────────────────────── + +describe('CustomerIdSchema', () => { + it('accepts a valid UUID', () => { + const result = CustomerIdSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); + + it('rejects invalid UUID', () => { + const result = CustomerIdSchema.safeParse({ id: 'bad-id' }); + expect(result.success).toBe(false); + }); +}); + +// ── CreateCustomerSchema ──────────────────────────────────────────────── + +describe('CreateCustomerSchema', () => { + it('accepts a valid customer', () => { + const result = CreateCustomerSchema.safeParse(makeCustomer()); + expect(result.success).toBe(true); + }); + + it('rejects empty firstName', () => { + const result = CreateCustomerSchema.safeParse(makeCustomer({ firstName: '' })); + expect(result.success).toBe(false); + }); + + it('rejects empty lastName', () => { + const result = CreateCustomerSchema.safeParse(makeCustomer({ lastName: '' })); + expect(result.success).toBe(false); + }); + + it('rejects invalid idNumber format', () => { + const result = CreateCustomerSchema.safeParse(makeCustomer({ idNumber: '1234' })); + expect(result.success).toBe(false); + }); + + it('accepts valid idNumber formats', () => { + for (const id of ['V-12345678', 'E-123456', 'J-12345678', 'G-12345678']) { + const result = CreateCustomerSchema.safeParse(makeCustomer({ idNumber: id })); + expect(result.success).toBe(true); + } + }); + + it('rejects invalid birthDate', () => { + const result = CreateCustomerSchema.safeParse(makeCustomer({ birthDate: 'not-a-date' })); + expect(result.success).toBe(false); + }); + + it('rejects invalid phone number', () => { + const result = CreateCustomerSchema.safeParse(makeCustomer({ primaryPhone: '123' })); + expect(result.success).toBe(false); + }); + + it('accepts empty email (optional)', () => { + const result = CreateCustomerSchema.safeParse(makeCustomer({ email: '' })); + expect(result.success).toBe(true); + }); + + it('rejects malformed email', () => { + const result = CreateCustomerSchema.safeParse(makeCustomer({ email: 'bad-email' })); + expect(result.success).toBe(false); + }); + + it('allows optional fields to be omitted', () => { + const result = CreateCustomerSchema.safeParse({ + firstName: 'Ana', + lastName: 'Gómez', + idNumber: 'V-87654321', + birthDate: '1985-12-01', + primaryPhone: '+584141234567', + email: '' + }); + expect(result.success).toBe(true); + }); + + it('accepts optional secondaryPhones array', () => { + const result = CreateCustomerSchema.safeParse( + makeCustomer({ secondaryPhones: ['+584161234567'] }) + ); + expect(result.success).toBe(true); + }); + + it('rejects invalid secondary phone in array', () => { + const result = CreateCustomerSchema.safeParse(makeCustomer({ secondaryPhones: ['123'] })); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateCustomerSchema ──────────────────────────────────────────────── + +describe('UpdateCustomerSchema', () => { + it('accepts a valid update with id and partial fields', () => { + const result = UpdateCustomerSchema.safeParse({ + id: crypto.randomUUID(), + firstName: 'Updated' + }); + expect(result.success).toBe(true); + }); + + it('requires a valid UUID for id', () => { + const result = UpdateCustomerSchema.safeParse({ id: 'bad', firstName: 'Test' }); + expect(result.success).toBe(false); + }); + + it('accepts update with only id', () => { + const result = UpdateCustomerSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); +}); + +// ── CreateCustomerWithPrescriptionSchema ──────────────────────────────── + +describe('CreateCustomerWithPrescriptionSchema', () => { + it('accepts customer without prescription', () => { + const result = CreateCustomerWithPrescriptionSchema.safeParse(makeCustomer()); + expect(result.success).toBe(true); + }); + + it('accepts customer with a valid prescription', () => { + const result = CreateCustomerWithPrescriptionSchema.safeParse( + makeCustomer({ + prescription: { + prescriptionDate: '2024-01-15', + odSphere: -2.0, + odCylinder: -0.5, + odAxis: 90, + osSphere: -1.75, + osCylinder: -0.25, + osAxis: 85, + recommendedLensType: 'MONOFOCAL', + doctorName: 'Dr. García' + } + }) + ); + expect(result.success).toBe(true); + }); +}); + +// ── ReactivateCustomerSchema ──────────────────────────────────────────── + +describe('ReactivateCustomerSchema', () => { + it('accepts a valid UUID', () => { + const result = ReactivateCustomerSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); + + it('rejects missing id', () => { + const result = ReactivateCustomerSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); diff --git a/src/lib/schemas/exchangeRates.spec.ts b/src/lib/schemas/exchangeRates.spec.ts new file mode 100644 index 00000000..b56d61a7 --- /dev/null +++ b/src/lib/schemas/exchangeRates.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest'; +import { + ExchangeRateIdSchema, + ListCurrenciesSchema, + UpsertExchangeRateSchema, + BatchUpsertRatesSchema, + GetRatesForDateSchema +} from '$lib/schemas/exchangeRates'; + +// ── Helpers ───────────────────────────────────────────────────────────── + +function makeRate(overrides: Record = {}) { + return { + currencyCode: 'USD_BCV', + rateToVes: 36.5, + effectiveDate: '2024-06-15', + source: 'manual', + ...overrides + }; +} + +// ── ExchangeRateIdSchema ──────────────────────────────────────────────── + +describe('ExchangeRateIdSchema', () => { + it('accepts a valid UUID', () => { + const result = ExchangeRateIdSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); + + it('rejects invalid UUID', () => { + const result = ExchangeRateIdSchema.safeParse({ id: 'nope' }); + expect(result.success).toBe(false); + }); +}); + +// ── ListCurrenciesSchema ──────────────────────────────────────────────── + +describe('ListCurrenciesSchema', () => { + it('defaults activeOnly to true', () => { + const result = ListCurrenciesSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.activeOnly).toBe(true); + } + }); + + it('accepts explicit activeOnly false', () => { + const result = ListCurrenciesSchema.safeParse({ activeOnly: false }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.activeOnly).toBe(false); + } + }); +}); + +// ── UpsertExchangeRateSchema ──────────────────────────────────────────── + +describe('UpsertExchangeRateSchema', () => { + it('accepts a valid rate', () => { + const result = UpsertExchangeRateSchema.safeParse(makeRate()); + expect(result.success).toBe(true); + }); + + it('defaults source to manual when omitted', () => { + const { source: _, ...rest } = makeRate(); + const result = UpsertExchangeRateSchema.safeParse(rest); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.source).toBe('manual'); + } + }); + + it('rejects invalid currency code', () => { + const result = UpsertExchangeRateSchema.safeParse(makeRate({ currencyCode: 'INVALID' })); + expect(result.success).toBe(false); + }); + + it('rejects rate less than 0.01', () => { + const result = UpsertExchangeRateSchema.safeParse(makeRate({ rateToVes: 0 })); + expect(result.success).toBe(false); + }); + + it('rejects invalid date format', () => { + const result = UpsertExchangeRateSchema.safeParse(makeRate({ effectiveDate: '15/06/2024' })); + expect(result.success).toBe(false); + }); + + it('coerces string rateToVes to number', () => { + const result = UpsertExchangeRateSchema.safeParse(makeRate({ rateToVes: '42.5' })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.rateToVes).toBe(42.5); + } + }); + + it('accepts all valid currency codes', () => { + for (const code of ['USD_BCV', 'EUR_BCV', 'USDT', 'USD_PAYPAL']) { + const result = UpsertExchangeRateSchema.safeParse(makeRate({ currencyCode: code })); + expect(result.success).toBe(true); + } + }); + + it('allows optional notes', () => { + const result = UpsertExchangeRateSchema.safeParse(makeRate({ notes: 'BCV rate' })); + expect(result.success).toBe(true); + }); +}); + +// ── BatchUpsertRatesSchema ────────────────────────────────────────────── + +describe('BatchUpsertRatesSchema', () => { + it('accepts a valid batch of rates', () => { + const result = BatchUpsertRatesSchema.safeParse({ + rates: [ + { currencyCode: 'USD_BCV', rateToVes: 36.5 }, + { currencyCode: 'EUR_BCV', rateToVes: 40.2 } + ], + effectiveDate: '2024-06-15' + }); + expect(result.success).toBe(true); + }); + + it('defaults source to manual', () => { + const result = BatchUpsertRatesSchema.safeParse({ + rates: [{ currencyCode: 'USDT', rateToVes: 36.0 }], + effectiveDate: '2024-06-15' + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.source).toBe('manual'); + } + }); + + it('rejects invalid currency code in batch', () => { + const result = BatchUpsertRatesSchema.safeParse({ + rates: [{ currencyCode: 'FAKE', rateToVes: 10 }], + effectiveDate: '2024-06-15' + }); + expect(result.success).toBe(false); + }); + + it('rejects missing effectiveDate', () => { + const result = BatchUpsertRatesSchema.safeParse({ + rates: [{ currencyCode: 'USD_BCV', rateToVes: 36.5 }] + }); + expect(result.success).toBe(false); + }); +}); + +// ── GetRatesForDateSchema ─────────────────────────────────────────────── + +describe('GetRatesForDateSchema', () => { + it('accepts a valid ISO date', () => { + const result = GetRatesForDateSchema.safeParse({ date: '2024-06-15' }); + expect(result.success).toBe(true); + }); + + it('rejects invalid date format', () => { + const result = GetRatesForDateSchema.safeParse({ date: 'June 15' }); + expect(result.success).toBe(false); + }); + + it('rejects missing date', () => { + const result = GetRatesForDateSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); diff --git a/src/lib/schemas/lenses.spec.ts b/src/lib/schemas/lenses.spec.ts new file mode 100644 index 00000000..f7661ed4 --- /dev/null +++ b/src/lib/schemas/lenses.spec.ts @@ -0,0 +1,372 @@ +import { describe, it, expect } from 'vitest'; +import { + CreateLensMaterialSchema, + UpdateLensMaterialSchema, + OpticalRangeSchema, + CreateLensCatalogItemSchema, + UpdateLensCatalogItemSchema, + LensIdSchema, + ListLensCatalogSchema, + LensSupplierIdSchema +} from '$lib/schemas/lenses'; +import { + LensType, + LensCatalogSource, + LensPriceType, + LensInventoryMode +} from '$lib/shared/enums'; + +// ── Helpers ───────────────────────────────────────────────────────────── + +function makeMaterial(overrides: Record = {}) { + return { + name: 'CR-39', + code: 'CR39', + refractiveIndex: 1.5, + description: 'Standard plastic', + ...overrides + }; +} + +function makeRange(overrides: Record = {}) { + return { + sphereMin: -6, + sphereMax: 6, + ...overrides + }; +} + +function makeCatalogItem(overrides: Record = {}) { + return { + supplierId: crypto.randomUUID(), + name: 'Monofocal CR-39', + type: LensType.MONOFOCAL, + materialId: crypto.randomUUID(), + ranges: JSON.stringify([makeRange()]), + basePrice: 10, + salePrice: 25, + ...overrides + }; +} + +// ── CreateLensMaterialSchema ──────────────────────────────────────────── + +describe('CreateLensMaterialSchema', () => { + it('accepts a valid material', () => { + const result = CreateLensMaterialSchema.safeParse(makeMaterial()); + expect(result.success).toBe(true); + }); + + it('rejects empty name', () => { + const result = CreateLensMaterialSchema.safeParse(makeMaterial({ name: '' })); + expect(result.success).toBe(false); + }); + + it('rejects empty code', () => { + const result = CreateLensMaterialSchema.safeParse(makeMaterial({ code: '' })); + expect(result.success).toBe(false); + }); + + it('rejects code longer than 50 characters', () => { + const result = CreateLensMaterialSchema.safeParse(makeMaterial({ code: 'X'.repeat(51) })); + expect(result.success).toBe(false); + }); + + it('rejects refractive index below 1.0', () => { + const result = CreateLensMaterialSchema.safeParse(makeMaterial({ refractiveIndex: 0.5 })); + expect(result.success).toBe(false); + }); + + it('rejects refractive index above 2.0', () => { + const result = CreateLensMaterialSchema.safeParse(makeMaterial({ refractiveIndex: 2.5 })); + expect(result.success).toBe(false); + }); + + it('accepts boundary refractive index values', () => { + const low = CreateLensMaterialSchema.safeParse(makeMaterial({ refractiveIndex: 1.0 })); + const high = CreateLensMaterialSchema.safeParse(makeMaterial({ refractiveIndex: 2.0 })); + expect(low.success).toBe(true); + expect(high.success).toBe(true); + }); +}); + +// ── UpdateLensMaterialSchema ──────────────────────────────────────────── + +describe('UpdateLensMaterialSchema', () => { + it('accepts a valid update with id', () => { + const result = UpdateLensMaterialSchema.safeParse({ + id: crypto.randomUUID(), + name: 'Updated' + }); + expect(result.success).toBe(true); + }); + + it('accepts isActive field', () => { + const result = UpdateLensMaterialSchema.safeParse({ + id: crypto.randomUUID(), + isActive: false + }); + expect(result.success).toBe(true); + }); + + it('rejects missing id', () => { + const result = UpdateLensMaterialSchema.safeParse({ name: 'Test' }); + expect(result.success).toBe(false); + }); +}); + +// ── OpticalRangeSchema ────────────────────────────────────────────────── + +describe('OpticalRangeSchema', () => { + it('accepts a valid range', () => { + const result = OpticalRangeSchema.safeParse(makeRange()); + expect(result.success).toBe(true); + }); + + it('rejects sphereMin > sphereMax', () => { + const result = OpticalRangeSchema.safeParse(makeRange({ sphereMin: 6, sphereMax: -6 })); + expect(result.success).toBe(false); + }); + + it('accepts equal sphere values', () => { + const result = OpticalRangeSchema.safeParse(makeRange({ sphereMin: 0, sphereMax: 0 })); + expect(result.success).toBe(true); + }); + + it('rejects cylinderMin > cylinderMax', () => { + const result = OpticalRangeSchema.safeParse( + makeRange({ cylinderMin: -1, cylinderMax: -4 }) + ); + expect(result.success).toBe(false); + }); + + it('accepts valid cylinder range', () => { + const result = OpticalRangeSchema.safeParse( + makeRange({ cylinderMin: -4, cylinderMax: -1 }) + ); + expect(result.success).toBe(true); + }); + + it('rejects cylinderMin without cylinderMax', () => { + const result = OpticalRangeSchema.safeParse(makeRange({ cylinderMin: -2 })); + expect(result.success).toBe(false); + }); + + it('rejects additionMin > additionMax', () => { + const result = OpticalRangeSchema.safeParse( + makeRange({ additionMin: 3, additionMax: 1 }) + ); + expect(result.success).toBe(false); + }); + + it('accepts valid addition range', () => { + const result = OpticalRangeSchema.safeParse( + makeRange({ additionMin: 1, additionMax: 3 }) + ); + expect(result.success).toBe(true); + }); + + it('rejects additionMax without additionMin', () => { + const result = OpticalRangeSchema.safeParse(makeRange({ additionMax: 2 })); + expect(result.success).toBe(false); + }); +}); + +// ── CreateLensCatalogItemSchema ───────────────────────────────────────── + +describe('CreateLensCatalogItemSchema', () => { + it('accepts a valid catalog item', () => { + const result = CreateLensCatalogItemSchema.safeParse(makeCatalogItem()); + expect(result.success).toBe(true); + }); + + it('defaults source to LAB', () => { + const result = CreateLensCatalogItemSchema.safeParse(makeCatalogItem()); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.source).toBe(LensCatalogSource.LAB); + } + }); + + it('defaults boolean fields to false', () => { + const result = CreateLensCatalogItemSchema.safeParse(makeCatalogItem()); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.hasAr).toBe(false); + expect(result.data.hasBluecut).toBe(false); + expect(result.data.isPhotochromic).toBe(false); + } + }); + + it('defaults priceType to UNIT', () => { + const result = CreateLensCatalogItemSchema.safeParse(makeCatalogItem()); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.priceType).toBe(LensPriceType.UNIT); + } + }); + + it('defaults inventoryMode to ON_DEMAND', () => { + const result = CreateLensCatalogItemSchema.safeParse(makeCatalogItem()); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.inventoryMode).toBe(LensInventoryMode.ON_DEMAND); + } + }); + + it('rejects FINISHED source without ranges', () => { + const result = CreateLensCatalogItemSchema.safeParse( + makeCatalogItem({ source: LensCatalogSource.FINISHED, ranges: '[]' }) + ); + expect(result.success).toBe(false); + }); + + it('accepts FINISHED source with ranges', () => { + const result = CreateLensCatalogItemSchema.safeParse( + makeCatalogItem({ source: LensCatalogSource.FINISHED }) + ); + expect(result.success).toBe(true); + }); + + it('accepts LAB source without ranges', () => { + const result = CreateLensCatalogItemSchema.safeParse(makeCatalogItem({ ranges: '[]' })); + expect(result.success).toBe(true); + }); + + it('rejects negative basePrice', () => { + const result = CreateLensCatalogItemSchema.safeParse(makeCatalogItem({ basePrice: -1 })); + expect(result.success).toBe(false); + }); + + it('rejects invalid lens type', () => { + const result = CreateLensCatalogItemSchema.safeParse(makeCatalogItem({ type: 'INVALID' })); + expect(result.success).toBe(false); + }); + + it('coerces string booleans', () => { + const result = CreateLensCatalogItemSchema.safeParse( + makeCatalogItem({ hasAr: 'true', hasBluecut: 'false' }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.hasAr).toBe(true); + expect(result.data.hasBluecut).toBe(false); + } + }); + + it('coerces string prices to numbers', () => { + const result = CreateLensCatalogItemSchema.safeParse( + makeCatalogItem({ basePrice: '15.5', salePrice: '30' }) + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.basePrice).toBe(15.5); + expect(result.data.salePrice).toBe(30); + } + }); + + it('accepts pending_ supplierId for inline creation', () => { + const result = CreateLensCatalogItemSchema.safeParse( + makeCatalogItem({ supplierId: 'pending_new-supplier' }) + ); + expect(result.success).toBe(true); + }); + + it('accepts pending_material_ materialId for inline creation', () => { + const result = CreateLensCatalogItemSchema.safeParse( + makeCatalogItem({ materialId: 'pending_material_new' }) + ); + expect(result.success).toBe(true); + }); +}); + +// ── UpdateLensCatalogItemSchema ───────────────────────────────────────── + +describe('UpdateLensCatalogItemSchema', () => { + it('accepts a valid update with id', () => { + const result = UpdateLensCatalogItemSchema.safeParse({ + id: crypto.randomUUID(), + name: 'Updated Lens' + }); + expect(result.success).toBe(true); + }); + + it('accepts isActive as coerced boolean', () => { + const result = UpdateLensCatalogItemSchema.safeParse({ + id: crypto.randomUUID(), + isActive: 'false' + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isActive).toBe(false); + } + }); + + it('rejects missing id', () => { + const result = UpdateLensCatalogItemSchema.safeParse({ name: 'Test' }); + expect(result.success).toBe(false); + }); +}); + +// ── LensIdSchema ──────────────────────────────────────────────────────── + +describe('LensIdSchema', () => { + it('accepts a valid UUID', () => { + const result = LensIdSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); + + it('rejects invalid UUID', () => { + const result = LensIdSchema.safeParse({ id: 'bad' }); + expect(result.success).toBe(false); + }); +}); + +// ── ListLensCatalogSchema ─────────────────────────────────────────────── + +describe('ListLensCatalogSchema', () => { + it('accepts empty object (all optional)', () => { + const result = ListLensCatalogSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('accepts search and filter fields', () => { + const result = ListLensCatalogSchema.safeParse({ + search: 'mono', + source: LensCatalogSource.LAB, + type: LensType.PROGRESSIVE, + supplierId: crypto.randomUUID(), + materialId: crypto.randomUUID() + }); + expect(result.success).toBe(true); + }); + + it('rejects invalid source enum', () => { + const result = ListLensCatalogSchema.safeParse({ source: 'INVALID' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid type enum', () => { + const result = ListLensCatalogSchema.safeParse({ type: 'INVALID' }); + expect(result.success).toBe(false); + }); +}); + +// ── LensSupplierIdSchema ──────────────────────────────────────────────── + +describe('LensSupplierIdSchema', () => { + it('accepts a valid UUID', () => { + const result = LensSupplierIdSchema.safeParse({ supplierId: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); + + it('rejects invalid UUID', () => { + const result = LensSupplierIdSchema.safeParse({ supplierId: 'bad' }); + expect(result.success).toBe(false); + }); + + it('rejects missing supplierId', () => { + const result = LensSupplierIdSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); diff --git a/src/lib/schemas/reports.spec.ts b/src/lib/schemas/reports.spec.ts new file mode 100644 index 00000000..c1afacd4 --- /dev/null +++ b/src/lib/schemas/reports.spec.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { DateRangeSchema } from '$lib/schemas/reports'; + +// ── DateRangeSchema ───────────────────────────────────────────────────── + +describe('DateRangeSchema', () => { + it('accepts valid date range', () => { + const result = DateRangeSchema.safeParse({ + dateFrom: '2024-01-01', + dateTo: '2024-12-31' + }); + expect(result.success).toBe(true); + }); + + it('rejects empty dateFrom', () => { + const result = DateRangeSchema.safeParse({ dateFrom: '', dateTo: '2024-12-31' }); + expect(result.success).toBe(false); + }); + + it('rejects empty dateTo', () => { + const result = DateRangeSchema.safeParse({ dateFrom: '2024-01-01', dateTo: '' }); + expect(result.success).toBe(false); + }); + + it('rejects missing dateFrom', () => { + const result = DateRangeSchema.safeParse({ dateTo: '2024-12-31' }); + expect(result.success).toBe(false); + }); + + it('rejects missing dateTo', () => { + const result = DateRangeSchema.safeParse({ dateFrom: '2024-01-01' }); + expect(result.success).toBe(false); + }); + + it('rejects empty object', () => { + const result = DateRangeSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('accepts any non-empty string (no date format validation)', () => { + const result = DateRangeSchema.safeParse({ + dateFrom: 'yesterday', + dateTo: 'today' + }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/lib/schemas/search.spec.ts b/src/lib/schemas/search.spec.ts new file mode 100644 index 00000000..83d41da2 --- /dev/null +++ b/src/lib/schemas/search.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { UniversalSearchSchema } from '$lib/schemas/search'; + +// ── UniversalSearchSchema ─────────────────────────────────────────────── + +describe('UniversalSearchSchema', () => { + it('accepts a non-empty query', () => { + const result = UniversalSearchSchema.safeParse({ query: 'sunglasses' }); + expect(result.success).toBe(true); + }); + + it('rejects empty query', () => { + const result = UniversalSearchSchema.safeParse({ query: '' }); + expect(result.success).toBe(false); + }); + + it('rejects missing query', () => { + const result = UniversalSearchSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('accepts single character query', () => { + const result = UniversalSearchSchema.safeParse({ query: 'a' }); + expect(result.success).toBe(true); + }); + + it('accepts long query', () => { + const result = UniversalSearchSchema.safeParse({ query: 'a'.repeat(500) }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/lib/schemas/settings.spec.ts b/src/lib/schemas/settings.spec.ts new file mode 100644 index 00000000..6d18d478 --- /dev/null +++ b/src/lib/schemas/settings.spec.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest'; +import { + UpdateSettingsSchema, + UpdateProfileSchema, + ChangePasswordSchema +} from '$lib/schemas/settings'; + +// ── UpdateSettingsSchema ──────────────────────────────────────────────── + +describe('UpdateSettingsSchema', () => { + it('accepts minimal settings with required email field', () => { + const result = UpdateSettingsSchema.safeParse({ businessEmail: '' }); + expect(result.success).toBe(true); + }); + + it('accepts full settings', () => { + const result = UpdateSettingsSchema.safeParse({ + businessName: 'Óptica Central', + businessRif: 'J-12345678-4', + businessPhone: '+584121234567', + businessEmail: 'info@optica.com', + businessAddress: 'Av. Principal', + businessWebsite: 'https://optica.com', + businessLogo: 'logo.png' + }); + expect(result.success).toBe(true); + }); + + it('accepts empty email string', () => { + const result = UpdateSettingsSchema.safeParse({ businessEmail: '' }); + expect(result.success).toBe(true); + }); + + it('rejects invalid email format', () => { + const result = UpdateSettingsSchema.safeParse({ businessEmail: 'not-email' }); + expect(result.success).toBe(false); + }); + + it('accepts empty RIF (optional)', () => { + const result = UpdateSettingsSchema.safeParse({ businessEmail: '', businessRif: '' }); + expect(result.success).toBe(true); + }); + + it('rejects invalid RIF format', () => { + const result = UpdateSettingsSchema.safeParse({ + businessEmail: '', + businessRif: 'INVALID' + }); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateProfileSchema ───────────────────────────────────────────────── + +describe('UpdateProfileSchema', () => { + it('accepts valid profile', () => { + const result = UpdateProfileSchema.safeParse({ + fullName: 'Carlos López', + email: 'carlos@example.com' + }); + expect(result.success).toBe(true); + }); + + it('rejects empty fullName', () => { + const result = UpdateProfileSchema.safeParse({ + fullName: '', + email: 'carlos@example.com' + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid email', () => { + const result = UpdateProfileSchema.safeParse({ + fullName: 'Carlos', + email: 'bad' + }); + expect(result.success).toBe(false); + }); + + it('rejects missing email', () => { + const result = UpdateProfileSchema.safeParse({ fullName: 'Carlos' }); + expect(result.success).toBe(false); + }); + + it('rejects missing fullName', () => { + const result = UpdateProfileSchema.safeParse({ email: 'test@test.com' }); + expect(result.success).toBe(false); + }); + + it('rejects fullName exceeding 100 characters', () => { + const result = UpdateProfileSchema.safeParse({ + fullName: 'A'.repeat(101), + email: 'test@test.com' + }); + expect(result.success).toBe(false); + }); +}); + +// ── ChangePasswordSchema ──────────────────────────────────────────────── + +describe('ChangePasswordSchema', () => { + it('accepts valid password change', () => { + const result = ChangePasswordSchema.safeParse({ + currentPassword: 'oldpass123', + newPassword: 'newpass456', + confirmPassword: 'newpass456' + }); + expect(result.success).toBe(true); + }); + + it('rejects empty currentPassword', () => { + const result = ChangePasswordSchema.safeParse({ + currentPassword: '', + newPassword: 'newpass456', + confirmPassword: 'newpass456' + }); + expect(result.success).toBe(false); + }); + + it('rejects newPassword shorter than 8 characters', () => { + const result = ChangePasswordSchema.safeParse({ + currentPassword: 'oldpass123', + newPassword: 'short', + confirmPassword: 'short' + }); + expect(result.success).toBe(false); + }); + + it('rejects newPassword longer than 24 characters', () => { + const result = ChangePasswordSchema.safeParse({ + currentPassword: 'oldpass123', + newPassword: 'a'.repeat(25), + confirmPassword: 'a'.repeat(25) + }); + expect(result.success).toBe(false); + }); + + it('rejects empty confirmPassword', () => { + const result = ChangePasswordSchema.safeParse({ + currentPassword: 'oldpass123', + newPassword: 'newpass456', + confirmPassword: '' + }); + expect(result.success).toBe(false); + }); + + it('rejects missing fields', () => { + const result = ChangePasswordSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); diff --git a/src/lib/schemas/suppliers.spec.ts b/src/lib/schemas/suppliers.spec.ts new file mode 100644 index 00000000..76717078 --- /dev/null +++ b/src/lib/schemas/suppliers.spec.ts @@ -0,0 +1,336 @@ +import { describe, it, expect } from 'vitest'; +import { + ListSuppliersSchema, + CreateSupplierSchema, + UpdateSupplierSchema, + SupplierIdSchema, + ReactivateSupplierSchema, + SupplierTreatmentQuerySchema, + CreateSupplierTreatmentSchema, + UpdateSupplierTreatmentSchema, + SupplierTreatmentIdSchema, + QuickCreateSupplierSchema +} from '$lib/schemas/suppliers'; +import { SupplierType, CurrencyCode } from '$lib/shared/enums'; + +// ── Helpers ───────────────────────────────────────────────────────────── + +function makeSupplier(overrides: Record = {}) { + return { + name: 'LensLab VE', + type: SupplierType.LABORATORY, + primaryPhone: '+584121234567', + email: 'lab@example.com', + instagram: '@lenslab', + whatsapp: '+584121234567', + website: 'https://lenslab.com', + ...overrides + }; +} + +function makeTreatment(overrides: Record = {}) { + return { + supplierId: crypto.randomUUID(), + name: 'Anti-reflective', + category: 'AR', + price: 15, + ...overrides + }; +} + +// ── ListSuppliersSchema ───────────────────────────────────────────────── + +describe('ListSuppliersSchema', () => { + it('applies pagination defaults', () => { + const result = ListSuppliersSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.page).toBe(1); + expect(result.data.perPage).toBe(10); + expect(result.data.includeDeleted).toBe(false); + } + }); + + it('accepts type filter', () => { + const result = ListSuppliersSchema.safeParse({ type: SupplierType.DISTRIBUTOR }); + expect(result.success).toBe(true); + }); + + it('rejects invalid type', () => { + const result = ListSuppliersSchema.safeParse({ type: 'INVALID' }); + expect(result.success).toBe(false); + }); +}); + +// ── CreateSupplierSchema ──────────────────────────────────────────────── + +describe('CreateSupplierSchema', () => { + it('accepts a valid supplier', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier()); + expect(result.success).toBe(true); + }); + + it('rejects empty name', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier({ name: '' })); + expect(result.success).toBe(false); + }); + + it('rejects invalid type', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier({ type: 'FAKE' })); + expect(result.success).toBe(false); + }); + + it('rejects invalid phone', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier({ primaryPhone: '123' })); + expect(result.success).toBe(false); + }); + + it('accepts empty email', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier({ email: '' })); + expect(result.success).toBe(true); + }); + + it('rejects invalid email', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier({ email: 'not-email' })); + expect(result.success).toBe(false); + }); + + it('accepts valid instagram handle', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier({ instagram: '@optica_ve' })); + expect(result.success).toBe(true); + }); + + it('rejects instagram without @', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier({ instagram: 'optica_ve' })); + expect(result.success).toBe(false); + }); + + it('accepts empty instagram', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier({ instagram: '' })); + expect(result.success).toBe(true); + }); + + it('accepts empty website (optional URL)', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier({ website: '' })); + expect(result.success).toBe(true); + }); + + it('rejects invalid website URL', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier({ website: 'not-a-url' })); + expect(result.success).toBe(false); + }); + + it('accepts all supplier types', () => { + for (const type of Object.values(SupplierType)) { + const result = CreateSupplierSchema.safeParse(makeSupplier({ type })); + expect(result.success).toBe(true); + } + }); + + it('accepts optional defaultCurrency', () => { + const result = CreateSupplierSchema.safeParse( + makeSupplier({ defaultCurrency: CurrencyCode.USD_BCV }) + ); + expect(result.success).toBe(true); + }); + + it('rejects invalid defaultCurrency', () => { + const result = CreateSupplierSchema.safeParse(makeSupplier({ defaultCurrency: 'FAKE' })); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateSupplierSchema ──────────────────────────────────────────────── + +describe('UpdateSupplierSchema', () => { + it('accepts a valid update with id', () => { + const result = UpdateSupplierSchema.safeParse({ + id: crypto.randomUUID(), + name: 'Updated' + }); + expect(result.success).toBe(true); + }); + + it('requires a valid UUID for id', () => { + const result = UpdateSupplierSchema.safeParse({ id: 'bad' }); + expect(result.success).toBe(false); + }); + + it('accepts update with only id (all fields optional)', () => { + const result = UpdateSupplierSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); +}); + +// ── SupplierIdSchema ──────────────────────────────────────────────────── + +describe('SupplierIdSchema', () => { + it('accepts a valid UUID', () => { + const result = SupplierIdSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); + + it('rejects invalid UUID', () => { + const result = SupplierIdSchema.safeParse({ id: 'bad' }); + expect(result.success).toBe(false); + }); +}); + +// ── ReactivateSupplierSchema ──────────────────────────────────────────── + +describe('ReactivateSupplierSchema', () => { + it('accepts a valid UUID for deletedSupplierId', () => { + const result = ReactivateSupplierSchema.safeParse({ + deletedSupplierId: crypto.randomUUID() + }); + expect(result.success).toBe(true); + }); + + it('rejects missing deletedSupplierId', () => { + const result = ReactivateSupplierSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +// ── SupplierTreatmentQuerySchema ──────────────────────────────────────── + +describe('SupplierTreatmentQuerySchema', () => { + it('accepts a valid UUID', () => { + const result = SupplierTreatmentQuerySchema.safeParse({ supplierId: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); + + it('rejects invalid UUID', () => { + const result = SupplierTreatmentQuerySchema.safeParse({ supplierId: 'bad' }); + expect(result.success).toBe(false); + }); +}); + +// ── CreateSupplierTreatmentSchema ─────────────────────────────────────── + +describe('CreateSupplierTreatmentSchema', () => { + it('accepts a valid treatment', () => { + const result = CreateSupplierTreatmentSchema.safeParse(makeTreatment()); + expect(result.success).toBe(true); + }); + + it('defaults isTaxable to true', () => { + const result = CreateSupplierTreatmentSchema.safeParse(makeTreatment()); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isTaxable).toBe(true); + } + }); + + it('defaults taxRate to 16', () => { + const result = CreateSupplierTreatmentSchema.safeParse(makeTreatment()); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.taxRate).toBe(16); + } + }); + + it('rejects empty name', () => { + const result = CreateSupplierTreatmentSchema.safeParse(makeTreatment({ name: '' })); + expect(result.success).toBe(false); + }); + + it('rejects name exceeding 100 characters', () => { + const result = CreateSupplierTreatmentSchema.safeParse( + makeTreatment({ name: 'X'.repeat(101) }) + ); + expect(result.success).toBe(false); + }); + + it('rejects invalid category', () => { + const result = CreateSupplierTreatmentSchema.safeParse( + makeTreatment({ category: 'INVALID' }) + ); + expect(result.success).toBe(false); + }); + + it('rejects negative price', () => { + const result = CreateSupplierTreatmentSchema.safeParse(makeTreatment({ price: -1 })); + expect(result.success).toBe(false); + }); + + it('coerces string price to number', () => { + const result = CreateSupplierTreatmentSchema.safeParse(makeTreatment({ price: '20.5' })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.price).toBe(20.5); + } + }); + + it('accepts valid categories', () => { + for (const category of ['AR', 'BLUECUT']) { + const result = CreateSupplierTreatmentSchema.safeParse(makeTreatment({ category })); + expect(result.success).toBe(true); + } + }); +}); + +// ── UpdateSupplierTreatmentSchema ─────────────────────────────────────── + +describe('UpdateSupplierTreatmentSchema', () => { + it('accepts a valid update with id', () => { + const result = UpdateSupplierTreatmentSchema.safeParse({ + id: crypto.randomUUID(), + name: 'Updated Treatment' + }); + expect(result.success).toBe(true); + }); + + it('accepts isActive coerced boolean', () => { + const result = UpdateSupplierTreatmentSchema.safeParse({ + id: crypto.randomUUID(), + isActive: 'true' + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isActive).toBe(true); + } + }); + + it('rejects missing id', () => { + const result = UpdateSupplierTreatmentSchema.safeParse({ name: 'Test' }); + expect(result.success).toBe(false); + }); +}); + +// ── SupplierTreatmentIdSchema ─────────────────────────────────────────── + +describe('SupplierTreatmentIdSchema', () => { + it('accepts a valid UUID', () => { + const result = SupplierTreatmentIdSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); + + it('rejects invalid UUID', () => { + const result = SupplierTreatmentIdSchema.safeParse({ id: 'bad' }); + expect(result.success).toBe(false); + }); +}); + +// ── QuickCreateSupplierSchema ─────────────────────────────────────────── + +describe('QuickCreateSupplierSchema', () => { + it('accepts just a name', () => { + const result = QuickCreateSupplierSchema.safeParse({ name: 'Quick Supplier' }); + expect(result.success).toBe(true); + }); + + it('accepts name with optional type and phone', () => { + const result = QuickCreateSupplierSchema.safeParse({ + name: 'Quick Supplier', + type: SupplierType.DISTRIBUTOR, + primaryPhone: '+584121234567' + }); + expect(result.success).toBe(true); + }); + + it('rejects empty name', () => { + const result = QuickCreateSupplierSchema.safeParse({ name: '' }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/lib/schemas/users.spec.ts b/src/lib/schemas/users.spec.ts new file mode 100644 index 00000000..039f0b37 --- /dev/null +++ b/src/lib/schemas/users.spec.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; +import { + ListUsersSchema, + CreateUserSchema, + UpdateUserSchema, + UserIdSchema, + ReactivateUserSchema +} from '$lib/schemas/users'; +import { UserRole } from '$lib/shared/enums'; + +// ── Helpers ───────────────────────────────────────────────────────────── + +function makeUser(overrides: Record = {}) { + return { + email: 'user@example.com', + username: 'john_doe', + fullName: 'John Doe', + password: 'securepass1', + role: UserRole.SELLER, + ...overrides + }; +} + +// ── ListUsersSchema ───────────────────────────────────────────────────── + +describe('ListUsersSchema', () => { + it('applies pagination defaults', () => { + const result = ListUsersSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.page).toBe(1); + expect(result.data.perPage).toBe(10); + expect(result.data.includeInactive).toBe(false); + } + }); + + it('accepts role filter', () => { + const result = ListUsersSchema.safeParse({ role: UserRole.ADMIN }); + expect(result.success).toBe(true); + }); + + it('rejects invalid role', () => { + const result = ListUsersSchema.safeParse({ role: 'INVALID' }); + expect(result.success).toBe(false); + }); +}); + +// ── CreateUserSchema ──────────────────────────────────────────────────── + +describe('CreateUserSchema', () => { + it('accepts a valid user', () => { + const result = CreateUserSchema.safeParse(makeUser()); + expect(result.success).toBe(true); + }); + + it('defaults role to VIEWER', () => { + const { role: _, ...user } = makeUser(); + const result = CreateUserSchema.safeParse(user); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe(UserRole.VIEWER); + } + }); + + it('defaults isActive to true', () => { + const result = CreateUserSchema.safeParse(makeUser()); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isActive).toBe(true); + } + }); + + it('rejects invalid email', () => { + const result = CreateUserSchema.safeParse(makeUser({ email: 'bad' })); + expect(result.success).toBe(false); + }); + + it('rejects email exceeding 255 characters', () => { + const result = CreateUserSchema.safeParse( + makeUser({ email: 'a'.repeat(250) + '@b.com' }) + ); + expect(result.success).toBe(false); + }); + + it('rejects username shorter than 3 characters', () => { + const result = CreateUserSchema.safeParse(makeUser({ username: 'ab' })); + expect(result.success).toBe(false); + }); + + it('rejects username with special characters', () => { + const result = CreateUserSchema.safeParse(makeUser({ username: 'user@name' })); + expect(result.success).toBe(false); + }); + + it('accepts username with underscores', () => { + const result = CreateUserSchema.safeParse(makeUser({ username: 'john_doe_123' })); + expect(result.success).toBe(true); + }); + + it('rejects empty fullName', () => { + const result = CreateUserSchema.safeParse(makeUser({ fullName: '' })); + expect(result.success).toBe(false); + }); + + it('rejects password shorter than 8 characters', () => { + const result = CreateUserSchema.safeParse(makeUser({ password: 'short' })); + expect(result.success).toBe(false); + }); + + it('rejects password longer than 24 characters', () => { + const result = CreateUserSchema.safeParse(makeUser({ password: 'a'.repeat(25) })); + expect(result.success).toBe(false); + }); + + it('rejects invalid role', () => { + const result = CreateUserSchema.safeParse(makeUser({ role: 'INVALID' })); + expect(result.success).toBe(false); + }); + + it('accepts all valid roles', () => { + for (const role of Object.values(UserRole)) { + const result = CreateUserSchema.safeParse(makeUser({ role })); + expect(result.success).toBe(true); + } + }); + + it('coerces isActive from string', () => { + const result = CreateUserSchema.safeParse(makeUser({ isActive: 'false' })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isActive).toBe(false); + } + }); +}); + +// ── UpdateUserSchema ──────────────────────────────────────────────────── + +describe('UpdateUserSchema', () => { + it('accepts a valid update with id', () => { + const result = UpdateUserSchema.safeParse({ + id: crypto.randomUUID(), + fullName: 'Updated Name' + }); + expect(result.success).toBe(true); + }); + + it('requires a valid UUID for id', () => { + const result = UpdateUserSchema.safeParse({ id: 'bad' }); + expect(result.success).toBe(false); + }); + + it('accepts empty password (keep current)', () => { + const result = UpdateUserSchema.safeParse({ + id: crypto.randomUUID(), + password: '' + }); + expect(result.success).toBe(true); + }); + + it('accepts valid new password on update', () => { + const result = UpdateUserSchema.safeParse({ + id: crypto.randomUUID(), + password: 'newpass12' + }); + expect(result.success).toBe(true); + }); + + it('rejects short non-empty password on update', () => { + const result = UpdateUserSchema.safeParse({ + id: crypto.randomUUID(), + password: 'short' + }); + expect(result.success).toBe(false); + }); + + it('accepts update with only id', () => { + const result = UpdateUserSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); +}); + +// ── UserIdSchema ──────────────────────────────────────────────────────── + +describe('UserIdSchema', () => { + it('accepts a valid UUID', () => { + const result = UserIdSchema.safeParse({ id: crypto.randomUUID() }); + expect(result.success).toBe(true); + }); + + it('rejects invalid UUID', () => { + const result = UserIdSchema.safeParse({ id: 'bad' }); + expect(result.success).toBe(false); + }); +}); + +// ── ReactivateUserSchema ──────────────────────────────────────────────── + +describe('ReactivateUserSchema', () => { + it('accepts a valid reactivation with all fields', () => { + const result = ReactivateUserSchema.safeParse({ + ...makeUser(), + deletedUserId: crypto.randomUUID() + }); + expect(result.success).toBe(true); + }); + + it('requires deletedUserId', () => { + const result = ReactivateUserSchema.safeParse(makeUser()); + expect(result.success).toBe(false); + }); + + it('rejects invalid deletedUserId', () => { + const result = ReactivateUserSchema.safeParse({ + ...makeUser(), + deletedUserId: 'bad' + }); + expect(result.success).toBe(false); + }); + + it('requires all CreateUser fields plus deletedUserId', () => { + const result = ReactivateUserSchema.safeParse({ + deletedUserId: crypto.randomUUID() + }); + expect(result.success).toBe(false); + }); +}); From 8cf6ac2a9fce60c4e133643d427d4a09ca395370 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:08:23 +0000 Subject: [PATCH 4/5] test: add comprehensive tests for shared enum and contract files Add 133 tests across 8 new spec files covering: - purchaseTypes (status, item type, labels, badge colors) - currencyTypes (codes, labels, symbols, base currency) - lensTypes (6 enums, labels, badge colors) - supplierTypes (types, labels, badge colors) - inventoryTypes (movements, references, adjustments, loss reasons) - salesTypes (status, payment, discount, refund) - contracts/common (PatientEye enum) - contracts/search (SearchScope, prefixes, labels) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: NanezX <81595884+NanezX@users.noreply.github.com> --- src/lib/shared/contracts/common.spec.ts | 19 ++ src/lib/shared/contracts/search.spec.ts | 76 ++++++ src/lib/shared/enums/currencyTypes.spec.ts | 83 +++++++ src/lib/shared/enums/inventoryTypes.spec.ts | 194 +++++++++++++++ src/lib/shared/enums/lensTypes.spec.ts | 246 ++++++++++++++++++++ src/lib/shared/enums/purchaseTypes.spec.ts | 106 +++++++++ src/lib/shared/enums/salesTypes.spec.ts | 223 ++++++++++++++++++ src/lib/shared/enums/supplierTypes.spec.ts | 64 +++++ 8 files changed, 1011 insertions(+) create mode 100644 src/lib/shared/contracts/common.spec.ts create mode 100644 src/lib/shared/contracts/search.spec.ts create mode 100644 src/lib/shared/enums/currencyTypes.spec.ts create mode 100644 src/lib/shared/enums/inventoryTypes.spec.ts create mode 100644 src/lib/shared/enums/lensTypes.spec.ts create mode 100644 src/lib/shared/enums/purchaseTypes.spec.ts create mode 100644 src/lib/shared/enums/salesTypes.spec.ts create mode 100644 src/lib/shared/enums/supplierTypes.spec.ts diff --git a/src/lib/shared/contracts/common.spec.ts b/src/lib/shared/contracts/common.spec.ts new file mode 100644 index 00000000..9ea59993 --- /dev/null +++ b/src/lib/shared/contracts/common.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { PatientEye } from './common'; + +describe('PatientEye enum', () => { + it('has OD (right eye) value', () => { + expect(PatientEye.OD).toBe('OD'); + }); + + it('has OI (left eye) value', () => { + expect(PatientEye.OI).toBe('OI'); + }); + + it('contains exactly 2 values', () => { + const values = Object.values(PatientEye); + expect(values).toHaveLength(2); + expect(values).toContain('OD'); + expect(values).toContain('OI'); + }); +}); diff --git a/src/lib/shared/contracts/search.spec.ts b/src/lib/shared/contracts/search.spec.ts new file mode 100644 index 00000000..509f21af --- /dev/null +++ b/src/lib/shared/contracts/search.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { SearchScope, SEARCH_SCOPE_PREFIXES, SEARCH_SCOPE_LABELS } from './search'; + +const ALL_SEARCH_SCOPES = Object.values(SearchScope) as SearchScope[]; + +describe('SearchScope enum', () => { + it('has all expected values', () => { + expect(SearchScope.GLOBAL).toBe('GLOBAL'); + expect(SearchScope.DOCUMENT).toBe('DOCUMENT'); + expect(SearchScope.CUSTOMER).toBe('CUSTOMER'); + expect(SearchScope.PRODUCT).toBe('PRODUCT'); + expect(SearchScope.LENS).toBe('LENS'); + expect(SearchScope.PROVIDER_OR_BRAND).toBe('PROVIDER_OR_BRAND'); + }); + + it('contains exactly 6 scopes', () => { + expect(ALL_SEARCH_SCOPES).toHaveLength(6); + }); +}); + +describe('SEARCH_SCOPE_PREFIXES', () => { + it('has a prefix entry for every scope', () => { + for (const scope of ALL_SEARCH_SCOPES) { + expect(scope in SEARCH_SCOPE_PREFIXES).toBe(true); + } + }); + + it('GLOBAL has null prefix (no prefix needed)', () => { + expect(SEARCH_SCOPE_PREFIXES[SearchScope.GLOBAL]).toBeNull(); + }); + + it('non-GLOBAL scopes have string prefixes', () => { + expect(SEARCH_SCOPE_PREFIXES[SearchScope.DOCUMENT]).toBe('#'); + expect(SEARCH_SCOPE_PREFIXES[SearchScope.CUSTOMER]).toBe('@'); + expect(SEARCH_SCOPE_PREFIXES[SearchScope.PRODUCT]).toBe('!'); + expect(SEARCH_SCOPE_PREFIXES[SearchScope.LENS]).toBe('*'); + expect(SEARCH_SCOPE_PREFIXES[SearchScope.PROVIDER_OR_BRAND]).toBe('%'); + }); + + it('all non-GLOBAL prefixes are unique single characters', () => { + const prefixes = ALL_SEARCH_SCOPES + .filter((s) => s !== SearchScope.GLOBAL) + .map((s) => SEARCH_SCOPE_PREFIXES[s]); + + for (const p of prefixes) { + expect(typeof p).toBe('string'); + expect(p).toHaveLength(1); + } + + const unique = new Set(prefixes); + expect(unique.size).toBe(prefixes.length); + }); +}); + +describe('SEARCH_SCOPE_LABELS', () => { + it('has a label for every scope', () => { + for (const scope of ALL_SEARCH_SCOPES) { + expect(SEARCH_SCOPE_LABELS[scope]).toBeDefined(); + } + }); + + it('returns correct Spanish labels', () => { + expect(SEARCH_SCOPE_LABELS[SearchScope.GLOBAL]).toBe('Global'); + expect(SEARCH_SCOPE_LABELS[SearchScope.DOCUMENT]).toBe('Documentos'); + expect(SEARCH_SCOPE_LABELS[SearchScope.CUSTOMER]).toBe('Pacientes'); + expect(SEARCH_SCOPE_LABELS[SearchScope.PRODUCT]).toBe('Productos'); + expect(SEARCH_SCOPE_LABELS[SearchScope.LENS]).toBe('Cristales'); + expect(SEARCH_SCOPE_LABELS[SearchScope.PROVIDER_OR_BRAND]).toBe('Proveedores y marcas'); + }); + + it('all labels are non-empty strings', () => { + for (const scope of ALL_SEARCH_SCOPES) { + expect(SEARCH_SCOPE_LABELS[scope].length).toBeGreaterThan(0); + } + }); +}); diff --git a/src/lib/shared/enums/currencyTypes.spec.ts b/src/lib/shared/enums/currencyTypes.spec.ts new file mode 100644 index 00000000..9e5f828b --- /dev/null +++ b/src/lib/shared/enums/currencyTypes.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { + CurrencyCode, + CURRENCY_LABELS, + getCurrencyLabel, + CURRENCY_SYMBOLS, + BASE_CURRENCY, + isBaseCurrency, + ALL_CURRENCY_CODES +} from './currencyTypes'; + +describe('CurrencyCode enum', () => { + it('has all expected values', () => { + expect(CurrencyCode.USD_BCV).toBe('USD_BCV'); + expect(CurrencyCode.EUR_BCV).toBe('EUR_BCV'); + expect(CurrencyCode.USDT).toBe('USDT'); + expect(CurrencyCode.USD_PAYPAL).toBe('USD_PAYPAL'); + }); + + it('ALL_CURRENCY_CODES contains all values', () => { + expect(ALL_CURRENCY_CODES).toHaveLength(4); + expect(ALL_CURRENCY_CODES).toContain(CurrencyCode.USD_BCV); + expect(ALL_CURRENCY_CODES).toContain(CurrencyCode.EUR_BCV); + expect(ALL_CURRENCY_CODES).toContain(CurrencyCode.USDT); + expect(ALL_CURRENCY_CODES).toContain(CurrencyCode.USD_PAYPAL); + }); +}); + +describe('getCurrencyLabel', () => { + it('returns display labels for known currency codes', () => { + expect(getCurrencyLabel('USD_BCV')).toBe('USD (BCV)'); + expect(getCurrencyLabel('EUR_BCV')).toBe('EUR (BCV)'); + expect(getCurrencyLabel('USDT')).toBe('USDT'); + expect(getCurrencyLabel('USD_PAYPAL')).toBe('USD PayPal'); + }); + + it('has a label for every code in the enum', () => { + for (const code of ALL_CURRENCY_CODES) { + expect(CURRENCY_LABELS[code]).toBeDefined(); + } + }); + + it('returns raw value for unknown code', () => { + expect(getCurrencyLabel('BTC')).toBe('BTC'); + }); + + it('returns raw value for empty string', () => { + expect(getCurrencyLabel('')).toBe(''); + }); +}); + +describe('CURRENCY_SYMBOLS', () => { + it('has a symbol for every currency code', () => { + for (const code of ALL_CURRENCY_CODES) { + expect(CURRENCY_SYMBOLS[code]).toBeDefined(); + } + }); + + it('returns correct symbols', () => { + expect(CURRENCY_SYMBOLS[CurrencyCode.USD_BCV]).toBe('$'); + expect(CURRENCY_SYMBOLS[CurrencyCode.EUR_BCV]).toBe('€'); + expect(CURRENCY_SYMBOLS[CurrencyCode.USDT]).toBe('$'); + expect(CURRENCY_SYMBOLS[CurrencyCode.USD_PAYPAL]).toBe('$'); + }); +}); + +describe('BASE_CURRENCY', () => { + it('is USD_BCV', () => { + expect(BASE_CURRENCY).toBe(CurrencyCode.USD_BCV); + }); +}); + +describe('isBaseCurrency', () => { + it('returns true for USD_BCV', () => { + expect(isBaseCurrency(CurrencyCode.USD_BCV)).toBe(true); + }); + + it('returns false for non-base currencies', () => { + expect(isBaseCurrency(CurrencyCode.EUR_BCV)).toBe(false); + expect(isBaseCurrency(CurrencyCode.USDT)).toBe(false); + expect(isBaseCurrency(CurrencyCode.USD_PAYPAL)).toBe(false); + }); +}); diff --git a/src/lib/shared/enums/inventoryTypes.spec.ts b/src/lib/shared/enums/inventoryTypes.spec.ts new file mode 100644 index 00000000..a79606e4 --- /dev/null +++ b/src/lib/shared/enums/inventoryTypes.spec.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import { + InventoryMovementType, + ALL_INVENTORY_MOVEMENT_TYPES, + INVENTORY_MOVEMENT_TYPE_LABELS, + getInventoryMovementTypeLabel, + MovementReferenceType, + ALL_MOVEMENT_REFERENCE_TYPES, + MOVEMENT_REFERENCE_TYPE_LABELS, + getMovementReferenceTypeLabel, + AdjustmentReason, + ALL_ADJUSTMENT_REASONS, + ADJUSTMENT_REASON_LABELS, + getAdjustmentReasonLabel, + ADJUSTMENT_REPORT_CATEGORIES, + LOSS_REASONS +} from './inventoryTypes'; + +// ── InventoryMovementType ─────────────────────────────────────────────── + +describe('InventoryMovementType enum', () => { + it('has all expected values', () => { + expect(InventoryMovementType.PURCHASE_IN).toBe('PURCHASE_IN'); + expect(InventoryMovementType.SALE_OUT).toBe('SALE_OUT'); + expect(InventoryMovementType.ADJUSTMENT_IN).toBe('ADJUSTMENT_IN'); + expect(InventoryMovementType.ADJUSTMENT_OUT).toBe('ADJUSTMENT_OUT'); + expect(InventoryMovementType.RETURN_IN).toBe('RETURN_IN'); + expect(InventoryMovementType.CANCEL_REVERT).toBe('CANCEL_REVERT'); + }); + + it('ALL_INVENTORY_MOVEMENT_TYPES contains all values', () => { + expect(ALL_INVENTORY_MOVEMENT_TYPES).toHaveLength(6); + expect(ALL_INVENTORY_MOVEMENT_TYPES).toContain(InventoryMovementType.PURCHASE_IN); + expect(ALL_INVENTORY_MOVEMENT_TYPES).toContain(InventoryMovementType.SALE_OUT); + expect(ALL_INVENTORY_MOVEMENT_TYPES).toContain(InventoryMovementType.ADJUSTMENT_IN); + expect(ALL_INVENTORY_MOVEMENT_TYPES).toContain(InventoryMovementType.ADJUSTMENT_OUT); + expect(ALL_INVENTORY_MOVEMENT_TYPES).toContain(InventoryMovementType.RETURN_IN); + expect(ALL_INVENTORY_MOVEMENT_TYPES).toContain(InventoryMovementType.CANCEL_REVERT); + }); +}); + +describe('getInventoryMovementTypeLabel', () => { + it('returns Spanish labels for known types', () => { + expect(getInventoryMovementTypeLabel('PURCHASE_IN')).toBe('Entrada por compra'); + expect(getInventoryMovementTypeLabel('SALE_OUT')).toBe('Salida por venta'); + expect(getInventoryMovementTypeLabel('ADJUSTMENT_IN')).toBe('Ajuste positivo'); + expect(getInventoryMovementTypeLabel('ADJUSTMENT_OUT')).toBe('Ajuste negativo'); + expect(getInventoryMovementTypeLabel('RETURN_IN')).toBe('Devolución'); + expect(getInventoryMovementTypeLabel('CANCEL_REVERT')).toBe('Reversión por cancelación'); + }); + + it('has a label for every movement type', () => { + for (const type of ALL_INVENTORY_MOVEMENT_TYPES) { + expect(INVENTORY_MOVEMENT_TYPE_LABELS[type]).toBeDefined(); + } + }); + + it('returns raw value for unknown type', () => { + expect(getInventoryMovementTypeLabel('UNKNOWN')).toBe('UNKNOWN'); + }); + + it('returns raw value for empty string', () => { + expect(getInventoryMovementTypeLabel('')).toBe(''); + }); +}); + +// ── MovementReferenceType ─────────────────────────────────────────────── + +describe('MovementReferenceType enum', () => { + it('has all expected values', () => { + expect(MovementReferenceType.PURCHASE_ORDER).toBe('PURCHASE_ORDER'); + expect(MovementReferenceType.SALE).toBe('SALE'); + expect(MovementReferenceType.MANUAL_ADJUSTMENT).toBe('MANUAL_ADJUSTMENT'); + }); + + it('ALL_MOVEMENT_REFERENCE_TYPES contains all values', () => { + expect(ALL_MOVEMENT_REFERENCE_TYPES).toHaveLength(3); + expect(ALL_MOVEMENT_REFERENCE_TYPES).toContain(MovementReferenceType.PURCHASE_ORDER); + expect(ALL_MOVEMENT_REFERENCE_TYPES).toContain(MovementReferenceType.SALE); + expect(ALL_MOVEMENT_REFERENCE_TYPES).toContain(MovementReferenceType.MANUAL_ADJUSTMENT); + }); +}); + +describe('getMovementReferenceTypeLabel', () => { + it('returns Spanish labels for known types', () => { + expect(getMovementReferenceTypeLabel('PURCHASE_ORDER')).toBe('Orden de compra'); + expect(getMovementReferenceTypeLabel('SALE')).toBe('Venta'); + expect(getMovementReferenceTypeLabel('MANUAL_ADJUSTMENT')).toBe('Ajuste manual'); + }); + + it('has a label for every reference type', () => { + for (const type of ALL_MOVEMENT_REFERENCE_TYPES) { + expect(MOVEMENT_REFERENCE_TYPE_LABELS[type]).toBeDefined(); + } + }); + + it('returns raw value for unknown type', () => { + expect(getMovementReferenceTypeLabel('TRANSFER')).toBe('TRANSFER'); + }); +}); + +// ── AdjustmentReason ──────────────────────────────────────────────────── + +describe('AdjustmentReason enum', () => { + it('has all expected values', () => { + expect(AdjustmentReason.PHYSICAL_COUNT).toBe('PHYSICAL_COUNT'); + expect(AdjustmentReason.DAMAGE).toBe('DAMAGE'); + expect(AdjustmentReason.SAMPLE).toBe('SAMPLE'); + expect(AdjustmentReason.CUSTOMER_RETURN).toBe('CUSTOMER_RETURN'); + expect(AdjustmentReason.ENTRY_ERROR).toBe('ENTRY_ERROR'); + expect(AdjustmentReason.OTHER).toBe('OTHER'); + }); + + it('ALL_ADJUSTMENT_REASONS contains all values', () => { + expect(ALL_ADJUSTMENT_REASONS).toHaveLength(6); + expect(ALL_ADJUSTMENT_REASONS).toContain(AdjustmentReason.PHYSICAL_COUNT); + expect(ALL_ADJUSTMENT_REASONS).toContain(AdjustmentReason.DAMAGE); + expect(ALL_ADJUSTMENT_REASONS).toContain(AdjustmentReason.SAMPLE); + expect(ALL_ADJUSTMENT_REASONS).toContain(AdjustmentReason.CUSTOMER_RETURN); + expect(ALL_ADJUSTMENT_REASONS).toContain(AdjustmentReason.ENTRY_ERROR); + expect(ALL_ADJUSTMENT_REASONS).toContain(AdjustmentReason.OTHER); + }); +}); + +describe('getAdjustmentReasonLabel', () => { + it('returns Spanish labels for known reasons', () => { + expect(getAdjustmentReasonLabel('PHYSICAL_COUNT')).toBe('Conteo físico'); + expect(getAdjustmentReasonLabel('DAMAGE')).toBe('Daño / merma'); + expect(getAdjustmentReasonLabel('SAMPLE')).toBe('Muestra o cortesía'); + expect(getAdjustmentReasonLabel('CUSTOMER_RETURN')).toBe( + 'Devolución de cliente (sin reembolso)' + ); + expect(getAdjustmentReasonLabel('ENTRY_ERROR')).toBe('Error de registro'); + expect(getAdjustmentReasonLabel('OTHER')).toBe('Otro'); + }); + + it('has a label for every adjustment reason', () => { + for (const reason of ALL_ADJUSTMENT_REASONS) { + expect(ADJUSTMENT_REASON_LABELS[reason]).toBeDefined(); + } + }); + + it('returns raw value for unknown reason', () => { + expect(getAdjustmentReasonLabel('THEFT')).toBe('THEFT'); + }); + + it('returns raw value for empty string', () => { + expect(getAdjustmentReasonLabel('')).toBe(''); + }); +}); + +describe('ADJUSTMENT_REPORT_CATEGORIES', () => { + it('has a category for every adjustment reason', () => { + for (const reason of ALL_ADJUSTMENT_REASONS) { + expect(ADJUSTMENT_REPORT_CATEGORIES[reason]).toBeDefined(); + } + }); + + it('maps DAMAGE and SAMPLE to loss-related categories', () => { + expect(ADJUSTMENT_REPORT_CATEGORIES[AdjustmentReason.DAMAGE]).toBe('Pérdidas operativas'); + expect(ADJUSTMENT_REPORT_CATEGORIES[AdjustmentReason.SAMPLE]).toBe('Muestras y cortesías'); + }); + + it('maps informational reasons to inventory adjustments', () => { + expect(ADJUSTMENT_REPORT_CATEGORIES[AdjustmentReason.PHYSICAL_COUNT]).toBe( + 'Ajustes de inventario' + ); + expect(ADJUSTMENT_REPORT_CATEGORIES[AdjustmentReason.ENTRY_ERROR]).toBe( + 'Ajustes de inventario' + ); + expect(ADJUSTMENT_REPORT_CATEGORIES[AdjustmentReason.OTHER]).toBe('Ajustes de inventario'); + }); + + it('maps CUSTOMER_RETURN to its own category', () => { + expect(ADJUSTMENT_REPORT_CATEGORIES[AdjustmentReason.CUSTOMER_RETURN]).toBe( + 'Devoluciones recibidas' + ); + }); +}); + +describe('LOSS_REASONS', () => { + it('contains only DAMAGE and SAMPLE', () => { + expect(LOSS_REASONS).toHaveLength(2); + expect(LOSS_REASONS).toContain(AdjustmentReason.DAMAGE); + expect(LOSS_REASONS).toContain(AdjustmentReason.SAMPLE); + }); + + it('does not include informational reasons', () => { + expect(LOSS_REASONS).not.toContain(AdjustmentReason.PHYSICAL_COUNT); + expect(LOSS_REASONS).not.toContain(AdjustmentReason.CUSTOMER_RETURN); + expect(LOSS_REASONS).not.toContain(AdjustmentReason.ENTRY_ERROR); + expect(LOSS_REASONS).not.toContain(AdjustmentReason.OTHER); + }); +}); diff --git a/src/lib/shared/enums/lensTypes.spec.ts b/src/lib/shared/enums/lensTypes.spec.ts new file mode 100644 index 00000000..bd47f485 --- /dev/null +++ b/src/lib/shared/enums/lensTypes.spec.ts @@ -0,0 +1,246 @@ +import { describe, it, expect } from 'vitest'; +import { + LensType, + LensCatalogSource, + LensPriceType, + LensInventoryMode, + TreatmentCategory, + SaleItemType, + ALL_LENS_TYPES, + ALL_LENS_SOURCES, + ALL_LENS_PRICE_TYPES, + ALL_LENS_INVENTORY_MODES, + ALL_TREATMENT_CATEGORIES, + ALL_SALE_ITEM_TYPES, + LENS_TYPE_LABELS, + LENS_SOURCE_LABELS, + LENS_PRICE_TYPE_LABELS, + LENS_INVENTORY_MODE_LABELS, + TREATMENT_CATEGORY_LABELS, + SALE_ITEM_TYPE_LABELS, + getLensTypeLabel, + getLensSourceLabel, + getPriceTypeLabel, + getInventoryModeLabel, + getTreatmentCategoryLabel, + lensTypeBadgeColors, + getLensTypeBadgeColor +} from './lensTypes'; + +// ── LensType ──────────────────────────────────────────────────────────── + +describe('LensType enum', () => { + it('has all expected values', () => { + expect(LensType.MONOFOCAL).toBe('MONOFOCAL'); + expect(LensType.BIFOCAL).toBe('BIFOCAL'); + expect(LensType.PROGRESSIVE).toBe('PROGRESSIVE'); + expect(LensType.OCCUPATIONAL).toBe('OCCUPATIONAL'); + }); + + it('ALL_LENS_TYPES contains all values', () => { + expect(ALL_LENS_TYPES).toHaveLength(4); + expect(ALL_LENS_TYPES).toContain(LensType.MONOFOCAL); + expect(ALL_LENS_TYPES).toContain(LensType.BIFOCAL); + expect(ALL_LENS_TYPES).toContain(LensType.PROGRESSIVE); + expect(ALL_LENS_TYPES).toContain(LensType.OCCUPATIONAL); + }); +}); + +describe('getLensTypeLabel', () => { + it('returns Spanish labels for known types', () => { + expect(getLensTypeLabel('MONOFOCAL')).toBe('Monofocal'); + expect(getLensTypeLabel('BIFOCAL')).toBe('Bifocal'); + expect(getLensTypeLabel('PROGRESSIVE')).toBe('Progresivo'); + expect(getLensTypeLabel('OCCUPATIONAL')).toBe('Ocupacional'); + }); + + it('has a label for every lens type', () => { + for (const type of ALL_LENS_TYPES) { + expect(LENS_TYPE_LABELS[type]).toBeDefined(); + } + }); + + it('returns raw value for unknown type', () => { + expect(getLensTypeLabel('UNKNOWN')).toBe('UNKNOWN'); + }); + + it('returns raw value for empty string', () => { + expect(getLensTypeLabel('')).toBe(''); + }); +}); + +describe('getLensTypeBadgeColor', () => { + it('returns correct badge colors', () => { + expect(getLensTypeBadgeColor('MONOFOCAL')).toBe('info'); + expect(getLensTypeBadgeColor('BIFOCAL')).toBe('success'); + expect(getLensTypeBadgeColor('PROGRESSIVE')).toBe('purple'); + expect(getLensTypeBadgeColor('OCCUPATIONAL')).toBe('warning'); + }); + + it('has a color for every lens type', () => { + for (const type of ALL_LENS_TYPES) { + expect(lensTypeBadgeColors[type]).toBeDefined(); + } + }); + + it('returns info (default) for unknown type', () => { + expect(getLensTypeBadgeColor('INVALID')).toBe('info'); + }); +}); + +// ── LensCatalogSource ─────────────────────────────────────────────────── + +describe('LensCatalogSource enum', () => { + it('has all expected values', () => { + expect(LensCatalogSource.FINISHED).toBe('FINISHED'); + expect(LensCatalogSource.LAB).toBe('LAB'); + }); + + it('ALL_LENS_SOURCES contains all values', () => { + expect(ALL_LENS_SOURCES).toHaveLength(2); + expect(ALL_LENS_SOURCES).toContain(LensCatalogSource.FINISHED); + expect(ALL_LENS_SOURCES).toContain(LensCatalogSource.LAB); + }); +}); + +describe('getLensSourceLabel', () => { + it('returns Spanish labels for known sources', () => { + expect(getLensSourceLabel('FINISHED')).toBe('Terminado'); + expect(getLensSourceLabel('LAB')).toBe('Laboratorio'); + }); + + it('has a label for every source', () => { + for (const source of ALL_LENS_SOURCES) { + expect(LENS_SOURCE_LABELS[source]).toBeDefined(); + } + }); + + it('returns raw value for unknown source', () => { + expect(getLensSourceLabel('UNKNOWN')).toBe('UNKNOWN'); + }); +}); + +// ── LensPriceType ─────────────────────────────────────────────────────── + +describe('LensPriceType enum', () => { + it('has all expected values', () => { + expect(LensPriceType.UNIT).toBe('UNIT'); + expect(LensPriceType.PAIR).toBe('PAIR'); + }); + + it('ALL_LENS_PRICE_TYPES contains all values', () => { + expect(ALL_LENS_PRICE_TYPES).toHaveLength(2); + expect(ALL_LENS_PRICE_TYPES).toContain(LensPriceType.UNIT); + expect(ALL_LENS_PRICE_TYPES).toContain(LensPriceType.PAIR); + }); +}); + +describe('getPriceTypeLabel', () => { + it('returns Spanish labels for known price types', () => { + expect(getPriceTypeLabel('UNIT')).toBe('Por Unidad'); + expect(getPriceTypeLabel('PAIR')).toBe('Por Par'); + }); + + it('has a label for every price type', () => { + for (const pt of ALL_LENS_PRICE_TYPES) { + expect(LENS_PRICE_TYPE_LABELS[pt]).toBeDefined(); + } + }); + + it('returns raw value for unknown price type', () => { + expect(getPriceTypeLabel('BULK')).toBe('BULK'); + }); +}); + +// ── LensInventoryMode ─────────────────────────────────────────────────── + +describe('LensInventoryMode enum', () => { + it('has all expected values', () => { + expect(LensInventoryMode.ON_DEMAND).toBe('ON_DEMAND'); + expect(LensInventoryMode.STOCK).toBe('STOCK'); + }); + + it('ALL_LENS_INVENTORY_MODES contains all values', () => { + expect(ALL_LENS_INVENTORY_MODES).toHaveLength(2); + expect(ALL_LENS_INVENTORY_MODES).toContain(LensInventoryMode.ON_DEMAND); + expect(ALL_LENS_INVENTORY_MODES).toContain(LensInventoryMode.STOCK); + }); +}); + +describe('getInventoryModeLabel', () => { + it('returns Spanish labels for known modes', () => { + expect(getInventoryModeLabel('ON_DEMAND')).toBe('Por demanda'); + expect(getInventoryModeLabel('STOCK')).toBe('En inventario'); + }); + + it('has a label for every inventory mode', () => { + for (const mode of ALL_LENS_INVENTORY_MODES) { + expect(LENS_INVENTORY_MODE_LABELS[mode]).toBeDefined(); + } + }); + + it('returns raw value for unknown mode', () => { + expect(getInventoryModeLabel('CONSIGNMENT')).toBe('CONSIGNMENT'); + }); +}); + +// ── TreatmentCategory ─────────────────────────────────────────────────── + +describe('TreatmentCategory enum', () => { + it('has all expected values', () => { + expect(TreatmentCategory.AR).toBe('AR'); + expect(TreatmentCategory.BLUECUT).toBe('BLUECUT'); + }); + + it('ALL_TREATMENT_CATEGORIES contains all values', () => { + expect(ALL_TREATMENT_CATEGORIES).toHaveLength(2); + expect(ALL_TREATMENT_CATEGORIES).toContain(TreatmentCategory.AR); + expect(ALL_TREATMENT_CATEGORIES).toContain(TreatmentCategory.BLUECUT); + }); +}); + +describe('getTreatmentCategoryLabel', () => { + it('returns Spanish labels for known categories', () => { + expect(getTreatmentCategoryLabel('AR')).toBe('Antirreflejo'); + expect(getTreatmentCategoryLabel('BLUECUT')).toBe('Bluecut'); + }); + + it('has a label for every category', () => { + for (const cat of ALL_TREATMENT_CATEGORIES) { + expect(TREATMENT_CATEGORY_LABELS[cat]).toBeDefined(); + } + }); + + it('returns raw value for unknown category', () => { + expect(getTreatmentCategoryLabel('PHOTOCHROMIC')).toBe('PHOTOCHROMIC'); + }); +}); + +// ── SaleItemType ──────────────────────────────────────────────────────── + +describe('SaleItemType enum', () => { + it('has all expected values', () => { + expect(SaleItemType.PRODUCT).toBe('PRODUCT'); + expect(SaleItemType.LENS_PAIR).toBe('LENS_PAIR'); + expect(SaleItemType.TREATMENT).toBe('TREATMENT'); + }); + + it('ALL_SALE_ITEM_TYPES contains all values', () => { + expect(ALL_SALE_ITEM_TYPES).toHaveLength(3); + expect(ALL_SALE_ITEM_TYPES).toContain(SaleItemType.PRODUCT); + expect(ALL_SALE_ITEM_TYPES).toContain(SaleItemType.LENS_PAIR); + expect(ALL_SALE_ITEM_TYPES).toContain(SaleItemType.TREATMENT); + }); + + it('has a label for every sale item type', () => { + for (const type of ALL_SALE_ITEM_TYPES) { + expect(SALE_ITEM_TYPE_LABELS[type]).toBeDefined(); + } + }); + + it('returns correct labels', () => { + expect(SALE_ITEM_TYPE_LABELS[SaleItemType.PRODUCT]).toBe('Producto'); + expect(SALE_ITEM_TYPE_LABELS[SaleItemType.LENS_PAIR]).toBe('Cristales'); + expect(SALE_ITEM_TYPE_LABELS[SaleItemType.TREATMENT]).toBe('Tratamiento'); + }); +}); diff --git a/src/lib/shared/enums/purchaseTypes.spec.ts b/src/lib/shared/enums/purchaseTypes.spec.ts new file mode 100644 index 00000000..2db4653e --- /dev/null +++ b/src/lib/shared/enums/purchaseTypes.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { + PurchaseOrderStatus, + ALL_PURCHASE_ORDER_STATUSES, + PURCHASE_ORDER_STATUS_LABELS, + getPurchaseOrderStatusLabel, + purchaseOrderStatusColors, + getPurchaseOrderStatusBadgeColor, + PurchaseOrderItemType, + ALL_PURCHASE_ORDER_ITEM_TYPES, + PURCHASE_ORDER_ITEM_TYPE_LABELS, + getPurchaseOrderItemTypeLabel +} from './purchaseTypes'; + +// ── PurchaseOrderStatus ───────────────────────────────────────────────── + +describe('PurchaseOrderStatus enum', () => { + it('has all expected values', () => { + expect(PurchaseOrderStatus.DRAFT).toBe('DRAFT'); + expect(PurchaseOrderStatus.CONFIRMED).toBe('CONFIRMED'); + expect(PurchaseOrderStatus.CANCELLED).toBe('CANCELLED'); + }); + + it('ALL_PURCHASE_ORDER_STATUSES contains all values', () => { + expect(ALL_PURCHASE_ORDER_STATUSES).toHaveLength(3); + expect(ALL_PURCHASE_ORDER_STATUSES).toContain(PurchaseOrderStatus.DRAFT); + expect(ALL_PURCHASE_ORDER_STATUSES).toContain(PurchaseOrderStatus.CONFIRMED); + expect(ALL_PURCHASE_ORDER_STATUSES).toContain(PurchaseOrderStatus.CANCELLED); + }); +}); + +describe('getPurchaseOrderStatusLabel', () => { + it('returns Spanish labels for known statuses', () => { + expect(getPurchaseOrderStatusLabel('DRAFT')).toBe('Borrador'); + expect(getPurchaseOrderStatusLabel('CONFIRMED')).toBe('Confirmada'); + expect(getPurchaseOrderStatusLabel('CANCELLED')).toBe('Cancelada'); + }); + + it('has a label for every status in the enum', () => { + for (const status of ALL_PURCHASE_ORDER_STATUSES) { + expect(PURCHASE_ORDER_STATUS_LABELS[status]).toBeDefined(); + } + }); + + it('returns raw value for unknown status', () => { + expect(getPurchaseOrderStatusLabel('UNKNOWN')).toBe('UNKNOWN'); + }); + + it('returns raw value for empty string', () => { + expect(getPurchaseOrderStatusLabel('')).toBe(''); + }); +}); + +describe('getPurchaseOrderStatusBadgeColor', () => { + it('returns correct badge colors', () => { + expect(getPurchaseOrderStatusBadgeColor('DRAFT')).toBe('warning'); + expect(getPurchaseOrderStatusBadgeColor('CONFIRMED')).toBe('success'); + expect(getPurchaseOrderStatusBadgeColor('CANCELLED')).toBe('error'); + }); + + it('has a color for every status in the enum', () => { + for (const status of ALL_PURCHASE_ORDER_STATUSES) { + expect(purchaseOrderStatusColors[status]).toBeDefined(); + } + }); + + it('returns warning (default) for unknown status', () => { + expect(getPurchaseOrderStatusBadgeColor('INVALID')).toBe('warning'); + }); +}); + +// ── PurchaseOrderItemType ─────────────────────────────────────────────── + +describe('PurchaseOrderItemType enum', () => { + it('has all expected values', () => { + expect(PurchaseOrderItemType.PRODUCT).toBe('PRODUCT'); + expect(PurchaseOrderItemType.LENS).toBe('LENS'); + }); + + it('ALL_PURCHASE_ORDER_ITEM_TYPES contains all values', () => { + expect(ALL_PURCHASE_ORDER_ITEM_TYPES).toHaveLength(2); + expect(ALL_PURCHASE_ORDER_ITEM_TYPES).toContain(PurchaseOrderItemType.PRODUCT); + expect(ALL_PURCHASE_ORDER_ITEM_TYPES).toContain(PurchaseOrderItemType.LENS); + }); +}); + +describe('getPurchaseOrderItemTypeLabel', () => { + it('returns Spanish labels for known types', () => { + expect(getPurchaseOrderItemTypeLabel('PRODUCT')).toBe('Producto'); + expect(getPurchaseOrderItemTypeLabel('LENS')).toBe('Lente'); + }); + + it('has a label for every item type in the enum', () => { + for (const type of ALL_PURCHASE_ORDER_ITEM_TYPES) { + expect(PURCHASE_ORDER_ITEM_TYPE_LABELS[type]).toBeDefined(); + } + }); + + it('returns raw value for unknown type', () => { + expect(getPurchaseOrderItemTypeLabel('UNKNOWN')).toBe('UNKNOWN'); + }); + + it('returns raw value for empty string', () => { + expect(getPurchaseOrderItemTypeLabel('')).toBe(''); + }); +}); diff --git a/src/lib/shared/enums/salesTypes.spec.ts b/src/lib/shared/enums/salesTypes.spec.ts new file mode 100644 index 00000000..c9ab9ccf --- /dev/null +++ b/src/lib/shared/enums/salesTypes.spec.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; +import { + SaleStatus, + ALL_SALE_STATUSES, + SALE_STATUS_LABELS, + getSaleStatusLabel, + saleStatusColors, + getSaleStatusBadgeColor, + PaymentMethod, + ALL_PAYMENT_METHODS, + PAYMENT_METHOD_LABELS, + getPaymentMethodLabel, + isBsPaymentMethod, + getExchangeRateLabel, + DiscountType, + ALL_DISCOUNT_TYPES, + DISCOUNT_TYPE_LABELS, + RefundStatus, + ALL_REFUND_STATUSES, + REFUND_STATUS_LABELS, + getRefundStatusLabel +} from './salesTypes'; + +// ── SaleStatus ────────────────────────────────────────────────────────── + +describe('SaleStatus enum', () => { + it('has all expected values', () => { + expect(SaleStatus.PENDING).toBe('PENDING'); + expect(SaleStatus.COMPLETED).toBe('COMPLETED'); + expect(SaleStatus.CANCELLED).toBe('CANCELLED'); + }); + + it('ALL_SALE_STATUSES contains all values', () => { + expect(ALL_SALE_STATUSES).toHaveLength(3); + expect(ALL_SALE_STATUSES).toContain(SaleStatus.PENDING); + expect(ALL_SALE_STATUSES).toContain(SaleStatus.COMPLETED); + expect(ALL_SALE_STATUSES).toContain(SaleStatus.CANCELLED); + }); +}); + +describe('getSaleStatusLabel', () => { + it('returns Spanish labels for known statuses', () => { + expect(getSaleStatusLabel('PENDING')).toBe('Pendiente'); + expect(getSaleStatusLabel('COMPLETED')).toBe('Completada'); + expect(getSaleStatusLabel('CANCELLED')).toBe('Cancelada'); + }); + + it('has a label for every sale status', () => { + for (const status of ALL_SALE_STATUSES) { + expect(SALE_STATUS_LABELS[status]).toBeDefined(); + } + }); + + it('returns raw value for unknown status', () => { + expect(getSaleStatusLabel('REFUNDED')).toBe('REFUNDED'); + }); + + it('returns raw value for empty string', () => { + expect(getSaleStatusLabel('')).toBe(''); + }); +}); + +describe('getSaleStatusBadgeColor', () => { + it('returns correct badge colors', () => { + expect(getSaleStatusBadgeColor('PENDING')).toBe('warning'); + expect(getSaleStatusBadgeColor('COMPLETED')).toBe('success'); + expect(getSaleStatusBadgeColor('CANCELLED')).toBe('error'); + }); + + it('has a color for every sale status', () => { + for (const status of ALL_SALE_STATUSES) { + expect(saleStatusColors[status]).toBeDefined(); + } + }); + + it('returns warning (default) for unknown status', () => { + expect(getSaleStatusBadgeColor('INVALID')).toBe('warning'); + }); +}); + +// ── PaymentMethod ─────────────────────────────────────────────────────── + +describe('PaymentMethod enum', () => { + it('has all expected values', () => { + expect(PaymentMethod.PAGO_MOVIL_BS).toBe('PAGO_MOVIL_BS'); + expect(PaymentMethod.TRANSFERENCIA_BS).toBe('TRANSFERENCIA_BS'); + expect(PaymentMethod.PUNTO_VENTA_BS).toBe('PUNTO_VENTA_BS'); + expect(PaymentMethod.EFECTIVO_BS).toBe('EFECTIVO_BS'); + expect(PaymentMethod.EFECTIVO_USD).toBe('EFECTIVO_USD'); + expect(PaymentMethod.BINANCE_USDT).toBe('BINANCE_USDT'); + }); + + it('ALL_PAYMENT_METHODS contains all values', () => { + expect(ALL_PAYMENT_METHODS).toHaveLength(6); + expect(ALL_PAYMENT_METHODS).toContain(PaymentMethod.PAGO_MOVIL_BS); + expect(ALL_PAYMENT_METHODS).toContain(PaymentMethod.TRANSFERENCIA_BS); + expect(ALL_PAYMENT_METHODS).toContain(PaymentMethod.PUNTO_VENTA_BS); + expect(ALL_PAYMENT_METHODS).toContain(PaymentMethod.EFECTIVO_BS); + expect(ALL_PAYMENT_METHODS).toContain(PaymentMethod.EFECTIVO_USD); + expect(ALL_PAYMENT_METHODS).toContain(PaymentMethod.BINANCE_USDT); + }); +}); + +describe('getPaymentMethodLabel', () => { + it('returns Spanish labels for known methods', () => { + expect(getPaymentMethodLabel('PAGO_MOVIL_BS')).toBe('Pago Móvil Bs'); + expect(getPaymentMethodLabel('TRANSFERENCIA_BS')).toBe('Transferencia Bs'); + expect(getPaymentMethodLabel('PUNTO_VENTA_BS')).toBe('Punto de Venta Bs'); + expect(getPaymentMethodLabel('EFECTIVO_BS')).toBe('Efectivo Bs'); + expect(getPaymentMethodLabel('EFECTIVO_USD')).toBe('Efectivo $'); + expect(getPaymentMethodLabel('BINANCE_USDT')).toBe('Binance USDT'); + }); + + it('has a label for every payment method', () => { + for (const method of ALL_PAYMENT_METHODS) { + expect(PAYMENT_METHOD_LABELS[method]).toBeDefined(); + } + }); + + it('returns raw value for unknown method', () => { + expect(getPaymentMethodLabel('CRYPTO')).toBe('CRYPTO'); + }); + + it('returns raw value for empty string', () => { + expect(getPaymentMethodLabel('')).toBe(''); + }); +}); + +describe('isBsPaymentMethod', () => { + it('returns true for Bolivar-denominated methods', () => { + expect(isBsPaymentMethod(PaymentMethod.PAGO_MOVIL_BS)).toBe(true); + expect(isBsPaymentMethod(PaymentMethod.TRANSFERENCIA_BS)).toBe(true); + expect(isBsPaymentMethod(PaymentMethod.PUNTO_VENTA_BS)).toBe(true); + expect(isBsPaymentMethod(PaymentMethod.EFECTIVO_BS)).toBe(true); + }); + + it('returns false for non-Bolivar methods', () => { + expect(isBsPaymentMethod(PaymentMethod.EFECTIVO_USD)).toBe(false); + expect(isBsPaymentMethod(PaymentMethod.BINANCE_USDT)).toBe(false); + }); +}); + +describe('getExchangeRateLabel', () => { + it('returns label for EFECTIVO_USD', () => { + expect(getExchangeRateLabel(PaymentMethod.EFECTIVO_USD)).toBe('Tasa USD Cash (Bs/$)'); + }); + + it('returns label for BINANCE_USDT', () => { + expect(getExchangeRateLabel(PaymentMethod.BINANCE_USDT)).toBe('Tasa USDT (Bs/USDT)'); + }); + + it('returns empty string for Bs payment methods', () => { + expect(getExchangeRateLabel(PaymentMethod.PAGO_MOVIL_BS)).toBe(''); + expect(getExchangeRateLabel(PaymentMethod.TRANSFERENCIA_BS)).toBe(''); + expect(getExchangeRateLabel(PaymentMethod.PUNTO_VENTA_BS)).toBe(''); + expect(getExchangeRateLabel(PaymentMethod.EFECTIVO_BS)).toBe(''); + }); +}); + +// ── DiscountType ──────────────────────────────────────────────────────── + +describe('DiscountType enum', () => { + it('has all expected values', () => { + expect(DiscountType.FIXED).toBe('FIXED'); + expect(DiscountType.PERCENTAGE).toBe('PERCENTAGE'); + }); + + it('ALL_DISCOUNT_TYPES contains all values', () => { + expect(ALL_DISCOUNT_TYPES).toHaveLength(2); + expect(ALL_DISCOUNT_TYPES).toContain(DiscountType.FIXED); + expect(ALL_DISCOUNT_TYPES).toContain(DiscountType.PERCENTAGE); + }); + + it('has a label for every discount type', () => { + for (const type of ALL_DISCOUNT_TYPES) { + expect(DISCOUNT_TYPE_LABELS[type]).toBeDefined(); + } + }); + + it('returns correct labels', () => { + expect(DISCOUNT_TYPE_LABELS[DiscountType.FIXED]).toBe('Fijo ($)'); + expect(DISCOUNT_TYPE_LABELS[DiscountType.PERCENTAGE]).toBe('Porcentaje (%)'); + }); +}); + +// ── RefundStatus ──────────────────────────────────────────────────────── + +describe('RefundStatus enum', () => { + it('has all expected values', () => { + expect(RefundStatus.REFUNDED).toBe('REFUNDED'); + expect(RefundStatus.RETAINED).toBe('RETAINED'); + expect(RefundStatus.NO_PAYMENT).toBe('NO_PAYMENT'); + }); + + it('ALL_REFUND_STATUSES contains all values', () => { + expect(ALL_REFUND_STATUSES).toHaveLength(3); + expect(ALL_REFUND_STATUSES).toContain(RefundStatus.REFUNDED); + expect(ALL_REFUND_STATUSES).toContain(RefundStatus.RETAINED); + expect(ALL_REFUND_STATUSES).toContain(RefundStatus.NO_PAYMENT); + }); +}); + +describe('getRefundStatusLabel', () => { + it('returns Spanish labels for known statuses', () => { + expect(getRefundStatusLabel('REFUNDED')).toBe('Reembolsado'); + expect(getRefundStatusLabel('RETAINED')).toBe('Retenido'); + expect(getRefundStatusLabel('NO_PAYMENT')).toBe('Sin pagos'); + }); + + it('has a label for every refund status', () => { + for (const status of ALL_REFUND_STATUSES) { + expect(REFUND_STATUS_LABELS[status as RefundStatus]).toBeDefined(); + } + }); + + it('returns raw value for unknown status', () => { + expect(getRefundStatusLabel('PARTIAL')).toBe('PARTIAL'); + }); + + it('returns raw value for empty string', () => { + expect(getRefundStatusLabel('')).toBe(''); + }); +}); diff --git a/src/lib/shared/enums/supplierTypes.spec.ts b/src/lib/shared/enums/supplierTypes.spec.ts new file mode 100644 index 00000000..3472e271 --- /dev/null +++ b/src/lib/shared/enums/supplierTypes.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { + SupplierType, + ALL_SUPPLIER_TYPES, + SUPPLIER_TYPE_LABELS, + getSupplierTypeLabel, + supplierTypeColors, + getSupplierTypeBadgeColor +} from './supplierTypes'; + +describe('SupplierType enum', () => { + it('has all expected values', () => { + expect(SupplierType.DISTRIBUTOR).toBe('DISTRIBUTOR'); + expect(SupplierType.LABORATORY).toBe('LABORATORY'); + expect(SupplierType.BOTH).toBe('BOTH'); + }); + + it('ALL_SUPPLIER_TYPES contains all values', () => { + expect(ALL_SUPPLIER_TYPES).toHaveLength(3); + expect(ALL_SUPPLIER_TYPES).toContain(SupplierType.DISTRIBUTOR); + expect(ALL_SUPPLIER_TYPES).toContain(SupplierType.LABORATORY); + expect(ALL_SUPPLIER_TYPES).toContain(SupplierType.BOTH); + }); +}); + +describe('getSupplierTypeLabel', () => { + it('returns Spanish labels for known types', () => { + expect(getSupplierTypeLabel('DISTRIBUTOR')).toBe('Distribuidor'); + expect(getSupplierTypeLabel('LABORATORY')).toBe('Laboratorio'); + expect(getSupplierTypeLabel('BOTH')).toBe('Ambos'); + }); + + it('has a label for every supplier type', () => { + for (const type of ALL_SUPPLIER_TYPES) { + expect(SUPPLIER_TYPE_LABELS[type]).toBeDefined(); + } + }); + + it('returns raw value for unknown type', () => { + expect(getSupplierTypeLabel('UNKNOWN')).toBe('UNKNOWN'); + }); + + it('returns raw value for empty string', () => { + expect(getSupplierTypeLabel('')).toBe(''); + }); +}); + +describe('getSupplierTypeBadgeColor', () => { + it('returns correct badge colors', () => { + expect(getSupplierTypeBadgeColor('DISTRIBUTOR')).toBe('info'); + expect(getSupplierTypeBadgeColor('LABORATORY')).toBe('success'); + expect(getSupplierTypeBadgeColor('BOTH')).toBe('purple'); + }); + + it('has a color for every supplier type', () => { + for (const type of ALL_SUPPLIER_TYPES) { + expect(supplierTypeColors[type]).toBeDefined(); + } + }); + + it('returns info (default) for unknown type', () => { + expect(getSupplierTypeBadgeColor('INVALID')).toBe('info'); + }); +}); From b3489e367079dd7a0cc6db0c5958e522e647c1d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:12:28 +0000 Subject: [PATCH 5/5] Phase 2: Add tests for server guards and audit utils (46 new tests) Agent-Logs-Url: https://github.com/Optikt/optikt-app/sessions/ba46b228-9632-4493-aa44-c4bd330bbeca Co-authored-by: NanezX <81595884+NanezX@users.noreply.github.com> --- src/lib/server/audit/utils.spec.ts | 251 +++++++++++++++++++++++++++++ src/lib/server/guards.spec.ts | 195 ++++++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 src/lib/server/audit/utils.spec.ts create mode 100644 src/lib/server/guards.spec.ts diff --git a/src/lib/server/audit/utils.spec.ts b/src/lib/server/audit/utils.spec.ts new file mode 100644 index 00000000..c7d9c78f --- /dev/null +++ b/src/lib/server/audit/utils.spec.ts @@ -0,0 +1,251 @@ +import { describe, it, expect } from 'vitest'; +import { + calculateDiff, + createChangeRecordForCreate, + createChangeRecordForDelete, + hasChanges +} from './utils'; + +describe('audit/utils', () => { + describe('calculateDiff', () => { + it('returns empty object when both entities are identical', () => { + const entity = { name: 'Test', status: 'active' }; + expect(calculateDiff(entity, entity)).toEqual({}); + }); + + it('detects changed string fields', () => { + const old = { name: 'Old Name', status: 'active' }; + const updated = { name: 'New Name', status: 'active' }; + const diff = calculateDiff(old, updated); + expect(diff).toEqual({ + name: { old: 'Old Name', new: 'New Name' } + }); + }); + + it('detects changed numeric fields', () => { + const old = { price: 100, quantity: 5 }; + const updated = { price: 150, quantity: 5 }; + const diff = calculateDiff(old, updated); + expect(diff).toEqual({ + price: { old: 100, new: 150 } + }); + }); + + it('detects null to value changes', () => { + const old = { name: null as string | null }; + const updated = { name: 'Hello' }; + const diff = calculateDiff(old, updated); + expect(diff).toEqual({ + name: { old: null, new: 'Hello' } + }); + }); + + it('detects value to null changes', () => { + const old = { name: 'Hello' as string | null }; + const updated = { name: null as string | null }; + const diff = calculateDiff(old, updated); + expect(diff).toEqual({ + name: { old: 'Hello', new: null } + }); + }); + + it('excludes updatedAt, createdAt, deletedAt, and id fields', () => { + const old = { + id: '1', + name: 'Test', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + deletedAt: null as Date | null + }; + const updated = { + id: '1', + name: 'Updated', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-06-01'), + deletedAt: new Date('2024-06-01') + }; + const diff = calculateDiff(old, updated); + expect(diff).toEqual({ + name: { old: 'Test', new: 'Updated' } + }); + }); + + it('respects additional excludeFields parameter', () => { + const old = { name: 'Test', secret: 'old-secret' }; + const updated = { name: 'Updated', secret: 'new-secret' }; + const diff = calculateDiff(old, updated, ['secret']); + expect(diff).toEqual({ + name: { old: 'Test', new: 'Updated' } + }); + }); + + it('handles Date comparisons correctly', () => { + const date = new Date('2024-06-15T12:00:00.000Z'); + const old = { birthDate: date }; + const updated = { birthDate: new Date(date.getTime()) }; + const diff = calculateDiff(old, updated); + expect(diff).toEqual({}); + }); + + it('detects Date changes', () => { + const old = { birthDate: new Date('2024-01-01T00:00:00.000Z') }; + const updated = { birthDate: new Date('2024-06-15T00:00:00.000Z') }; + const diff = calculateDiff(old, updated); + expect(Object.keys(diff)).toContain('birthDate'); + }); + + it('handles array comparisons', () => { + const old = { tags: ['a', 'b'] }; + const updated = { tags: ['a', 'b', 'c'] }; + const diff = calculateDiff(old, updated); + expect(diff).toHaveProperty('tags'); + }); + + it('detects no change for identical arrays', () => { + const old = { tags: ['a', 'b'] }; + const updated = { tags: ['a', 'b'] }; + const diff = calculateDiff(old, updated); + expect(diff).toEqual({}); + }); + + it('handles nested object comparisons', () => { + const old = { meta: { key: 'value1' } }; + const updated = { meta: { key: 'value2' } }; + const diff = calculateDiff(old, updated); + expect(diff).toHaveProperty('meta'); + }); + + it('detects new keys in updated entity', () => { + const old: Record = { name: 'Test' }; + const updated: Record = { name: 'Test', email: 'test@example.com' }; + const diff = calculateDiff(old, updated); + expect(diff).toEqual({ + email: { old: null, new: 'test@example.com' } + }); + }); + + it('normalizes undefined to null', () => { + const old: Record = { name: 'Test', email: undefined }; + const updated: Record = { name: 'Test', email: 'test@example.com' }; + const diff = calculateDiff(old, updated); + expect(diff.email?.old).toBeNull(); + expect(diff.email?.new).toBe('test@example.com'); + }); + + it('handles multiple changes at once', () => { + const old = { name: 'Old', status: 'active', price: 100 }; + const updated = { name: 'New', status: 'inactive', price: 200 }; + const diff = calculateDiff(old, updated); + expect(Object.keys(diff)).toHaveLength(3); + }); + }); + + describe('createChangeRecordForCreate', () => { + it('creates change record with all fields as new', () => { + const entity = { name: 'Test', status: 'active', price: 100 }; + const record = createChangeRecordForCreate(entity); + expect(record).toEqual({ + name: { old: null, new: 'Test' }, + status: { old: null, new: 'active' }, + price: { old: null, new: 100 } + }); + }); + + it('excludes system fields (id, createdAt, updatedAt, deletedAt)', () => { + const entity = { + id: '1', + name: 'Test', + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null as Date | null + }; + const record = createChangeRecordForCreate(entity); + expect(record).toEqual({ + name: { old: null, new: 'Test' } + }); + }); + + it('excludes null and undefined values', () => { + const entity = { name: 'Test', email: null as string | null, phone: undefined }; + const record = createChangeRecordForCreate(entity); + expect(record).toEqual({ + name: { old: null, new: 'Test' } + }); + }); + + it('respects additional excludeFields', () => { + const entity = { name: 'Test', secret: 'hidden' }; + const record = createChangeRecordForCreate(entity, ['secret']); + expect(record).toEqual({ + name: { old: null, new: 'Test' } + }); + }); + + it('normalizes Date values to ISO strings', () => { + const date = new Date('2024-06-15T12:00:00.000Z'); + const entity = { birthDate: date }; + const record = createChangeRecordForCreate(entity); + expect(record.birthDate?.new).toBe('2024-06-15T12:00:00.000Z'); + }); + }); + + describe('createChangeRecordForDelete', () => { + it('creates change record with all fields as old', () => { + const entity = { name: 'Test', status: 'active', price: 100 }; + const record = createChangeRecordForDelete(entity); + expect(record).toEqual({ + name: { old: 'Test', new: null }, + status: { old: 'active', new: null }, + price: { old: 100, new: null } + }); + }); + + it('excludes system fields', () => { + const entity = { + id: '1', + name: 'Test', + createdAt: new Date(), + updatedAt: new Date() + }; + const record = createChangeRecordForDelete(entity); + expect(record).toEqual({ + name: { old: 'Test', new: null } + }); + }); + + it('excludes null and undefined values', () => { + const entity = { name: 'Test', email: null as string | null }; + const record = createChangeRecordForDelete(entity); + expect(record).toEqual({ + name: { old: 'Test', new: null } + }); + }); + + it('respects additional excludeFields', () => { + const entity = { name: 'Test', internal: 'data' }; + const record = createChangeRecordForDelete(entity, ['internal']); + expect(record).toEqual({ + name: { old: 'Test', new: null } + }); + }); + }); + + describe('hasChanges', () => { + it('returns true when changes exist', () => { + expect(hasChanges({ name: { old: 'Old', new: 'New' } })).toBe(true); + }); + + it('returns false for empty change record', () => { + expect(hasChanges({})).toBe(false); + }); + + it('returns true for multiple changes', () => { + expect( + hasChanges({ + name: { old: 'A', new: 'B' }, + status: { old: 'active', new: 'inactive' } + }) + ).toBe(true); + }); + }); +}); diff --git a/src/lib/server/guards.spec.ts b/src/lib/server/guards.spec.ts new file mode 100644 index 00000000..2225420d --- /dev/null +++ b/src/lib/server/guards.spec.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { UserRole } from '$lib/shared/enums'; + +// Mock $app/server +const mockGetRequestEvent = vi.fn(); +vi.mock('$app/server', () => ({ + getRequestEvent: () => mockGetRequestEvent() +})); + +// Capture HttpError throws from @sveltejs/kit error() +class HttpError { + constructor( + public status: number, + public body: { message: string } + ) {} +} + +vi.mock('@sveltejs/kit', () => ({ + error: (status: number, message: string) => { + throw new HttpError(status, { message }); + } +})); + +import { + getCurrentUser, + getCurrentSession, + requireAuth, + requireRole, + requireAdmin, + requireSuperuser +} from './guards'; + +function makeUser(overrides: Record = {}) { + return { + id: 'user-1', + username: 'testuser', + email: 'test@example.com', + fullName: 'Test User', + role: UserRole.VIEWER, + isActive: true, + isSuperuser: false, + ...overrides + }; +} + +describe('guards', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getCurrentUser', () => { + it('returns the user from locals', () => { + const user = makeUser(); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(getCurrentUser()).toBe(user); + }); + + it('returns undefined when no user in locals', () => { + mockGetRequestEvent.mockReturnValue({ locals: {} }); + expect(getCurrentUser()).toBeUndefined(); + }); + }); + + describe('getCurrentSession', () => { + it('returns the session from locals', () => { + const session = { id: 'session-1', userId: 'user-1' }; + mockGetRequestEvent.mockReturnValue({ locals: { session } }); + expect(getCurrentSession()).toBe(session); + }); + + it('returns undefined when no session in locals', () => { + mockGetRequestEvent.mockReturnValue({ locals: {} }); + expect(getCurrentSession()).toBeUndefined(); + }); + }); + + describe('requireAuth', () => { + it('returns user when authenticated', () => { + const user = makeUser(); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(requireAuth()).toBe(user); + }); + + it('throws 401 when user is null', () => { + mockGetRequestEvent.mockReturnValue({ locals: { user: null } }); + expect(() => requireAuth()).toThrow(HttpError); + try { + requireAuth(); + } catch (e) { + expect((e as HttpError).status).toBe(401); + } + }); + + it('throws 401 when user is undefined', () => { + mockGetRequestEvent.mockReturnValue({ locals: {} }); + expect(() => requireAuth()).toThrow(HttpError); + }); + }); + + describe('requireRole', () => { + it('returns user when role matches', () => { + const user = makeUser({ role: UserRole.ADMIN }); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(requireRole(UserRole.ADMIN)).toBe(user); + }); + + it('returns user when role is in allowed list', () => { + const user = makeUser({ role: UserRole.MANAGER }); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(requireRole(UserRole.ADMIN, UserRole.MANAGER)).toBe(user); + }); + + it('throws 403 when role does not match', () => { + const user = makeUser({ role: UserRole.VIEWER }); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(() => requireRole(UserRole.ADMIN)).toThrow(HttpError); + try { + requireRole(UserRole.ADMIN); + } catch (e) { + expect((e as HttpError).status).toBe(403); + } + }); + + it('throws 401 when not authenticated', () => { + mockGetRequestEvent.mockReturnValue({ locals: {} }); + expect(() => requireRole(UserRole.ADMIN)).toThrow(HttpError); + try { + requireRole(UserRole.ADMIN); + } catch (e) { + expect((e as HttpError).status).toBe(401); + } + }); + }); + + describe('requireAdmin', () => { + it('returns SUPERADMIN user', () => { + const user = makeUser({ role: UserRole.SUPERADMIN }); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(requireAdmin()).toBe(user); + }); + + it('returns ADMIN user', () => { + const user = makeUser({ role: UserRole.ADMIN }); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(requireAdmin()).toBe(user); + }); + + it('returns MANAGER user', () => { + const user = makeUser({ role: UserRole.MANAGER }); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(requireAdmin()).toBe(user); + }); + + it('throws 403 for SELLER', () => { + const user = makeUser({ role: UserRole.SELLER }); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(() => requireAdmin()).toThrow(HttpError); + }); + + it('throws 403 for VIEWER', () => { + const user = makeUser({ role: UserRole.VIEWER }); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(() => requireAdmin()).toThrow(HttpError); + }); + }); + + describe('requireSuperuser', () => { + it('returns superuser', () => { + const user = makeUser({ isSuperuser: true }); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(requireSuperuser()).toBe(user); + }); + + it('throws 403 when not superuser', () => { + const user = makeUser({ isSuperuser: false }); + mockGetRequestEvent.mockReturnValue({ locals: { user } }); + expect(() => requireSuperuser()).toThrow(HttpError); + try { + requireSuperuser(); + } catch (e) { + expect((e as HttpError).status).toBe(403); + } + }); + + it('throws 401 when not authenticated', () => { + mockGetRequestEvent.mockReturnValue({ locals: {} }); + expect(() => requireSuperuser()).toThrow(HttpError); + try { + requireSuperuser(); + } catch (e) { + expect((e as HttpError).status).toBe(401); + } + }); + }); +});