diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 00000000..242e1efd --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,83 @@ +# Lints, builds and tests the backend and frontend on every push, but +# only for the side(s) that actually changed. + +name: Verify + +permissions: + contents: read + +on: + push: + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + backend: ${{ steps.filter.outputs.backend }} + frontend: ${{ steps.filter.outputs.frontend }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: dorny/paths-filter@v4 + id: filter + with: + filters: | + backend: + - 'backend/**' + frontend: + - 'frontend/**' + + backend: + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + package_json_file: backend/package.json + - name: Use Node.js + uses: actions/setup-node@v7 + with: + node-version: '24.x' + cache: 'pnpm' + cache-dependency-path: backend/pnpm-lock.yaml + - name: Install dependencies + run: cd backend/ && pnpm install + - name: Lint + run: cd backend/ && pnpm lint + - name: Build + run: cd backend/ && pnpm run build + - name: Test + run: cd backend/ && pnpm test + + frontend: + needs: changes + if: needs.changes.outputs.frontend == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + package_json_file: frontend/package.json + - name: Use Node.js + uses: actions/setup-node@v7 + with: + node-version: '24.x' + cache: 'pnpm' + cache-dependency-path: | + backend/pnpm-lock.yaml + frontend/pnpm-lock.yaml + - name: Build backend (frontend imports its compiled types via the @backend alias) + run: cd backend/ && pnpm install && pnpm run build + - name: Install dependencies + run: cd frontend/ && pnpm install + - name: Lint + run: cd frontend/ && pnpm lint + - name: Build + run: cd frontend/ && pnpm run build + - name: Test + run: cd frontend/ && pnpm test diff --git a/backend/access.ts b/backend/access.ts new file mode 100644 index 00000000..97f8ef6c --- /dev/null +++ b/backend/access.ts @@ -0,0 +1,63 @@ +import { CollectionAccessAction, UserContext } from 'amberbase'; +import { SetlistEntity } from './models.js'; +import { UserRole } from './definitions.js'; + +/** + * Access rights for the setlists collection. + * Public setlists can be created by editors and performers. + * Private setlists can be created by everyone. + */ +export const setlistsAccessRights = (user: UserContext, doc: SetlistEntity | null, action: CollectionAccessAction): boolean => { + if (action === 'create') { + if (user.roles.includes(UserRole.Editor) || user.roles.includes(UserRole.Performer)) { + return true; + } + if (user.roles.includes(UserRole.Reader)) { + return doc?.createdBy === user.userId && !doc?.isPublic; + } + return false; + } + // Setlists can be subscribed to by all roles, but only public setlists can be read per default. + // This is done via access tags below. + if (action === 'subscribe') { + return user.roles.includes(UserRole.Editor) || user.roles.includes(UserRole.Performer) || user.roles.includes(UserRole.Reader); + } + // Setlists can be deleted by editors or the corresponding creator. + if (action === 'delete') { + return user.roles.includes(UserRole.Editor) || doc?.createdBy === user.userId; + } + // Setlists can be updated by the corresponding creator. + // Public setlists can be updated by editors and performers. + if (action === 'update') { + if (doc?.createdBy === user.userId) { + return true; + } + if (doc?.isPublic && (user.roles.includes(UserRole.Editor) || user.roles.includes(UserRole.Performer))) { + return true; + } + return false; + } + + return false; +}; + +/** + * The owner of a private setlist can share it with other users + */ +export const setlistsAccessTagsFromDocument = (doc: SetlistEntity): string[] => { + const tags = [`o-${doc.createdBy}`]; + if (doc.isPublic) { + tags.push('public'); + } else { + doc.sharedWith.forEach((userId) => { + tags.push(`s-${userId}`); + }); + } + return tags; +}; + +export const setlistsAccessTagsFromUser = (user: UserContext): string[] => [ + 'public', + `o-${user.userId}`, + `s-${user.userId}`, +]; diff --git a/backend/definitions.ts b/backend/definitions.ts index 5db27206..fd09ee31 100644 --- a/backend/definitions.ts +++ b/backend/definitions.ts @@ -109,7 +109,7 @@ export const can = (action: string, roles: UserRole[], context?: CanContext): bo [UserRole.Performer]: 2, [UserRole.Reader]: 1, }; - const highestRole = roles.toSorted((a, b) => level[a] - level[b])[0]; + const highestRole = roles.toSorted((a, b) => level[b] - level[a])[0]; // Admins are allowed in general if (highestRole === UserRole.Admin) { diff --git a/backend/index.ts b/backend/index.ts index 6334af0a..a4dab4b3 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -1,9 +1,10 @@ -import { amber, CollectionAccessAction, UserContext } from 'amberbase'; +import { amber } from 'amberbase'; import cookieParser from 'cookie-parser'; import express from 'express'; import rateLimit from 'express-rate-limit'; import * as path from 'path'; import { fileURLToPath } from 'url'; +import { setlistsAccessRights, setlistsAccessTagsFromDocument, setlistsAccessTagsFromUser } from './access.js'; import { SetlistEntity, SongEntity } from './models.js'; import { UserRole } from './definitions.js'; @@ -27,59 +28,9 @@ const appInit = amber() } }) .withCollection('setlists', { - accessRights: (user: UserContext, doc: SetlistEntity | null, action: CollectionAccessAction) => { - // Public setlists can be created by editors and performers. - // Private setlists can be created by everyone. - if (action === 'create') { - if (user.roles.includes(UserRole.Editor) || user.roles.includes(UserRole.Performer)) { - return true; - } - if (user.roles.includes(UserRole.Reader)) { - return doc?.createdBy === user.userId && !doc?.isPublic; - } - return false; - } - // Setlists can be subscribed to by all roles, but only public setlists can be read per default. - // This is done via access tags below. - if (action === 'subscribe') { - return user.roles.includes(UserRole.Editor) || user.roles.includes(UserRole.Performer) || user.roles.includes(UserRole.Reader); - } - // Setlists can be deleted by editors or the corresponding creator. - if (action === 'delete') { - return user.roles.includes(UserRole.Editor) || doc?.createdBy === user.userId; - } - // Setlists can be updated by the corresponding creator. - // Public setlists can be updated by editors and performers. - if (action === 'update') { - if (doc?.createdBy === user.userId) { - return true; - } - if (doc?.isPublic && (user.roles.includes(UserRole.Editor) || user.roles.includes(UserRole.Performer))) { - return true; - } - return false; - } - - return false; - }, - - // The owner of a private setlist can share it with other users - accessTagsFromDocument: (doc: SetlistEntity) => { - const tags = [`o-${doc.createdBy}`]; - if (doc.isPublic) { - tags.push('public'); - } else { - doc.sharedWith.forEach((userId) => { - tags.push(`s-${userId}`); - }); - } - return tags; - }, - accessTagsFromUser: (user: UserContext) => [ - 'public', - `o-${user.userId}`, - `s-${user.userId}`, - ], + accessRights: setlistsAccessRights, + accessTagsFromDocument: setlistsAccessTagsFromDocument, + accessTagsFromUser: setlistsAccessTagsFromUser, }) .withUi({ availableRoles: [UserRole.Editor, UserRole.Performer, UserRole.Reader], diff --git a/backend/package.json b/backend/package.json index d962488c..e9735bc3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,8 @@ "main": "index.js", "packageManager": "pnpm@11.9.0", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest run", + "test:watch": "vitest", "start": "node dist/index.js", "build": "tsc", "lint": "oxlint", @@ -25,6 +26,7 @@ "@types/ws": "^8.18.1", "oxlint": "^1.76.0", "oxlint-tsgolint": "^7.0.2001", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" } } diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 5dab66e3..7ea64a98 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -36,9 +36,34 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)) packages: + '@emnapi/core@2.0.0-alpha.3': + resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} + + '@emnapi/runtime@2.0.0-alpha.3': + resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} + + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@oxlint-tsgolint/darwin-arm64@7.0.2001': resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} cpu: [arm64] @@ -191,9 +216,115 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-android-arm64@1.2.1': + resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.1': + resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.1': + resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.1': + resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.1': + resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.1': + resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.1': + resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.1': + resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.2.1': + resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.1': + resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -202,6 +333,12 @@ packages: peerDependencies: '@types/express': '*' + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.3': resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==} @@ -232,6 +369,35 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -246,6 +412,10 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + body-parser@1.20.6: resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -266,6 +436,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -282,6 +456,9 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.7: resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} engines: {node: '>= 0.8.0'} @@ -329,6 +506,10 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -348,6 +529,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -355,10 +539,17 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.6.1: resolution: {integrity: sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==} engines: {node: '>= 16'} @@ -373,6 +564,15 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + finalhandler@1.3.2: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} @@ -393,6 +593,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -442,10 +647,87 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mariadb@3.5.3: resolution: {integrity: sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==} engines: {node: '>= 20.0.0'} @@ -500,6 +782,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -512,6 +799,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -546,6 +837,20 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -570,6 +875,11 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rolldown@1.2.1: + resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -615,14 +925,45 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -651,6 +992,95 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -668,6 +1098,33 @@ packages: snapshots: + '@emnapi/core@2.0.0-alpha.3': + dependencies: + '@emnapi/wasi-threads': 2.0.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@2.0.0-alpha.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@2.0.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.142.0': {} + '@oxlint-tsgolint/darwin-arm64@7.0.2001': optional: true @@ -743,11 +1200,74 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.76.0': optional: true + '@rolldown/binding-android-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.1': + optional: true + + '@rolldown/binding-darwin-x64@1.2.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.2.1': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 '@types/node': 26.1.2 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 26.1.2 @@ -756,6 +1276,10 @@ snapshots: dependencies: '@types/express': 5.0.6 + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.3': dependencies: '@types/node': 26.1.2 @@ -794,6 +1318,47 @@ snapshots: dependencies: '@types/node': 26.1.2 + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@26.1.2))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.0(@types/node@26.1.2) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -817,6 +1382,8 @@ snapshots: array-flatten@1.1.1: {} + assertion-error@2.0.1: {} + body-parser@1.20.6: dependencies: bytes: 3.1.2 @@ -860,6 +1427,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chai@6.2.2: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -870,6 +1439,8 @@ snapshots: content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-parser@1.4.7: dependencies: cookie: 0.7.2 @@ -897,6 +1468,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@2.1.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -911,14 +1484,22 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 escape-html@1.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + etag@1.8.1: {} + expect-type@1.4.0: {} + express-rate-limit@8.6.1(express@5.2.1): dependencies: debug: 4.4.3 @@ -996,6 +1577,10 @@ snapshots: transitivePeerDependencies: - supports-color + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + finalhandler@1.3.2: dependencies: debug: 2.6.9 @@ -1025,6 +1610,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-intrinsic@1.3.0: @@ -1077,8 +1665,61 @@ snapshots: is-promise@4.0.0: {} + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lru-cache@11.5.2: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + mariadb@3.5.3: dependencies: '@types/geojson': 7946.0.16 @@ -1117,12 +1758,16 @@ snapshots: ms@2.1.3: {} + nanoid@3.3.16: {} + negotiator@0.6.3: {} negotiator@1.0.0: {} object-inspect@1.13.4: {} + obug@2.1.4: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -1169,6 +1814,18 @@ snapshots: path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -1197,6 +1854,27 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + rolldown@1.2.1: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.1 + '@rolldown/binding-darwin-arm64': 1.2.1 + '@rolldown/binding-darwin-x64': 1.2.1 + '@rolldown/binding-freebsd-x64': 1.2.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 + '@rolldown/binding-linux-arm64-gnu': 1.2.1 + '@rolldown/binding-linux-arm64-musl': 1.2.1 + '@rolldown/binding-linux-ppc64-gnu': 1.2.1 + '@rolldown/binding-linux-s390x-gnu': 1.2.1 + '@rolldown/binding-linux-x64-gnu': 1.2.1 + '@rolldown/binding-linux-x64-musl': 1.2.1 + '@rolldown/binding-openharmony-arm64': 1.2.1 + '@rolldown/binding-wasm32-wasi': 1.2.1 + '@rolldown/binding-win32-arm64-msvc': 1.2.1 + '@rolldown/binding-win32-x64-msvc': 1.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -1293,10 +1971,32 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.2.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + toidentifier@1.0.1: {} + tslib@2.8.1: + optional: true + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -1318,6 +2018,49 @@ snapshots: vary@1.1.2: {} + vite@8.2.0(@types/node@26.1.2): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.2 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@26.1.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.2 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrappy@1.0.2: {} ws@8.21.1: {} diff --git a/backend/test/access.test.ts b/backend/test/access.test.ts new file mode 100644 index 00000000..92f79b1b --- /dev/null +++ b/backend/test/access.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { setlistsAccessRights, setlistsAccessTagsFromDocument, setlistsAccessTagsFromUser } from '../access.js'; +import { SetlistEntity } from '../models.js'; +import { UserRole } from '../definitions.js'; + +const setlist = (overrides: Partial = {}): SetlistEntity => ({ + active: false, + createdBy: 'owner', + date: '2026-01-01', + isPublic: false, + position: 0, + sharedWith: [], + slug: 'a-setlist', + songs: [], + title: 'A setlist', + ...overrides, +}); + +describe('setlistsAccessRights', () => { + describe('create', () => { + it('allows editors and performers', () => { + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Editor] }, null, 'create')).toBe(true); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Performer] }, null, 'create')).toBe(true); + }); + + it('allows readers to create their own private setlist', () => { + const doc = setlist({ createdBy: 'u1', isPublic: false }); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Reader] }, doc, 'create')).toBe(true); + }); + + it('rejects readers creating a public setlist, or one not owned by them', () => { + const publicDoc = setlist({ createdBy: 'u1', isPublic: true }); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Reader] }, publicDoc, 'create')).toBe(false); + + const othersDoc = setlist({ createdBy: 'u2', isPublic: false }); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Reader] }, othersDoc, 'create')).toBe(false); + }); + }); + + describe('subscribe', () => { + it('allows every role', () => { + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Editor] }, null, 'subscribe')).toBe(true); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Performer] }, null, 'subscribe')).toBe(true); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Reader] }, null, 'subscribe')).toBe(true); + }); + }); + + describe('delete', () => { + it('allows editors regardless of ownership', () => { + const doc = setlist({ createdBy: 'someone-else' }); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Editor] }, doc, 'delete')).toBe(true); + }); + + it('allows the owner regardless of role', () => { + const doc = setlist({ createdBy: 'u1' }); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Reader] }, doc, 'delete')).toBe(true); + }); + + it('rejects non-owner, non-editors', () => { + const doc = setlist({ createdBy: 'u2' }); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Performer] }, doc, 'delete')).toBe(false); + }); + }); + + describe('update', () => { + it('allows the owner regardless of role', () => { + const doc = setlist({ createdBy: 'u1', isPublic: false }); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Reader] }, doc, 'update')).toBe(true); + }); + + it('allows editors/performers to update public setlists they do not own', () => { + const doc = setlist({ createdBy: 'u2', isPublic: true }); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Editor] }, doc, 'update')).toBe(true); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Performer] }, doc, 'update')).toBe(true); + }); + + it('rejects readers on public setlists they do not own', () => { + const doc = setlist({ createdBy: 'u2', isPublic: true }); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Reader] }, doc, 'update')).toBe(false); + }); + + it('rejects non-owners on private setlists', () => { + const doc = setlist({ createdBy: 'u2', isPublic: false }); + expect(setlistsAccessRights({ userId: 'u1', roles: [UserRole.Editor] }, doc, 'update')).toBe(false); + }); + }); +}); + +describe('setlistsAccessTagsFromDocument', () => { + it('tags a public setlist with the owner and public tags', () => { + const doc = setlist({ createdBy: 'u1', isPublic: true, sharedWith: ['u2'] }); + expect(setlistsAccessTagsFromDocument(doc)).toEqual(['o-u1', 'public']); + }); + + it('tags a private setlist with the owner and each shared user', () => { + const doc = setlist({ createdBy: 'u1', isPublic: false, sharedWith: ['u2', 'u3'] }); + expect(setlistsAccessTagsFromDocument(doc)).toEqual(['o-u1', 's-u2', 's-u3']); + }); +}); + +describe('setlistsAccessTagsFromUser', () => { + it('returns the public tag plus the user\'s own owner/shared tags', () => { + expect(setlistsAccessTagsFromUser({ userId: 'u1', roles: [UserRole.Reader] })).toEqual(['public', 'o-u1', 's-u1']); + }); +}); diff --git a/backend/test/definitions.test.ts b/backend/test/definitions.test.ts new file mode 100644 index 00000000..3f5489ab --- /dev/null +++ b/backend/test/definitions.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { can } from '../definitions.js'; +import { UserRole } from '../definitions.js'; + +describe('can', () => { + it('rejects an empty action', () => { + expect(can('', [UserRole.Editor])).toBe(false); + }); + + it('rejects an empty roles list', () => { + expect(can('createSetlists', [])).toBe(false); + }); + + it('allows admins to do anything, including unknown actions', () => { + expect(can('createSetlists', [UserRole.Admin])).toBe(true); + expect(can('deleteSongs', [UserRole.Admin])).toBe(true); + expect(can('nonsenseAction', [UserRole.Admin])).toBe(true); + }); + + it('resolves the highest-privilege role out of a mixed roles list', () => { + // reader+editor should behave like editor alone + expect(can('createSetlists', [UserRole.Reader, UserRole.Editor])).toBe(true); + expect(can('createSongs', [UserRole.Reader, UserRole.Editor])).toBe(true); + // admin+editor should still get the admin bypass + expect(can('nonsenseAction', [UserRole.Admin, UserRole.Editor])).toBe(true); + }); + + it('rejects an unknown action for non-admins', () => { + expect(can('nonsenseAction', [UserRole.Editor])).toBe(false); + }); + + describe('createSetlists', () => { + it('allows editors and performers', () => { + expect(can('createSetlists', [UserRole.Editor])).toBe(true); + expect(can('createSetlists', [UserRole.Performer])).toBe(true); + }); + + it('rejects readers', () => { + expect(can('createSetlists', [UserRole.Reader])).toBe(false); + }); + }); + + describe('updateSetlists', () => { + it('rejects without a context, regardless of role', () => { + expect(can('updateSetlists', [UserRole.Editor])).toBe(false); + }); + + it('allows the owner regardless of role', () => { + expect(can('updateSetlists', [UserRole.Reader], { userId: 'u1', ownerId: 'u1', isPublic: false })).toBe(true); + }); + + it('allows editors/performers to update public setlists they do not own', () => { + expect(can('updateSetlists', [UserRole.Editor], { userId: 'u1', ownerId: 'u2', isPublic: true })).toBe(true); + expect(can('updateSetlists', [UserRole.Performer], { userId: 'u1', ownerId: 'u2', isPublic: true })).toBe(true); + }); + + it('rejects readers on public setlists they do not own', () => { + expect(can('updateSetlists', [UserRole.Reader], { userId: 'u1', ownerId: 'u2', isPublic: true })).toBe(false); + }); + + it('rejects non-owners on private setlists', () => { + expect(can('updateSetlists', [UserRole.Editor], { userId: 'u1', ownerId: 'u2', isPublic: false })).toBe(false); + }); + }); + + describe('deleteSetlists', () => { + it('allows editors even without a context', () => { + expect(can('deleteSetlists', [UserRole.Editor])).toBe(true); + }); + + it('allows the owner', () => { + expect(can('deleteSetlists', [UserRole.Reader], { userId: 'u1', ownerId: 'u1' })).toBe(true); + }); + + it('rejects non-owner, non-editors', () => { + expect(can('deleteSetlists', [UserRole.Performer], { userId: 'u1', ownerId: 'u2' })).toBe(false); + expect(can('deleteSetlists', [UserRole.Reader])).toBe(false); + }); + }); + + describe.each(['createSongs', 'updateSongs', 'deleteSongs'])('%s', (action) => { + it('allows editors', () => { + expect(can(action, [UserRole.Editor])).toBe(true); + }); + + it('rejects performers and readers', () => { + expect(can(action, [UserRole.Performer])).toBe(false); + expect(can(action, [UserRole.Reader])).toBe(false); + }); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 3577c307..5bb5788c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,7 +27,9 @@ "serve": "vite preview", "typecheck": "vue-tsc --noEmit", "lint": "oxlint", - "lint:fix": "oxlint --fix" + "lint:fix": "oxlint --fix", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@kyvg/vue3-notification": "^3.1.3", @@ -62,6 +64,7 @@ "tailwindcss": "^4.0.14", "typescript": "^6.0.3", "vite": "^8.2.0", + "vitest": "^4.1.10", "vue-tsc": "^3.3.9" }, "browserslist": [ diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 822dd956..d428e5e8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: vite: specifier: ^8.2.0 version: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) vue-tsc: specifier: ^3.3.9 version: 3.3.9(typescript@6.0.3) @@ -612,6 +615,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -725,6 +731,12 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -750,6 +762,35 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -860,6 +901,10 @@ packages: amber-client@0.1.0-beta.1: resolution: {integrity: sha512-q2Q6WYZYVJRJlADjTOpG+7tGR7xWkDf2WCOXa8xpldT8NkXERiAfHMH4HwB0ktOPrJNWWXJgr4lcMdCnJzMCxA==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-kit@2.2.0: resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} @@ -884,6 +929,10 @@ packages: browserify-zlib@0.2.0: resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chart.js@4.5.1: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} @@ -902,6 +951,9 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -923,9 +975,19 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + exsolve@1.1.1: resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} @@ -1166,6 +1228,10 @@ packages: nostics@1.2.0: resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + oxlint-tsgolint@7.0.2001: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true @@ -1252,6 +1318,9 @@ packages: scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sortablejs@1.14.0: resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==} @@ -1259,6 +1328,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -1269,10 +1344,21 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1373,6 +1459,47 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -1444,6 +1571,11 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + xmldoc@2.0.3: resolution: {integrity: sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w==} engines: {node: '>=12.0.0'} @@ -1785,6 +1917,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.3': optional: true + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -1874,8 +2008,14 @@ snapshots: tslib: 2.8.1 optional: true - '@types/estree@1.0.9': - optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} '@types/jsesc@2.5.1': {} @@ -1900,6 +2040,47 @@ snapshots: vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) vue: 3.5.40(typescript@6.0.3) + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@volar/language-core@2.4.28': dependencies: '@volar/source-map': 2.4.28 @@ -2042,6 +2223,8 @@ snapshots: amber-client@0.1.0-beta.1: {} + assertion-error@2.0.1: {} + ast-kit@2.2.0: dependencies: '@babel/parser': 7.29.7 @@ -2067,6 +2250,8 @@ snapshots: dependencies: pako: 1.0.11 + chai@6.2.2: {} + chart.js@4.5.1: dependencies: '@kurkle/color': 0.3.4 @@ -2081,6 +2266,8 @@ snapshots: confbox@0.2.4: {} + convert-source-map@2.0.0: {} + csstype@3.2.3: {} date-fns@4.4.0: {} @@ -2096,8 +2283,16 @@ snapshots: entities@7.0.1: {} + es-module-lexer@2.3.1: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + exsolve@1.1.1: {} fast-deep-equal@3.1.3: {} @@ -2273,6 +2468,8 @@ snapshots: nostics@1.2.0: {} + obug@2.1.4: {} + oxlint-tsgolint@7.0.2001: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 7.0.2001 @@ -2419,21 +2616,33 @@ snapshots: scule@1.3.0: {} + siginfo@2.0.0: {} + sortablejs@1.14.0: {} source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} tiny-inflate@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@3.1.1: {} + tslib@2.8.1: {} typescript@6.0.3: {} @@ -2480,6 +2689,33 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 + vitest@4.1.10(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.2 + transitivePeerDependencies: + - msw + vscode-uri@3.1.0: {} vue-demi@0.14.10(vue@3.5.40(typescript@6.0.3)): @@ -2559,6 +2795,11 @@ snapshots: webpack-virtual-modules@0.6.2: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + xmldoc@2.0.3: dependencies: sax: 1.6.1 diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 1f315403..03406c90 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -134,7 +134,11 @@ function parsedContent(content: string, keyOffset: number, showChords: boolean, numbers.push((!isNaN(parseInt(n))) ? n : '0'); break; default: - // a non existent part tag was found + // a non existent part tag was found - treat as an untyped part + // instead of desyncing types/classes/numbers from parsed content + types.push(''); + classes.push(''); + numbers.push('0'); break; } // consider next part diff --git a/frontend/tests/utils.test.ts b/frontend/tests/utils.test.ts new file mode 100644 index 00000000..67309144 --- /dev/null +++ b/frontend/tests/utils.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from 'vitest'; +import type { SongEntity } from '@backend/models'; +import { + firstParam, + humanDate, + humanFileSize, + initials, + isChordLine, + openLyricsXML, + parseNumberInput, + parsedContent, + sdHighlight, + sortTags, + urlify, +} from '@/utils'; + +describe('isChordLine', () => { + it('rejects empty and whitespace-only lines', () => { + expect(isChordLine('')).toBe(false); + expect(isChordLine(' ')).toBe(false); + }); + + it('recognizes a line ending in exactly two trailing spaces', () => { + expect(isChordLine('Em ')).toBe(true); + }); + + it('rejects lines with one or zero trailing spaces', () => { + expect(isChordLine('Em ')).toBe(false); + expect(isChordLine('Em')).toBe(false); + }); +}); + +describe('parsedContent', () => { + it('treats content with no markers as a single unclassified part', () => { + const content = 'line1\nline2'; + expect(parsedContent(content, 0, true, false)).toEqual([ + { type: '', number: 0, class: '', content }, + ]); + }); + + it('parses a marker with a numeric suffix', () => { + expect(parsedContent('--V1\nHello world', 0, true, false)).toEqual([ + { type: 'v', number: '1', class: 'verse', content: 'Hello world' }, + ]); + }); + + it('defaults the number to \'0\' when a marker has no numeric suffix', () => { + expect(parsedContent('--C\nchorus line', 0, true, false)).toEqual([ + { type: 'c', number: '0', class: 'chorus', content: 'chorus line' }, + ]); + }); + + it('maps every recognized marker letter to its class', () => { + const content = '--I\nintro\n--V1\nverse one\n--P\nprechorus\n--C\nchorus\n--B\nbridge\n--M\nmitro\n--O\noutro'; + expect(parsedContent(content, 0, true, false)).toEqual([ + { type: 'i', number: '0', class: 'intro', content: 'intro' }, + { type: 'v', number: '1', class: 'verse', content: 'verse one' }, + { type: 'p', number: '0', class: 'prechorus', content: 'prechorus' }, + { type: 'c', number: '0', class: 'chorus', content: 'chorus' }, + { type: 'b', number: '0', class: 'bridge', content: 'bridge' }, + { type: 'm', number: '0', class: 'mitro', content: 'mitro' }, + { type: 'o', number: '0', class: 'outro', content: 'outro' }, + ]); + }); + + it('treats an unrecognized marker as an untyped part without desyncing later parts', () => { + const content = '--V1\nverse one\n--X\nmiddle\n--C\nchorus'; + expect(parsedContent(content, 0, true, false)).toEqual([ + { type: 'v', number: '1', class: 'verse', content: 'verse one' }, + { type: '', number: '0', class: '', content: 'middle' }, + { type: 'c', number: '0', class: 'chorus', content: 'chorus' }, + ]); + }); + + it('drops chord lines when showChords is false', () => { + const content = '--V1\nEm \nlyric line'; + expect(parsedContent(content, 0, false, false)).toEqual([ + { type: 'v', number: '1', class: 'verse', content: 'lyric line' }, + ]); + }); + + it('keeps chord lines unchanged when showChords is true and keyOffset is 0', () => { + const content = '--V1\nEm \nlyric line'; + expect(parsedContent(content, 0, true, false)).toEqual([ + { type: 'v', number: '1', class: 'verse', content: 'Em \nlyric line' }, + ]); + }); + + it('transposes a chord line up by the given key offset', () => { + const content = '--V1\nC '; + expect(parsedContent(content, 2, true, false)).toEqual([ + { type: 'v', number: '1', class: 'verse', content: 'D ' }, + ]); + }); + + it('wraps around the top of the key scale', () => { + const content = '--V1\nH '; + expect(parsedContent(content, 1, true, false)).toEqual([ + { type: 'v', number: '1', class: 'verse', content: 'C ' }, + ]); + }); + + it('wraps around the bottom of the key scale with a negative offset', () => { + const content = '--V1\nC '; + expect(parsedContent(content, -1, true, false)).toEqual([ + { type: 'v', number: '1', class: 'verse', content: 'H ' }, + ]); + }); + + it('shrinks a sharp chord to a natural while keeping exactly two trailing spaces', () => { + const content = '--V1\nC# '; + expect(parsedContent(content, 1, true, false)).toEqual([ + { type: 'v', number: '1', class: 'verse', content: 'D ' }, + ]); + }); + + it('splits parts into two columns', () => { + const content = '--V1\none\n--V2\ntwo\n--V3\nthree'; + expect(parsedContent(content, 0, true, true)).toEqual([ + [ + { type: 'v', number: '1', class: 'verse', content: 'one' }, + { type: 'v', number: '2', class: 'verse', content: 'two' }, + ], + [ + { type: 'v', number: '3', class: 'verse', content: 'three' }, + ], + ]); + }); +}); + +describe('humanDate', () => { + it('returns an empty string for null/undefined', () => { + expect(humanDate(null, 'en')).toBe(''); + expect(humanDate(undefined, 'en')).toBe(''); + }); + + it('formats with weekday by default', () => { + expect(humanDate('2026-03-05', 'en')).toBe('Thursday, March 5, 2026'); + }); + + it('formats without weekday when showWeekdate is false', () => { + expect(humanDate('2026-03-05', 'en', false)).toBe('March 5, 2026'); + }); + + it('formats short form without weekday', () => { + expect(humanDate('2026-03-05', 'en', false, true)).toBe('3/5/26'); + }); +}); + +describe('humanFileSize', () => { + it('uses a plain byte suffix below the threshold', () => { + expect(humanFileSize(500)).toBe('500 B'); + expect(humanFileSize(999, true)).toBe('999 B'); + }); + + it('uses binary units by default', () => { + expect(humanFileSize(1024)).toBe('1.0 KiB'); + }); + + it('uses SI units when si is true', () => { + expect(humanFileSize(1000, true)).toBe('1.0 kB'); + }); + + it('respects the decimal places argument', () => { + expect(humanFileSize(1536, false, 0)).toBe('2 KiB'); + expect(humanFileSize(1536, false, 2)).toBe('1.50 KiB'); + }); +}); + +describe('sdHighlight', () => { + it('wraps a marker line', () => { + expect(sdHighlight('--V1')).toBe('--V1'); + }); + + it('wraps a chord line', () => { + expect(sdHighlight('Em ')).toBe('Em '); + }); + + it('leaves a plain lyric line unwrapped', () => { + expect(sdHighlight('Amazing grace')).toBe('Amazing grace'); + }); + + it('handles multiple lines independently', () => { + expect(sdHighlight('--V1\nEm \nlyrics')).toBe( + '--V1\nEm \nlyrics' + ); + }); +}); + +describe('initials', () => { + it('takes the first letter of the first two words', () => { + expect(initials('Jane Doe')).toBe('JD'); + }); + + it('handles a single-word name', () => { + expect(initials('Cher')).toBe('C'); + }); + + it('returns an empty string when no name is given', () => { + expect(initials(undefined)).toBe(''); + }); +}); + +describe('urlify', () => { + it('replaces spaces, slashes and underscores with hyphens', () => { + expect(urlify('Foo Bar/Baz_Qux')).toBe('foo-bar-baz-qux'); + }); + + it('strips quotes and punctuation', () => { + expect(urlify(`It's "Great", Really; Truly.: Yes#`)).toBe('its-great-really-truly-yes'); + }); + + it('transliterates German umlauts, ß and superscripts', () => { + expect(urlify('Größe Straße m² m³')).toBe('groesse-strasse-m2-m3'); + }); + + it('trims and lowercases the input', () => { + expect(urlify(' Already-Fine ')).toBe('already-fine'); + }); +}); + +describe('parseNumberInput', () => { + it('treats an empty string as unset', () => { + expect(parseNumberInput('')).toBeUndefined(); + }); + + it('parses a numeric string', () => { + expect(parseNumberInput('42')).toBe(42); + }); +}); + +describe('sortTags', () => { + it('sorts by translated name, not raw key', () => { + // en: worship -> Worship, grace -> Grace, joy -> Joy + expect(sortTags(['worship', 'grace', 'joy'], 'en')).toEqual(['grace', 'joy', 'worship']); + }); + + it('sorts differently per locale', () => { + // de: worship -> Anbetung, grace -> Gnade, joy -> Freude + expect(sortTags(['worship', 'grace', 'joy'], 'de')).toEqual(['worship', 'joy', 'grace']); + }); + + it('falls back to the raw key for untranslated tags', () => { + expect(sortTags(['zzz-unknown', 'aaa-unknown'], 'en')).toEqual(['aaa-unknown', 'zzz-unknown']); + expect(sortTags(['worship', 'aaa-unknown'], 'en')).toEqual(['aaa-unknown', 'worship']); + }); +}); + +describe('firstParam', () => { + it('returns the first element of an array', () => { + expect(firstParam(['a', 'b'])).toBe('a'); + }); + + it('returns a plain string unchanged', () => { + expect(firstParam('a')).toBe('a'); + }); + + it('returns undefined unchanged', () => { + expect(firstParam(undefined)).toBeUndefined(); + }); +}); + +describe('openLyricsXML', () => { + const minimalSong: SongEntity = { + authors: [], + content: 'Hello', + createdBy: 'u', + language: 'en', + publisher: '', + slug: 's', + tags: [], + title: 'My Song', + translations: [], + }; + + it('omits optional elements that have no data', () => { + const xml = openLyricsXML(minimalSong, '1.0.0'); + expect(xml).toContain('My Song'); + expect(xml).not.toContain(''); + expect(xml).not.toContain(''); + expect(xml).not.toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain('Hello'); + }); + + it('includes optional elements when present, escaping the copyright text', () => { + const song: SongEntity = { + authors: ['Alice', 'Bob'], + ccli: 12345, + content: '--V1\nLine one', + createdBy: 'u', + language: 'en', + publisher: 'Pub & Co\nSecond line', + slug: 's2', + subtitle: 'A Subtitle', + tags: ['worship'], + title: 'Full Song', + translations: [], + year: 2020, + }; + const xml = openLyricsXML(song, '1.0.0'); + expect(xml).toContain('Full SongA Subtitle'); + expect(xml).toContain('2020'); + expect(xml).toContain('2020 Pub & Co; Second line'); + expect(xml).toContain('12345'); + expect(xml).toContain('AliceBob'); + expect(xml).toContain('Anbetung'); + expect(xml).toContain('Worship'); + expect(xml).toContain('Line one'); + }); + + it('includes translation markup and interleaved content when translatedSong is given', () => { + const song: SongEntity = { ...minimalSong, content: '--V1\nOriginal line' }; + const translatedSong: SongEntity = { ...minimalSong, content: '--V1\nTranslated line' }; + const xml = openLyricsXML(song, '1.0.0', translatedSong); + expect(xml).toContain(''); + expect(xml).toContain( + 'Original line

Translated line
' + ); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..dd618876 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import viteConfig from './vite.config.ts'; + +export default mergeConfig(viteConfig, defineConfig({ + test: { + environment: 'node', + }, +}));