diff --git a/package-lock.json b/package-lock.json index 521c3023..9df69df5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,6 @@ "@react-router/serve": "^7.3.0", "@tanstack/react-query": "^5.68.0", "@tanstack/react-virtual": "^3.13.4", - "@testing-library/user-event": "^14.6.1", "clsx": "^2.1.1", "electron-settings": "^4.0.4", "electron-squirrel-startup": "^1.0.1", @@ -36,11 +35,9 @@ "react-modal": "^3.16.3", "react-responsive": "^10.0.1", "react-router": "^7.3.0", - "run-applescript": "^7.0.0", "sha1": "^1.1.1", "unzipper": "^0.12.3", "use-debounce": "^10.0.4", - "vite-plugin-svgr": "^4.3.0", "zip-a-folder": "^3.1.9", "zustand": "^5.0.3" }, @@ -57,6 +54,7 @@ "@react-router/dev": "^7.3.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/electron-squirrel-startup": "^1.0.2", "@types/lodash": "^4.17.16", "@types/react": "^19.0.10", @@ -74,9 +72,11 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^5.2.0", "jsdom": "^26.0.0", + "prismock": "^1.35.4", "ts-node": "^10.9.2", "typescript": "^5.8.2", "vite": "^6.2.5", + "vite-plugin-svgr": "^4.5.0", "vitest": "^3.1.1", "vitest-mock-extended": "^3.0.1" } @@ -92,6 +92,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -126,6 +127,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -140,6 +142,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -149,6 +152,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", @@ -179,6 +183,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -188,6 +193,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.28.3", @@ -217,6 +223,7 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.2", @@ -233,6 +240,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -274,6 +282,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -297,6 +306,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -310,6 +320,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", @@ -382,6 +393,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -391,6 +403,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -400,6 +413,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -409,6 +423,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", @@ -422,6 +437,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -558,6 +574,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -567,6 +584,7 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -581,6 +599,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -599,6 +618,7 @@ "version": "7.28.2", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1672,6 +1692,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1688,6 +1709,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1704,6 +1726,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1720,6 +1743,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1736,6 +1760,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1752,6 +1777,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1768,6 +1794,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1784,6 +1811,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1800,6 +1828,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1816,6 +1845,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1832,6 +1862,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1848,6 +1879,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1864,6 +1896,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1880,6 +1913,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1896,6 +1930,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1912,6 +1947,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1928,6 +1964,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1944,6 +1981,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1960,6 +1998,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1976,6 +2015,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1992,6 +2032,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2008,6 +2049,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2024,6 +2066,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2040,6 +2083,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2056,6 +2100,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2072,6 +2117,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2278,6 +2324,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2288,6 +2335,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2297,12 +2345,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.30", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2351,6 +2401,19 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2600,6 +2663,16 @@ "node": ">=12" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2680,6 +2753,23 @@ "@prisma/get-platform": "6.15.0" } }, + "node_modules/@prisma/generator-helper": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-5.22.0.tgz", + "integrity": "sha512-LwqcBQ5/QsuAaLNQZAIVIAJDJBMjHwMwn16e06IYx/3Okj/xEEfw9IvrqB2cJCl3b2mCBlh3eVH0w9WGmi4aHg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@prisma/generator-helper/node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@prisma/get-platform": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.15.0.tgz", @@ -2689,6 +2779,107 @@ "@prisma/debug": "6.15.0" } }, + "node_modules/@prisma/internals": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/internals/-/internals-5.22.0.tgz", + "integrity": "sha512-Rsjw2ARB9VQzDczzEimUriSBdXmYG/Z5tNRer2IEwof/O8Q6A9cqV3oNVUpJ52TgWfQqMAq5K/KEf8LvvYLLOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines": "5.22.0", + "@prisma/fetch-engine": "5.22.0", + "@prisma/generator-helper": "5.22.0", + "@prisma/get-platform": "5.22.0", + "@prisma/prisma-schema-wasm": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/schema-files-loader": "5.22.0", + "arg": "5.0.2", + "prompts": "2.4.2" + } + }, + "node_modules/@prisma/internals/node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/internals/node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/internals/node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/internals/node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/internals/node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@prisma/prisma-schema-wasm": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/prisma-schema-wasm/-/prisma-schema-wasm-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-WPNB7SgTxF/rSHMa5o5/9AIINy4oVnRhvUkRzqR4Nfp8Hu9Q2IyUptxuiDuzRVJdjJBRi/U82sHTxyiD3oBBhQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/schema-files-loader": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/schema-files-loader/-/schema-files-loader-5.22.0.tgz", + "integrity": "sha512-/TNAJXvMSk6mCgZa+gIBM6sp5OUQBnb7rbjiSQm88gvcSibxEuKkVV/2pT3RmQpEAn1yiabvS4+dOvIotYe3ww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/prisma-schema-wasm": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "fs-extra": "11.1.1" + } + }, + "node_modules/@prisma/schema-files-loader/node_modules/fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@react-router/dev": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.8.2.tgz", @@ -2828,6 +3019,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -2850,12 +3042,14 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, "license": "MIT" }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2871,6 +3065,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2884,6 +3079,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2897,6 +3093,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2910,6 +3107,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2923,6 +3121,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2936,6 +3135,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2949,6 +3149,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2962,6 +3163,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2975,6 +3177,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2988,6 +3191,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3001,6 +3205,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3014,6 +3219,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3027,6 +3233,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3040,6 +3247,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3053,6 +3261,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3066,6 +3275,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3079,6 +3289,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3092,6 +3303,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3105,6 +3317,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3118,6 +3331,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3165,6 +3379,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -3181,6 +3396,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -3197,6 +3413,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -3213,6 +3430,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -3229,6 +3447,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -3245,6 +3464,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -3261,6 +3481,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -3277,6 +3498,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3293,6 +3515,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, "license": "MIT", "dependencies": { "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", @@ -3319,6 +3542,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.21.3", @@ -3339,6 +3563,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.21.3", @@ -3356,6 +3581,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -3368,6 +3594,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.21.3", @@ -3455,6 +3682,7 @@ "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -3530,6 +3758,7 @@ "version": "14.6.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, "license": "MIT", "engines": { "node": ">=12", @@ -3616,6 +3845,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, "license": "MIT", "peer": true }, @@ -3704,6 +3934,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/fs-extra": { @@ -4969,12 +5200,14 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "dequal": "^2.0.3" @@ -5350,6 +5583,7 @@ "version": "4.25.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -5378,6 +5612,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -5634,6 +5878,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5643,6 +5888,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5655,6 +5901,7 @@ "version": "1.0.30001737", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6125,6 +6372,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, "node_modules/cookie": { @@ -6152,6 +6400,7 @@ "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, "license": "MIT", "dependencies": { "import-fresh": "^3.3.0", @@ -6178,12 +6427,14 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, "license": "MIT" }, "node_modules/cosmiconfig/node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -6202,6 +6453,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7023,6 +7275,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7099,6 +7352,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, "license": "MIT", "peer": true }, @@ -7106,6 +7360,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, "license": "MIT", "dependencies": { "no-case": "^3.0.4", @@ -7667,6 +7922,7 @@ "version": "1.5.211", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.211.tgz", "integrity": "sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==", + "dev": true, "license": "ISC" }, "node_modules/electron-winstaller": { @@ -7883,6 +8139,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -8052,6 +8309,7 @@ "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -8093,6 +8351,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9119,6 +9378,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -9197,6 +9457,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -9821,6 +10082,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -9971,6 +10233,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -10586,6 +10849,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10638,6 +10902,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -10687,6 +10952,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -10726,6 +10992,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -10792,6 +11068,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, "node_modules/listr2": { @@ -10954,6 +11231,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.3" @@ -10972,6 +11250,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -10981,6 +11260,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, "license": "MIT", "peer": true, "bin": { @@ -11520,6 +11800,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -11577,6 +11858,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, "license": "MIT", "dependencies": { "lower-case": "^2.0.2", @@ -11668,6 +11950,7 @@ "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, "license": "MIT" }, "node_modules/nopt": { @@ -12194,6 +12477,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -12465,6 +12749,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -12545,6 +12830,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -12560,6 +12846,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "license": "MIT", "peer": true, "engines": { @@ -12594,6 +12881,23 @@ } } }, + "node_modules/prismock": { + "version": "1.35.4", + "resolved": "https://registry.npmjs.org/prismock/-/prismock-1.35.4.tgz", + "integrity": "sha512-BRqBQF2C5XKyYw/LTl3IBEOrheZdwUx2Cgu27Mtrk47lqPp9gpA05DOaCo/FKglHGi13aNHqfnTpugW1Eci79w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "2.2.2", + "@prisma/generator-helper": "5.22.0", + "@prisma/internals": "5.22.0", + "bson": "6.10.4" + }, + "peerDependencies": { + "@prisma/client": "*", + "prisma": "*" + } + }, "node_modules/proc-log": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz", @@ -12649,6 +12953,20 @@ "node": ">=10" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -12842,6 +13160,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, "license": "MIT", "peer": true }, @@ -13262,6 +13581,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -13378,6 +13698,7 @@ "version": "4.49.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz", "integrity": "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -13420,18 +13741,6 @@ "dev": true, "license": "MIT" }, - "node_modules/run-applescript": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13859,6 +14168,13 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -13916,6 +14232,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, "license": "MIT", "dependencies": { "dot-case": "^3.0.4", @@ -13978,6 +14295,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -14431,6 +14749,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true, "license": "MIT" }, "node_modules/symbol-tree": { @@ -14643,6 +14962,7 @@ "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.4.4", @@ -14659,6 +14979,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -14676,6 +14997,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -15280,6 +15602,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -15413,6 +15736,7 @@ "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -15517,6 +15841,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.5.0.tgz", "integrity": "sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==", + "dev": true, "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.2.0", @@ -15531,6 +15856,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -15548,6 +15874,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -16084,6 +16411,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, "license": "ISC" }, "node_modules/yargs": { diff --git a/package.json b/package.json index cfce96a8..2c4e43e1 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@react-router/dev": "^7.3.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/electron-squirrel-startup": "^1.0.2", "@types/lodash": "^4.17.16", "@types/react": "^19.0.10", @@ -60,9 +61,11 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^5.2.0", "jsdom": "^26.0.0", + "prismock": "^1.35.4", "ts-node": "^10.9.2", "typescript": "^5.8.2", "vite": "^6.2.5", + "vite-plugin-svgr": "^4.5.0", "vitest": "^3.1.1", "vitest-mock-extended": "^3.0.1" }, @@ -77,7 +80,6 @@ "@react-router/serve": "^7.3.0", "@tanstack/react-query": "^5.68.0", "@tanstack/react-virtual": "^3.13.4", - "@testing-library/user-event": "^14.6.1", "clsx": "^2.1.1", "electron-settings": "^4.0.4", "electron-squirrel-startup": "^1.0.1", @@ -93,11 +95,9 @@ "react-modal": "^3.16.3", "react-responsive": "^10.0.1", "react-router": "^7.3.0", - "run-applescript": "^7.0.0", "sha1": "^1.1.1", "unzipper": "^0.12.3", "use-debounce": "^10.0.4", - "vite-plugin-svgr": "^4.3.0", "zip-a-folder": "^3.1.9", "zustand": "^5.0.3" } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index fd81f57c..f81d92a7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,5 @@ -import { capitalize, deburr, uniqBy } from 'lodash'; -import type { MouseEvent } from 'react'; +import { capitalize, deburr, uniqBy } from "lodash"; +import type { MouseEvent } from "react"; import type { ReleaseType, Release, @@ -14,18 +14,27 @@ import type { ArtistWithReleases, GroupWithArtists, WithAdditionalArtists, - } from "@/types/types"; -import { getCover } from './links'; - -const releaseTypes: ReleaseType[] = - ['Album', 'Compilation', 'EP', 'Single', 'Bootleg', 'Various', 'Tribute', 'Soundtrack']; - -export const VARIOUS_ARTISTS_FOLDER = '[V:A]'; -export const VARIOUS_ARTISTS_NAME = '_VV_AA_'; - -export function getReleaseTitle({ title, subReleases = [] }: - Pick): string { +import { getCover } from "./links"; + +const releaseTypes: ReleaseType[] = [ + "Album", + "Compilation", + "EP", + "Single", + "Bootleg", + "Various", + "Tribute", + "Soundtrack", +]; + +export const VARIOUS_ARTISTS_FOLDER = "[V:A]"; +export const VARIOUS_ARTISTS_NAME = "_VV_AA_"; + +export function getReleaseTitle({ + title, + subReleases = [], +}: Pick): string { if (!subReleases.length) { return title; } @@ -33,14 +42,23 @@ export function getReleaseTitle({ title, subReleases = [] }: return match ? match[1] : title; } -export function getReleaseArtist( - { artist, additionalArtists }: Pick -) { - return [artist, ...additionalArtists].map(x => normalizeArtistName(x.name)).join(', '); +export function getReleaseArtist({ + artist, + additionalArtists, +}: Pick< + ReleaseWithArtist & WithAdditionalArtists, + "artist" | "additionalArtists" +>) { + return [artist, ...additionalArtists] + .map((x) => normalizeArtistName(x.name)) + .join(", "); } export function getUniqueArtists(releases: ReleaseWithArtist[]): Artist[] { - return uniqBy(releases.map(x => x.artist), (x => x.id)); + return uniqBy( + releases.map((x) => x.artist), + (x) => x.id + ); } export function getMainReleaseTitle(release: ReleaseWithArtist) { @@ -52,12 +70,15 @@ export function getMainReleaseTitle(release: ReleaseWithArtist) { } export function countReleasesByType(releases: Release[]): ReleaseCountByType { - return releases.reduce((memo, { type }) => ({ ...memo, [type]: memo[type] ? memo[type] + 1 : 1 }), {} as ReleaseCountByType); + return releases.reduce( + (memo, { type }) => ({ ...memo, [type]: memo[type] ? memo[type] + 1 : 1 }), + {} as ReleaseCountByType + ); } export function estimateListCardSize() { return { - width: '100%', + width: "100%", height: 6 * 16, }; } @@ -66,7 +87,7 @@ export function normalizeTitle(title: string) { return title .replace(/ CD(\d+)/, "") .replaceAll(/\(\w: (.*)\)/g, "") - .replaceAll(' : ', ' / ') + .replaceAll(" : ", " / ") .trim(); } @@ -78,25 +99,29 @@ export function normalizeArtistName(name: string) { } export function normalizeArtistDisplayName(name: string) { - return name === VARIOUS_ARTISTS_NAME ? 'Various Artists' : name; + return name === VARIOUS_ARTISTS_NAME ? "Various Artists" : name; } export function getDiscInfo({ subReleases }: ReleaseWithArtistAndSubreleases) { - return subReleases.length - ? `(${subReleases.length + 1} discs)` - : null; + return subReleases.length ? `(${subReleases.length + 1} discs)` : null; } -export function getReleaseDuration(release: ReleaseWithArtistAndTracksAndSubreleases) { +export function getReleaseDuration( + release: ReleaseWithArtistAndTracksAndSubreleases +) { const allTracks = [ ...release.tracks, - ...(release.subReleases.length ? release.subReleases.flatMap(x => x.tracks) : []) + ...(release.subReleases.length + ? release.subReleases.flatMap((x) => x.tracks) + : []), ]; return { trackCount: allTracks.length, - duration: formatDuration(allTracks.reduce((memo, { duration }) => memo += duration, 0)) - } + duration: formatDuration( + allTracks.reduce((memo, { duration }) => (memo += duration), 0) + ), + }; } export function formatDuration(duration: number) { @@ -112,15 +137,23 @@ export function formatDuration(duration: number) { return formatted; } - export function isEmpty(obj: object) { return Object.keys(obj).length === 0; } -export function sortReleasesByTypeAndYear(releases: ReleaseWithArtist[]) { - return releaseTypes - .flatMap(type => releases - .filter(x => x.type === type) +type Sortable = number | string | boolean | Date; + +export function sortBy(key: string, order: "asc" | "desc" = "asc") { + return (a: Record, b: Record) => + (a[key] > b[key] ? 1 : -1) * (order === "asc" ? 1 : -1); +} + +export function sortReleasesByTypeAndYear( + releases: Pick[] +) { + return releaseTypes.flatMap((type) => + releases + .filter((x) => x.type === type) .sort((a, b) => { if (!a.year || !b.year) { return 0; @@ -128,21 +161,28 @@ export function sortReleasesByTypeAndYear(releases: ReleaseWithArtist[]) { if (a.year === b.year) { return a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1; } - return Math.sign(a.year - b.year) + return Math.sign(a.year - b.year); }) - ) + ); } -export function getReleaseWithTracklistHeight(release: ReleaseWithArtistAndTracksAndSubreleases): number { - const maxTracks = Math.max(...[ - release, - ...release.subReleases - ].map(x => x.tracks?.length)); +export function getReleaseWithTracklistHeight( + release: ReleaseWithArtistAndTracksAndSubreleases +): number { + const maxTracks = Math.max( + ...[release, ...release.subReleases].map((x) => x.tracks?.length) + ); // cover height + margin + gap + tracks - return 128 + 16 + 4 + (release.subReleases.length ? 32 : 0) + (maxTracks * (40 + 4)); + return ( + 128 + 16 + 4 + (release.subReleases.length ? 32 : 0) + maxTracks * (40 + 4) + ); } -export async function mapSeries(array: T[], callback: (item: T, index: number) => Promise, interval = 0): Promise { +export async function mapSeries( + array: T[], + callback: (item: T, index: number) => Promise, + interval = 0 +): Promise { if (!array.length) { return []; } @@ -162,16 +202,19 @@ export async function mapSeries(array: T[], callback: (item: T, index: num }); } -export function sortByQueryPosition(query: string, key: keyof T, a: T, b: T) { +export function sortByQueryPosition< + K extends string, + T extends { + [Property in K]: string; + }, +>(query: string, key: keyof T, a: T, b: T) { const posA = a[key].toLowerCase().indexOf(query.toLowerCase()); const posB = b[key].toLowerCase().indexOf(query.toLowerCase()); return Math.sign(posA - posB); } export function wait(ms = 100) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } export function getReleaseContextMenuParams({ @@ -202,7 +245,7 @@ export function refreshCovers(releases: Release[]) { const seed = `${Math.random() * 100000}`.slice(0, 5); element.src = `${getCover(hash)}?_=${seed}`; }); - }) + }); } export function withStopPropagation(handler: (event: MouseEvent) => void) { @@ -212,13 +255,19 @@ export function withStopPropagation(handler: (event: MouseEvent) => void) { }; } -type Item = CollectionWithReleases | ArtistWithReleases | ReleaseWithArtistAndSubreleases | GroupWithArtists; +type Item = + | CollectionWithReleases + | ArtistWithReleases + | ReleaseWithArtistAndSubreleases + | GroupWithArtists; -export function getCoverRelease(item: Item): ReleaseWithArtistAndSubreleases | null { +export function getCoverRelease( + item: Item +): ReleaseWithArtistAndSubreleases | null { if (item._type === "release") { return item; } - if (item._type === 'group') { + if (item._type === "group") { const coverArtist = item.coverArtist || item.artists[0]; return coverArtist ? getCoverRelease(coverArtist) : null; } @@ -231,24 +280,41 @@ type NewReleaseInfo = { newTitle: string; newType: ReleaseType; newYear: number; -} - -type EditReleaseParam = ( - Pick - & NewReleaseInfo -); +}; -export function didReleaseInfoChange(infos: EditReleaseParam[], excludeDiscTitle?: boolean) { - return infos.some( - (x: EditReleaseParam) => ['path', 'title', 'type', 'year', 'discTitle'].slice(0, excludeDiscTitle ? -1 : undefined) - .some(key => x[key as keyof EditReleaseParam] !== x[`new${capitalize(key)}` as keyof NewReleaseInfo]) +type EditReleaseParam = Pick< + Release, + | "id" + | "path" + | "hash" + | "title" + | "artist_id" + | "year" + | "type" + | "discTitle" + | "discNumber" +> & + NewReleaseInfo; + +export function didReleaseInfoChange( + infos: EditReleaseParam[], + excludeDiscTitle?: boolean +) { + return infos.some((x: EditReleaseParam) => + ["path", "title", "type", "year", "discTitle"] + .slice(0, excludeDiscTitle ? -1 : undefined) + .some( + (key) => + x[key as keyof EditReleaseParam] !== + x[`new${capitalize(key)}` as keyof NewReleaseInfo] + ) ); } export function withCoverRelease(artist: ArtistWithReleases) { return { ...artist, - coverRelease: artist.coverRelease || artist.releases[0] + coverRelease: artist.coverRelease || artist.releases[0], }; } @@ -261,7 +327,7 @@ export function normalizeDiacritics(input: string) { input .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") - .replace(/\((\d+)\)$/, '') + .replace(/\((\d+)\)$/, "") .trim() ); -} \ No newline at end of file +} diff --git a/src/main/__mocks__/settings.ts b/src/main/__mocks__/settings.ts new file mode 100644 index 00000000..f1c8c700 --- /dev/null +++ b/src/main/__mocks__/settings.ts @@ -0,0 +1,8 @@ +import { beforeEach } from "vitest"; +import { mockReset } from "vitest-mock-extended"; + +beforeEach(() => { + mockReset(initSettings); +}); + +export const initSettings = vi.fn(); diff --git a/src/main/controllers/artist.test.ts b/src/main/controllers/artist.test.ts index 7a2cf9c7..4414ecbf 100644 --- a/src/main/controllers/artist.test.ts +++ b/src/main/controllers/artist.test.ts @@ -1,38 +1,100 @@ -import { getFakeArtist } from "@/test/utils"; -import { dialog } from 'electron'; -import path from 'path'; -import fsExtra, { existsSync } from 'fs-extra'; +import prisma from "../db/prisma"; +import { dialog } from "electron"; +import path from "path"; +import fsExtra, { existsSync } from "fs-extra"; import { artistController } from "./artist"; -import prisma from '../db/__mocks__/prisma'; import { mockFs } from "@/test/mock-fs"; import { StateManager } from "../state"; +import { clearPrisma } from "@/test/prisma-utils"; +import { + getFakeArtist, + getFakeArtists, + getFakeReleasesForArtist, +} from "@/test/seed"; +import { withPath } from "@/test/utils"; -vi.mock('../db/prisma'); +afterEach(clearPrisma); -describe('artist - editArtist function', () => { - it('shows a warning if the new path already exists', async (context) => { - const directory = await mockFs({ '/LIBRARY_PATH/A/Artist New': {} }, context.task.id); +const defaultParams = { + withPath, + state: {} as StateManager, +}; + +describe("artist - getArtist function", () => { + it("returns null if no artist is found", async () => { + const { getArtist } = artistController(defaultParams); + const result = await getArtist(1); + expect(result).toBe(null); + }); + + it("returns the artist if found", async () => { + const artist = getFakeArtist(1); + await prisma.artist.create({ data: artist }); + const { getArtist } = artistController(defaultParams); + const result = await getArtist(1); + expect(result).toMatchObject(artist); + }); +}); + +describe("artist - getAllArtists function", () => { + it("returns all the artists", async () => { + const artists = getFakeArtists({ length: 5 }); + await prisma.artist.createMany({ data: artists }); + const { getAllArtists } = artistController(defaultParams); + const result = await getAllArtists(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + expect(result).toMatchObject(artists.map(({ createdAt, ...x }) => x)); + }); +}); + +describe("artist - getLatestArtists function", () => { + it("returns the latest added artists in chronological reversed order", async () => { + const artists = getFakeArtists({ length: 5 }); + await prisma.artist.createMany({ data: artists }); + const { getLatestArtists } = artistController(defaultParams); + const result = await getLatestArtists({ + take: 50, + skip: 0, + }); + expect(result).toMatchObject({ + pagination: { + take: 50, + skip: 0, + total: artists.length, + }, + results: artists.toReversed(), + }); + }); +}); + +describe("artist - editArtist function", () => { + it("shows a warning if the new path already exists", async (context) => { + const directory = await mockFs( + { "/LIBRARY_PATH/A/Artist New": {} }, + context.task.id + ); const state = { setCurrentArtist: vi.fn() } as unknown as StateManager; const { editArtist } = artistController({ withPath: (key, folderPath) => path.join(directory, key, folderPath), - state + state, }); - const moveSpy = vi.spyOn(fsExtra, 'move'); - const dialogSpy = vi.spyOn(dialog, 'showMessageBoxSync'); + const moveSpy = vi.spyOn(fsExtra, "move"); + const dialogSpy = vi.spyOn(dialog, "showMessageBoxSync"); + const artist = getFakeArtist(); + await prisma.artist.create({ data: artist }); const result = await editArtist({ - ...getFakeArtist(1), - newName: 'Artist New', - newPath: 'A/Artist New', + ...artist, + newName: "Artist New", + newPath: "A/Artist New", }); - expect(dialogSpy).toHaveBeenCalledWith( - null, { - message: 'Error while renaming', + expect(dialogSpy).toHaveBeenCalledWith(null, { + message: "Error while renaming", detail: `Path A/Artist New already exists`, - type: 'error', - buttons: ['OK'], + type: "error", + buttons: ["OK"], }); expect(result).toBe(false); @@ -40,31 +102,148 @@ describe('artist - editArtist function', () => { expect(state.setCurrentArtist).not.toHaveBeenCalled(); }); - it('updates the artist with the given information', async (context) => { - const artist = getFakeArtist(1); - prisma.artist.update.mockImplementation(({ data }) => data); - const directory = await mockFs({ '/LIBRARY_PATH/A/Artist': {} }, context.task.id); + it("updates the artist with the given information", async (context) => { + const artist = getFakeArtist(); + const directory = await mockFs( + { "/LIBRARY_PATH/A/Artist 1": {} }, + context.task.id + ); const state = { setCurrentArtist: vi.fn() } as unknown as StateManager; const { editArtist } = artistController({ withPath: (key, folderPath) => path.join(directory, key, folderPath), - state + state, }); + await prisma.artist.create({ data: artist }); + const result = await editArtist({ ...artist, - newName: 'Artist New', - newPath: 'A/Artist New', + newName: "Artist New", + newPath: "A/Artist New", }); expect(result).toBeTruthy(); - expect(existsSync( - path.join(directory, 'LIBRARY_PATH/A/Artist')) - ).toBe(false); - expect(existsSync( - path.join(directory, 'LIBRARY_PATH/A/Artist New')) - ).toBe(true); + expect(existsSync(path.join(directory, "LIBRARY_PATH/A/Artist"))).toBe( + false + ); + expect(existsSync(path.join(directory, "LIBRARY_PATH/A/Artist New"))).toBe( + true + ); expect(state.setCurrentArtist).toHaveBeenCalled(); }); -}); \ No newline at end of file +}); + +describe("artist - setArtistCoverRelease function", () => { + it("sets the artist cover release", async () => { + const artist = getFakeArtist(); + const release = getFakeReleasesForArtist(artist.id).at(0); + await prisma.artist.create({ data: artist }); + await prisma.release.create({ data: release }); + + const { setArtistCoverRelease } = artistController(defaultParams); + + await setArtistCoverRelease(artist.id, release.id); + const updatedArtist = await prisma.artist.findFirst({ + where: { id: artist.id }, + }); + + expect(updatedArtist.coverReleaseId).toBe(release.id); + }); +}); + +describe("artist - addRelatedArtist function", () => { + it("adds a related artist", async () => { + const artists = getFakeArtists({ length: 2 }); + await prisma.artist.createMany({ data: artists }); + + const { addRelatedArtist } = artistController(defaultParams); + + await addRelatedArtist(artists[1].id, artists[0].id); + + const updatedArtist = await prisma.artist.findFirst({ + where: { id: artists[0].id }, + include: { relatedArtists: true, symmetricRelatedArtists: true }, + }); + + const addedArtist = await prisma.artist.findFirst({ + where: { id: artists[1].id }, + include: { relatedArtists: true, symmetricRelatedArtists: true }, + }); + + expect(updatedArtist.relatedArtists).toMatchObject([artists[1]]); + expect(addedArtist.relatedArtists).toMatchObject([artists[0]]); + }); +}); + +describe("artist - removeRelatedArtist function", () => { + it("removes a related artist", async () => { + const artists = getFakeArtists({ length: 3 }); + await prisma.artist.createMany({ data: artists }); + + const { addRelatedArtist, removeRelatedArtist } = + artistController(defaultParams); + + await addRelatedArtist(artists[1].id, artists[0].id); + await addRelatedArtist(artists[2].id, artists[0].id); + await removeRelatedArtist(artists[1].id, artists[0].id); + + const updatedArtist = await prisma.artist.findFirst({ + where: { id: artists[0].id }, + include: { relatedArtists: true, symmetricRelatedArtists: true }, + }); + + const removedArtist = await prisma.artist.findFirst({ + where: { id: artists[1].id }, + include: { relatedArtists: true, symmetricRelatedArtists: true }, + }); + + expect(updatedArtist.relatedArtists.map((x) => x.id)).toMatchObject([3]); + expect(removedArtist.relatedArtists).toMatchObject([]); + }); +}); + +describe("artist - searchArtists function", () => { + it("returns the artists that match the passed appearsIn exclude query", async () => { + const artists = getFakeArtists({ length: 9 }); + await prisma.artist.createMany({ data: artists }); + await prisma.release.createMany({ data: getFakeReleasesForArtist(1) }); + + await prisma.release.update({ + where: { id: 1 }, + data: { + additionalArtists: { + connect: { id: 1 }, + }, + }, + }); + + const { searchArtists } = artistController(defaultParams); + + const results = await searchArtists({ + query: "Art", + take: 50, + exclude: { key: "appearsIn", artist_id: 2, release_id: 1 }, + }); + + expect(results).toMatchObject(artists.slice(2)); + }); + + it("returns the artists that match the passed relatedArtists exclude query", async () => { + const artists = getFakeArtists({ length: 9 }); + await prisma.artist.createMany({ data: artists }); + + const { searchArtists, addRelatedArtist } = artistController(defaultParams); + + await addRelatedArtist(1, 2); + + const results = await searchArtists({ + query: "Art", + take: 50, + exclude: { key: "relatedArtists", artist_id: 1 }, + }); + + expect(results).toMatchObject(artists.slice(1)); + }); +}); diff --git a/src/main/controllers/artist.ts b/src/main/controllers/artist.ts index a98a204a..888c5c5c 100644 --- a/src/main/controllers/artist.ts +++ b/src/main/controllers/artist.ts @@ -1,5 +1,5 @@ -import { existsSync, move } from 'fs-extra'; -import { dialog } from 'electron'; +import { existsSync, move } from "fs-extra"; +import { dialog } from "electron"; import { Artist } from "@/types/types"; import { getArtist, @@ -9,44 +9,47 @@ import { setArtistCoverRelease, searchArtists, addRelatedArtist, - removeRelatedArtist -} from '../db/artist'; + removeRelatedArtist, +} from "../db/artist"; import { StateManager } from "../state"; type ArtistControllerParams = { withPath: (key: string, folderPath: string) => string; state: StateManager; -} +}; -type EditArtistParams = Artist & { +type EditArtistParams = Pick & { newPath: string; newName: string; }; export function artistController({ withPath, state }: ArtistControllerParams) { - async function editArtist(infos: EditArtistParams) { const shouldMoveArtist = infos.newPath !== infos.path; - if (shouldMoveArtist && existsSync(withPath('LIBRARY_PATH', infos.newPath))) { + + if ( + shouldMoveArtist && + existsSync(withPath("LIBRARY_PATH", infos.newPath)) + ) { dialog.showMessageBoxSync(null, { - message: 'Error while renaming', + message: "Error while renaming", detail: `Path ${infos.newPath} already exists`, - type: 'error', - buttons: ['OK'], + type: "error", + buttons: ["OK"], }); return false; } if (shouldMoveArtist) { await move( - withPath('LIBRARY_PATH', infos.path), - withPath('LIBRARY_PATH', infos.newPath), + withPath("LIBRARY_PATH", infos.path), + withPath("LIBRARY_PATH", infos.newPath) ); } const updatedArtist = await updateArtist(infos.id, { name: infos.newName, - path: infos.newPath + path: infos.newPath, }); state.setCurrentArtist(updatedArtist); @@ -63,18 +66,18 @@ export function artistController({ withPath, state }: ArtistControllerParams) { setArtistCoverRelease, searchArtists, addRelatedArtist, - removeRelatedArtist + removeRelatedArtist, }; } export const actions = [ - 'getArtist', - 'getAllArtists', - 'getLatestArtists', - 'updateArtist', - 'editArtist', - 'setArtistCoverRelease', - 'searchArtists', - 'addRelatedArtist', - 'removeRelatedArtist' -]; \ No newline at end of file + "getArtist", + "getAllArtists", + "getLatestArtists", + "updateArtist", + "editArtist", + "setArtistCoverRelease", + "searchArtists", + "addRelatedArtist", + "removeRelatedArtist", +]; diff --git a/src/main/controllers/collection.test.ts b/src/main/controllers/collection.test.ts index b1e4a996..1dec996d 100644 --- a/src/main/controllers/collection.test.ts +++ b/src/main/controllers/collection.test.ts @@ -1,68 +1,277 @@ +import prisma from "../db/prisma"; +import { clearPrisma } from "../../test/prisma-utils"; +import { dialog } from "electron"; import { collectionController } from "./collection"; -import { getFakeCollection, getFakeRelease } from "@/test/utils"; -import prisma from '../db/__mocks__/prisma'; -import { HasId, ReleaseWithArtistAndTracksAndSubreleases } from "@/types/types"; +import { sortBy } from "@/lib/utils"; +import { + getFakeCollection, + getFakeCollections, + getFakeArtists, + getFakeReleasesForArtist, + getFakeArtist, +} from "../../test/seed"; -vi.mock('../db/prisma'); +afterEach(clearPrisma); -const collections = Array.from({ length: 55 }, (_, i) => getFakeCollection(i + 1)); +describe("getAllCollections function", () => { + it("returns all the collections", async () => { + const collections = getFakeCollections({ length: 2 }); + await prisma.collection.createMany({ data: collections }); + const { getAllCollections } = collectionController(); + const result = await getAllCollections(); + expect(result).toMatchObject(collections); + }); +}); + +describe("getCollection function", () => { + it("returns the collection by given id", async () => { + const collection = getFakeCollection(); + const releases = getFakeReleasesForArtist(1); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.createMany({ data: releases }); + await prisma.collection.create({ + data: { + ...collection, + releases: { connect: releases.map((x) => ({ id: x.id })) }, + }, + }); + + const { getCollection } = collectionController(); + { + const result = await getCollection(1); + expect(result.releases).toMatchObject(releases); + } + { + const result = await getCollection(1, { + sortBy: "title", + order: "asc", + }); + expect(result.releases).toMatchObject(releases); + } + { + const result = await getCollection(1, { + sortBy: "title", + order: "desc", + }); + expect(result.releases).toMatchObject( + releases.toSorted(sortBy("title", "desc")) + ); + } + }); +}); + +describe("getCollections function", () => { + const collections = getFakeCollections({ length: 100 }); -describe('getCollections function', () => { - it('returns as many collections as per the take parameter', async () => { - prisma.collection.findMany.mockImplementation(({ take }) => collections.slice(0, take)); + it("returns as many collections as per the take parameter", async () => { + await prisma.collection.createMany({ data: collections }); const { getCollections } = collectionController(); const result = await getCollections({ take: 5 }); expect(result.length).toBe(5); }); - it('returns max 50 collections if no take parameter is specified', async () => { - prisma.collection.findMany.mockImplementation(({ take }) => collections.slice(0, take)); + it("returns max 50 collections if no take parameter is specified", async () => { + await prisma.collection.createMany({ data: collections }); const { getCollections } = collectionController(); const result = await getCollections({}); expect(result.length).toBe(50); }); -}) - -describe('removeReleasesFromCollection function', () => { - const collection = getFakeCollection(1, { - coverReleaseId: 1, - releases: [ - getFakeRelease(1), - getFakeRelease(2), - getFakeRelease(3), - ] as ReleaseWithArtistAndTracksAndSubreleases[] +}); + +describe("setCollectionCoverRelease function", () => { + it("set the passed release as cover for the collection", async () => { + const artist = getFakeArtists({ length: 1 }).at(0); + const releases = getFakeReleasesForArtist(artist.id); + const collection = getFakeCollections({ length: 1 }).at(0); + + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + await prisma.collection.create({ + data: { + ...collection, + releases: { + connect: releases.map((x) => ({ id: x.id })), + }, + }, + }); + const { setCollectionCoverRelease } = collectionController(); + await setCollectionCoverRelease(1, 2); + const updatedCollection = await prisma.collection.findFirst({ + where: { id: 1 }, + }); + expect(updatedCollection.coverReleaseId).toBe(2); + }); +}); + +describe("addReleasesToCollection function", () => { + it("adds the releases by given ids to the collection", async () => { + const artist = getFakeArtists({ length: 1 }).at(0); + const releases = getFakeReleasesForArtist(artist.id); + const collection = getFakeCollections({ length: 1 }).at(0); + + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + await prisma.collection.create({ data: collection }); + + const { addReleasesToCollection } = collectionController(); + await addReleasesToCollection(1, releases); + const updatedCollection = await prisma.collection.findFirst({ + where: { id: 1 }, + include: { releases: true }, + }); + + expect(updatedCollection.releases).toMatchObject([ + { id: 1 }, + { id: 2 }, + { id: 3 }, + { id: 4 }, + { id: 5 }, + ]); + }); +}); + +describe("createCollection function", () => { + it("creates a new collection", async () => { + const artist = getFakeArtists({ length: 1 }).at(0); + const releases = getFakeReleasesForArtist(artist.id); + + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + + const { createCollection } = collectionController(); + const newCollection = await createCollection({ + title: "new collection", + releases: releases.map(({ id }) => id), + }); + + expect(newCollection.title).toBe("new collection"); + + const result = await prisma.collection.findFirst({ + where: { id: 1 }, + include: { releases: true }, + }); + + expect(result.releases).toMatchObject(releases); + }); +}); + +describe("deleteCollection function", () => { + it("deletes a collection by the given id", async () => { + const collection = getFakeCollections({ length: 1 }).at(0); + await prisma.collection.create({ data: collection }); + + const { deleteCollection } = collectionController(); + await deleteCollection(1); + const result = await prisma.collection.findFirst({ where: { id: 1 } }); + + expect(result).toBe(null); + }); +}); + +describe("deleteCollections function", () => { + it("deletes all collections by the given ids", async () => { + const collections = getFakeCollections({ length: 3 }); + await prisma.collection.createMany({ data: collections }); + + const { deleteCollections } = collectionController(); + await deleteCollections([2, 3]); + const result = await prisma.collection.findMany(); + + expect(result).toMatchObject(collections.slice(0, 1)); + }); +}); + +describe("updateCollection function", () => { + it("updates the collection", async () => { + const artist = getFakeArtists({ length: 1 }).at(0); + const releases = getFakeReleasesForArtist(artist.id); + const collection = getFakeCollections({ length: 1 }).at(0); + + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + await prisma.collection.create({ data: collection }); + + const { updateCollection } = collectionController(); + + const result = await updateCollection(1, { + title: "new title", + releases: releases.map(({ id }) => id), + }); + + expect(result).toMatchObject({ + title: "new title", + releases: releases.map(({ id }) => ({ id })), + }); + }); +}); + +describe("removeReleasesFromCollection function", () => { + const artist = getFakeArtists({ length: 1 }).at(0); + const releases = getFakeReleasesForArtist(artist.id); + const collection = getFakeCollections({ length: 1 }).at(0); + + it("does nothing if the cancel button is pressed", async () => { + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + await prisma.collection.create({ + data: { + ...collection, + releases: { + connect: releases.map((x) => ({ id: x.id })), + }, + }, + }); + + const dialogSpy = vi.spyOn(dialog, "showMessageBoxSync"); + dialogSpy.mockReturnValueOnce(1); + + const { removeReleasesFromCollection } = collectionController(); + await removeReleasesFromCollection(1, [2]); + + const result = await prisma.collection.findFirst({ + where: { id: 1 }, + include: { releases: true }, + }); + + expect(result.releases.length).toBe(5); + + dialogSpy.mockReset(); }); - it('removes the releases by given ids from the collection', async () => { - prisma.collection.update.mockImplementation(({ data }) => { - return { + + it("removes the releases by given ids from the collection", async () => { + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + await prisma.collection.create({ + data: { ...collection, - releases: collection.releases.filter( - x => !(data.releases.disconnect as HasId[]).map(x => x.id).includes(x.id) - ) - } + releases: { + connect: releases.map((x) => ({ id: x.id })), + }, + }, }); const { removeReleasesFromCollection } = collectionController(); - const updatedCollection = await removeReleasesFromCollection(1, [2, 3]); - expect(updatedCollection.releases).toMatchObject([{ id: 1 }]); + const updatedCollection = await removeReleasesFromCollection(1, [2, 4]); + expect(updatedCollection.releases).toMatchObject([ + { id: 1 }, + { id: 3 }, + { id: 5 }, + ]); }); - it('sets the cover release to empty if the current cover release is removed', async () => { - prisma.collection.update.mockImplementation(({ data }) => { - if (data.coverReleaseId !== undefined) { - return { - ...collection, - coverReleaseId: data.coverReleaseId - } - } - return { + it("sets the cover release to empty if the current cover release is removed", async () => { + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + await prisma.collection.create({ + data: { ...collection, - releases: collection.releases.filter( - x => !(data.releases.disconnect as HasId[]).map(x => x.id).includes(x.id) - ) - } + releases: { + connect: releases.map((x) => ({ id: x.id })), + }, + coverReleaseId: 2, + }, }); const { removeReleasesFromCollection } = collectionController(); - const updatedCollection = await removeReleasesFromCollection(1, [1]); + const updatedCollection = await removeReleasesFromCollection(1, [2]); expect(updatedCollection.coverReleaseId).toBe(null); }); -}); \ No newline at end of file +}); diff --git a/src/main/controllers/group.test.ts b/src/main/controllers/group.test.ts index 34569c42..fca6da76 100644 --- a/src/main/controllers/group.test.ts +++ b/src/main/controllers/group.test.ts @@ -1,68 +1,233 @@ +import prisma from "../db/prisma"; +import { clearPrisma } from "../../test/prisma-utils"; +import { dialog } from "electron"; +import { getFakeGroups, getFakeArtists } from "../../test/seed"; import { groupController } from "./group"; -import { getFakeArtist, getFakeGroup } from "@/test/utils"; -import prisma from '../db/__mocks__/prisma'; -import { HasId } from "@/types/types"; +import { sortBy } from "@/lib/utils"; -vi.mock('../db/prisma'); +afterEach(clearPrisma); -const groups = Array.from({ length: 55 }, (_, i) => getFakeGroup(i + 1)); - -describe('getGroups function', () => { - it('returns as many groups as per the take parameter', async () => { - prisma.group.findMany.mockImplementation(({ take }) => groups.slice(0, take)); +describe("getGroups function", () => { + it("returns as many groups as per the take parameter", async () => { + await prisma.group.createMany({ data: getFakeGroups({ length: 10 }) }); const { getGroups } = groupController(); const result = await getGroups({ take: 5 }); expect(result.length).toBe(5); }); - it('returns max 50 groups if no take parameter is specified', async () => { - prisma.group.findMany.mockImplementation(({ take }) => groups.slice(0, take)); + it("returns max 50 groups if no take parameter is specified", async () => { + await prisma.group.createMany({ data: getFakeGroups({ length: 100 }) }); const { getGroups } = groupController(); const result = await getGroups({}); expect(result.length).toBe(50); }); }); -describe('removeArtistsFromGroup function', () => { - const group = getFakeGroup(1, { - coverArtistId: 1, - artists: [ - getFakeArtist(1), - getFakeArtist(2), - getFakeArtist(3), - ] - }); - it('removes the artists by given ids from the group', async () => { - prisma.group.update.mockImplementation(({ data }) => { - return { +describe("getAllGroups function", () => { + it("returns all groups", async () => { + const groups = getFakeGroups({ length: 10 }); + await prisma.group.createMany({ data: groups }); + const { getAllGroups } = groupController(); + const result = await getAllGroups(); + expect(result).toMatchObject(groups.toSorted(sortBy("title"))); + }); +}); + +describe("getGroup function", () => { + it("returns null if no group is found", async () => { + const { getGroup } = groupController(); + const result = await getGroup(1); + expect(result).toBe(null); + }); + + it("returns the group by given id", async () => { + const groups = getFakeGroups({ length: 10 }); + await prisma.group.createMany({ data: groups }); + const { getGroup } = groupController(); + const result = await getGroup(1); + expect(result).toMatchObject(groups[0]); + }); +}); + +describe("createGroup function", () => { + it("creates a new group", async () => { + const artists = getFakeArtists({ length: 3 }); + await prisma.artist.createMany({ data: artists }); + const { createGroup } = groupController(); + await createGroup({ + title: "new group", + artists: artists.map(({ id }) => id), + }); + + const group = await prisma.group.findFirst({ + where: { id: 1 }, + include: { artists: true }, + }); + expect(group).toMatchObject({ + title: "new group", + artists, + }); + }); +}); + +describe("updateGroup function", () => { + it("does nothing if no group is found", async () => { + const { updateGroup } = groupController(); + const result = await updateGroup(1, { + title: "new title", + artists: [], + }); + expect(result).toBe(null); + }); + + it("updates the group with the given information", async () => { + const group = getFakeGroups({ length: 1 }).at(0); + const artists = getFakeArtists({ length: 5 }); + await prisma.artist.createMany({ data: artists }); + await prisma.group.create({ + data: { ...group, - artists: group.artists.filter( - x => !(data.artists.disconnect as HasId[]).map(x => x.id).includes(x.id) - ) - } + artists: { + connect: [{ id: 1 }, { id: 2 }], + }, + }, + }); + + const { updateGroup } = groupController(); + await updateGroup(1, { + title: "new title", + artists: [3, 4], + }); + + const updatedGroup = await prisma.group.findFirst({ + where: { id: 1 }, + include: { artists: true }, + }); + + expect(updatedGroup).toMatchObject({ + title: "new title", + artists: [{ id: 3 }, { id: 4 }], + }); + }); +}); + +describe("deleteGroup function", () => { + it("deletes a group by the given id", async () => { + const group = getFakeGroups({ length: 1 }).at(0); + await prisma.group.create({ data: group }); + + const { deleteGroup } = groupController(); + await deleteGroup(1); + const result = await prisma.group.findFirst({ where: { id: 1 } }); + + expect(result).toBe(null); + }); +}); + +describe("deleteGroups function", () => { + it("deletes all groups by the given ids", async () => { + const groups = getFakeGroups({ length: 3 }); + await prisma.group.createMany({ data: groups }); + + const { deleteGroups } = groupController(); + await deleteGroups([2, 3]); + const result = await prisma.group.findMany(); + + expect(result).toMatchObject(groups.slice(0, 1)); + }); +}); + +describe("addArtistsToGroup function", async () => { + it("adds the artists by given ids from the group", async () => { + const artists = getFakeArtists({ length: 3 }); + await prisma.artist.createMany({ data: artists }); + await prisma.group.create({ + data: getFakeGroups({ length: 1 }).at(0), + }); + const { addArtistsToGroup } = groupController(); + await addArtistsToGroup(1, artists); + const updatedGroup = await prisma.group.findFirst({ + where: { id: 1 }, + include: { artists: true }, + }); + expect(updatedGroup.artists).toMatchObject(artists); + }); + + it("sets the cover artist to empty if the current cover artist is removed", async () => { + const artists = getFakeArtists({ length: 3 }); + await prisma.artist.createMany({ data: artists }); + await prisma.group.createMany({ + data: getFakeGroups({ length: 1, artists }), + }); + const { removeArtistsFromGroup } = groupController(); + const updatedGroup = await removeArtistsFromGroup(1, [1]); + expect(updatedGroup.coverArtistId).toBe(null); + }); +}); + +describe("removeArtistsFromGroup function", async () => { + it("does nothing if the cancel button is pressed", async () => { + const artists = getFakeArtists({ length: 3 }); + await prisma.artist.createMany({ data: artists }); + await prisma.group.create({ + data: getFakeGroups({ length: 1, artists }).at(0), + }); + + const dialogSpy = vi.spyOn(dialog, "showMessageBoxSync"); + dialogSpy.mockReturnValueOnce(1); + + const { removeArtistsFromGroup } = groupController(); + await removeArtistsFromGroup(1, [2]); + + const result = await prisma.group.findFirst({ + where: { id: 1 }, + include: { artists: true }, + }); + + expect(result.artists.length).toBe(artists.length); + + dialogSpy.mockReset(); + }); + + it("removes the artists by given ids from the group", async () => { + const artists = getFakeArtists({ length: 3 }); + await prisma.artist.createMany({ data: artists }); + await prisma.group.create({ + data: getFakeGroups({ length: 1, artists }).at(0), }); const { removeArtistsFromGroup } = groupController(); const updatedGroup = await removeArtistsFromGroup(1, [2, 3]); expect(updatedGroup.artists).toMatchObject([{ id: 1 }]); }); - it('sets the cover artist to empty if the current cover artist is removed', async () => { - prisma.group.update.mockImplementation(({ data }) => { - if (data.coverArtistId !== undefined) { - return { - ...group, - coverArtistId: data.coverArtistId - } - } - return { - ...group, - artists: group.artists.filter( - x => !(data.artists.disconnect as HasId[]).map(x => x.id).includes(x.id) - ) - } + it("sets the cover artist to empty if the current cover artist is removed", async () => { + const artists = getFakeArtists({ length: 3 }); + await prisma.artist.createMany({ data: artists }); + await prisma.group.createMany({ + data: getFakeGroups({ length: 1, artists }), }); const { removeArtistsFromGroup } = groupController(); const updatedGroup = await removeArtistsFromGroup(1, [1]); expect(updatedGroup.coverArtistId).toBe(null); }); -}); \ No newline at end of file +}); + +describe("setGroupCoverArtist function", () => { + it("sets the group cover", async () => { + const group = getFakeGroups({ length: 1 }).at(0); + const artists = getFakeArtists({ length: 2 }); + await prisma.artist.createMany({ data: artists }); + await prisma.group.create({ + data: { + ...group, + artists: { + connect: [{ id: 1 }], + }, + coverArtistId: 1, + }, + }); + const { setGroupCoverArtist } = groupController(); + const updatedGroup = await setGroupCoverArtist(1, 2); + expect(updatedGroup.coverArtistId).toBe(2); + }); +}); diff --git a/src/main/controllers/importExport.test.ts b/src/main/controllers/importExport.test.ts new file mode 100644 index 00000000..6ef380a6 --- /dev/null +++ b/src/main/controllers/importExport.test.ts @@ -0,0 +1,141 @@ +import { clearPrisma } from "@/test/prisma-utils"; +import { seed, getData } from "@/test/seed"; +import { mockFs } from "@/test/mock-fs"; +import { withoutDates } from "@/test/utils"; +import { importExportController } from "./importExport"; +import { Open } from "unzipper"; +import path from "node:path"; +import { readJSON } from "fs-extra"; + +afterEach(clearPrisma); + +describe("exportDataFromDialog function", () => { + it("exports current data to a zip archive", async (context) => { + await seed(); + const seeded = await getData(); + const directory = await mockFs( + { + Desktop: { + dumpFolder: {}, + }, + UserData: {}, + }, + context.task.id + ); + + const userDataPath = path.join(directory, "UserData"); + + const send = vi.fn(); + const openFileDialog = vi.fn(); + const openFolderDialog = () => [path.join(directory, "Desktop/dumpFolder")]; + + const { exportDataFromDialog } = importExportController({ + openFolderDialog, + openFileDialog, + desktopPath: path.join(directory, "Desktop"), + userDataPath, + appVersion: "0.5", + send, + }); + + const archivePath = await exportDataFromDialog(); + const outputPath = path.join( + userDataPath, + "imports", + path.basename(archivePath) + ); + + await unzip({ + archivePath, + outputPath, + }); + + await Promise.all( + Object.keys(seeded).map(async (entity: keyof typeof seeded) => { + const exported = await readJSON( + path.join(outputPath, `${entity}.json`) + ); + expect(exported.map(withoutDates)).toMatchObject( + seeded[entity].map(withoutDates) + ); + }) + ); + }); +}); + +describe("importDataFromDialog function", () => { + it("imports data from an archive", async (context) => { + await seed(); + const directory = await mockFs( + { + Desktop: { + dumpFolder: {}, + }, + UserData: {}, + }, + context.task.id + ); + + const userDataPath = path.join(directory, "UserData"); + + const send = vi.fn(); + const openFileDialog = vi.fn(); + const openFolderDialog = () => [path.join(directory, "Desktop/dumpFolder")]; + + const { importDataFromDialog, exportDataFromDialog } = + importExportController({ + openFolderDialog, + openFileDialog, + desktopPath: path.join(directory, "Desktop"), + userDataPath, + appVersion: "0.5", + send, + }); + + const archivePath = await exportDataFromDialog(); + const outputPath = path.join( + userDataPath, + "imports", + path.basename(archivePath) + ); + + openFileDialog.mockReturnValueOnce(archivePath); + + await unzip({ + archivePath, + outputPath, + }); + + await importDataFromDialog(); + + const data = await getData(); + const entities = Object.keys(data); + await Promise.all( + entities.map(async (entity: keyof typeof data) => { + expect(data[entity].map(withoutDates)).toMatchObject( + data[entity].map(withoutDates) + ); + }) + ); + + [ + ...entities.flatMap((x) => + ["Clearing", "Importing"].map((y) => `${y} ${x}`) + ), + "Importing additional relationships", + ].forEach((x) => { + expect(send).toHaveBeenCalledWith("importData:progress", x, false); + expect(send).toHaveBeenCalledWith("importData:progress", x, true); + }); + expect(send).toHaveBeenCalledWith("importData:progress", "done", false); + }); +}); + +type UnzipParams = { + archivePath: string; + outputPath: string; +}; +async function unzip({ archivePath, outputPath }: UnzipParams) { + const unzipped = await Open.file(archivePath); + await unzipped.extract({ path: outputPath }); +} diff --git a/src/main/controllers/importExport.ts b/src/main/controllers/importExport.ts index 047888f8..78338512 100644 --- a/src/main/controllers/importExport.ts +++ b/src/main/controllers/importExport.ts @@ -20,6 +20,13 @@ type ImportExportControllerParams = { const ON_DONE_DELAY = 5000; +function getDelay() { + if (process.env.NODE_ENV === "test") { + return 0; + } + return ON_DONE_DELAY; +} + export function importExportController({ openFolderDialog, openFileDialog, @@ -47,7 +54,7 @@ export function importExportController({ onProgress: (step, completed = false) => send("importData:progress", step, completed), }); - await wait(ON_DONE_DELAY); + await wait(getDelay()); app.relaunch(); app.exit(); } catch (error) { @@ -63,7 +70,8 @@ export function importExportController({ if (!outputPath) { return; } - exportData({ + + return await exportData({ userDataPath, outputPath, appVersion, diff --git a/src/main/controllers/init.test.ts b/src/main/controllers/init.test.ts new file mode 100644 index 00000000..354fe5d5 --- /dev/null +++ b/src/main/controllers/init.test.ts @@ -0,0 +1,82 @@ +import type { BrowserWindow } from "electron"; +import { ipcMain } from "electron"; +import { init } from "./init"; +import { actions as artistActions } from "./artist"; +import { actions as releaseActions } from "./release"; +import { actions as groupActions } from "./group"; +import { actions as collectionActions } from "./collection"; +import { actions as systemActions } from "./system"; +import { actions as searchResultActions } from "./searchResult"; +import { actions as importExportActions } from "./importExport"; + +vi.mock("../settings"); + +function getMainWindow() { + const onSwipe = vi.fn(); + + const mainWindow = { + on: vi.fn(), + dispatchEvent(event: Event, direction: string) { + if (event.type === "swipe") { + onSwipe(event, direction); + } + }, + } as unknown as BrowserWindow & { + dispatchEvent: (event: Event, direction: string) => void; + }; + + return { onSwipe, mainWindow }; +} + +describe("init function", () => { + it("should setup the window swipe listener", () => { + const { mainWindow, onSwipe } = getMainWindow(); + init(mainWindow); + + expect(mainWindow.on).toHaveBeenCalledWith("swipe", expect.anything()); + + ["left", "right"].forEach((direction) => { + const event = new Event("swipe"); + mainWindow.dispatchEvent(event, direction); + + expect(onSwipe).toHaveBeenCalledWith(event, direction); + }); + }); + + it("should setup the ipc listeners", () => { + const { mainWindow } = getMainWindow(); + const ipcOnSpy = vi.spyOn(ipcMain, "on"); + const ipcHandleSpy = vi.spyOn(ipcMain, "handle"); + + init(mainWindow); + + [ + "state:setInputFocused", + "state:selectReleases", + "state:navigate", + "state:clearSelection", + "state:toggleSidebar", + "state:refreshMenu", + "state:refreshCurrentArtist", + ].forEach((eventName) => { + expect(ipcOnSpy).toHaveBeenCalledWith(eventName, expect.anything()); + }); + + [ + ...artistActions, + ...releaseActions, + ...groupActions, + ...collectionActions, + ...systemActions, + ...searchResultActions, + ...importExportActions, + "menu:release", + "menu:artist", + "menu:collection", + "menu:group", + "menu:searchResult", + ].forEach((eventName) => { + expect(ipcHandleSpy).toHaveBeenCalledWith(eventName, expect.anything()); + }); + }); +}); diff --git a/src/main/controllers/release.test.ts b/src/main/controllers/release.test.ts index b3b48a64..fa85b83e 100644 --- a/src/main/controllers/release.test.ts +++ b/src/main/controllers/release.test.ts @@ -1,16 +1,30 @@ -import { getFakeArtist, withPath, getSetting, getFakeRelease, getFakeArtistByHash, getTrackFromData, getFakeTrack, getFakeReleaseByHash, send, FULL_TRACKS } from "@/test/utils"; -import { dialog } from 'electron'; -import path from 'path'; -import prisma from '../db/__mocks__/prisma'; -import fsExtra, { existsSync } from 'fs-extra'; +import prisma from "../db/prisma"; +import { clearPrisma } from "@/test/prisma-utils"; +import { withPath, getSetting, send } from "@/test/utils"; +import { dialog } from "electron"; +import path from "path"; +import fsExtra, { existsSync } from "fs-extra"; import { releaseController } from "./release"; import { mockFs } from "@/test/mock-fs"; -import { ArtistWithReleases, ReleaseType, ReleaseWithArtist, ReleaseWithArtistAndTracks, Track } from "@/types/types"; +import { + ReleaseType, + Release, + ReleaseWithArtist, + ReleaseWithArtistAndSubreleases, + ReleaseWithArtistAndTracks, + Track, +} from "@/types/types"; import { StateManager } from "../state"; +import { + getFakeArtist, + getFakeArtists, + getFakeReleasesForArtist, +} from "../../test/seed"; +import { sortBy } from "@/lib/utils"; -vi.mock('../db/prisma'); -vi.mock('../run'); -vi.mock('../covers'); +afterEach(clearPrisma); + +vi.mock("../covers"); const defaultParams = { withPath, @@ -20,342 +34,589 @@ const defaultParams = { openFolderDialog: vi.fn(), }; -describe('release - importFolder function', () => { - it('returns null if the folder has no tracks', async () => { +describe("getReleases function", () => { + it("returns the releases with the given pagination params", async () => { + const releases = getFakeReleasesForArtist(1, 20); + const artist = getFakeArtist(1); + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + + const { getReleases } = releaseController(defaultParams); + const result = await getReleases({ take: 10, skip: 0 }); + + expect(result.pagination).toMatchObject({ + take: 10, + skip: 0, + total: releases.length, + }); + + expect(result.results).toMatchObject( + releases.toSorted(sortBy("createdAt", "desc")).slice(0, 10) + ); + }); + + it("must exclude subreleases", async () => { + const releases = getFakeReleasesForArtist(1); + const artist = getFakeArtist(1); + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + + const { getReleases, groupReleases } = releaseController(defaultParams); + + await groupReleases({ + mainRelease: { + id: 1, + title: "main release title", + }, + discInfo: [ + { id: 1, title: "disc 1", number: 1 }, + { id: 2, title: "disc 2", number: 2 }, + ], + }); + + const result = await getReleases({ take: 10, skip: 0 }); + + expect(result.results).toMatchObject( + releases + .filter((x) => x.id !== 2) + .toSorted(sortBy("createdAt", "desc")) + .map((x) => + x.id === 1 + ? { + ...x, + title: "main release title", + normalizedTitle: "main release title", + discTitle: "disc 1", + } + : x + ) + ); + }); +}); + +describe("getLatestAdditions function", () => { + it("returns the latest added releases grouped by date", async () => { + const releases = getFakeReleasesForArtist(1, 20); + const artist = getFakeArtist(1); + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + + const { getLatestAdditions } = releaseController(defaultParams); + const result = await getLatestAdditions("2025-11-01"); + + expect(result).toMatchObject({ + "2025-11-11": [ + { id: 11, createdAt: new Date("2025-11-11T21:41:31.693Z") }, + ], + "2025-12-12": [ + { id: 12, createdAt: new Date("2025-12-12T21:41:31.693Z") }, + ], + }); + }); +}); + +describe("importFolder function", () => { + it("returns null if the folder has no tracks", async () => { const { importFolder } = releaseController(defaultParams); - const releases = await importFolder('empty/folder'); + const releases = await importFolder("empty/folder"); expect(releases).toEqual([]); }); - it('returns null if the folder is malformed', async () => { - const { importFolder } = releaseController(defaultParams); - const releases = await importFolder('malformed/folder'); + it("returns null if the folder is malformed", async (context) => { + const directory = await mockFs( + { + "/LIBRARY_PATH/malformed/folder": { + "01 - Track 1.mp3": "", + "02 - Track 2.mp3": "", + "03 - Track 3.mp3": "", + "04 - Track 4.mp3": "", + "05 - Track 5.mp3": "", + }, + }, + context.task.id + ); + + const LIBRARY_PATH = path.join(directory, "LIBRARY_PATH"); + + const { importFolder } = releaseController({ + ...defaultParams, + getSetting: (key: string) => + key === "LIBRARY_PATH" ? LIBRARY_PATH : key, + }); + + const releases = await importFolder( + path.join(LIBRARY_PATH, "malformed/folder") + ); expect(releases).toEqual([]); }); - it('parses the given path, updates the db and returns the created release', async () => { - prisma.artist.upsert.mockImplementation(({ where }) => getFakeArtistByHash(where.hash)); - prisma.release.upsert.mockImplementation(({ where }) => getFakeRelease(where.hash)); - prisma.track.create.mockImplementation( - ({ data }) => Promise.resolve(getTrackFromData(data)) + it("parses the given path, updates the db and returns the created release", async (context) => { + const directory = await mockFs( + { + "/LIBRARY_PATH/A/Artist 1": { + "[Album]": { + "2000 - Release 1": { + "01 - Track 1.mp3": "", + "02 - Track 2.mp3": "", + "03 - Track 3.mp3": "", + "04 - Track 4.mp3": "", + "05 - Track 5.mp3": "", + }, + }, + }, + }, + context.task.id ); - prisma.release.update.mockImplementation(({ data, where }) => Promise.resolve({ - ...getFakeRelease(where.id), - tracks: data.tracks.connect.map(({ id }, index) => getFakeTrack(index, id, where.id)) - })); - const { importFolder } = releaseController(defaultParams); - const releases = await importFolder('A/Artist/[Album]/1999 - Single Folder'); + const LIBRARY_PATH = path.join(directory, "LIBRARY_PATH"); + + const { importFolder } = releaseController({ + ...defaultParams, + getSetting: (key: string) => + key === "LIBRARY_PATH" ? LIBRARY_PATH : key, + }); + const releases = await importFolder( + path.join(LIBRARY_PATH, "A/Artist 1/[Album]") + ); expect(releases.length).toBe(1); expect(releases[0].tracks.length).toBe(5); }); - it('parses the given path, updates the db and returns the created releases', async () => { - prisma.artist.upsert.mockImplementation(({ where }) => getFakeArtistByHash(where.hash)); - prisma.release.upsert.mockImplementation(({ where }) => getFakeReleaseByHash(where.hash)); - prisma.track.create.mockImplementation( - ({ data }) => Promise.resolve(getTrackFromData(data)) + it("parses the given path, updates the db and returns the created releases", async (context) => { + const directory = await mockFs( + { + "/LIBRARY_PATH/A/Artist 1": { + "[Album]": { + "2000 - Release 1": { + "01 - Track 1.mp3": "", + "02 - Track 2.mp3": "", + "03 - Track 3.mp3": "", + "04 - Track 4.mp3": "", + "05 - Track 5.mp3": "", + }, + "2000 - Release 2": { + "01 - Track 1.mp3": "", + "02 - Track 2.mp3": "", + "03 - Track 3.mp3": "", + "04 - Track 4.mp3": "", + "05 - Track 5.mp3": "", + }, + }, + }, + }, + context.task.id ); - prisma.release.update.mockImplementation(({ data, where }) => { - return Promise.resolve({ - ...getFakeRelease(where.id), - tracks: data.tracks.connect.map(({ id }, index) => getFakeTrack(index, id, where.id)) - }) - }) - const { importFolder } = releaseController(defaultParams); - const releases = await importFolder('A/Artist/[Album]'); - - expect(releases.length).toBe(2); - expect(releases[0]).toMatchObject({ - _type: 'release', - id: 2, - path: 'Album Two', - title: 'Album Two', - hash: 'b66649708b05af8e', - year: 2000, - type: 'Album', - artist_id: 1, - }); - expect(releases[1]).toMatchObject({ - _type: 'release', - id: 1, - path: 'Album One', - title: 'Album One', - hash: 'e6ff3253fb407e5f', - year: 1999, - type: 'Album', - artist_id: 1, + const LIBRARY_PATH = path.join(directory, "LIBRARY_PATH"); + + const { importFolder } = releaseController({ + ...defaultParams, + getSetting: (key: string) => + key === "LIBRARY_PATH" ? LIBRARY_PATH : key, }); - expect(releases[0].tracks.length).toBe(5); - expect(releases[1].tracks.length).toBe(5); + const importedReleases = await importFolder( + path.join(LIBRARY_PATH, "A/Artist 1/[Album]") + ); + + expect(importedReleases.length).toBe(2); + expect( + importedReleases.sort((a, b) => (a.title > b.title ? 1 : -1)) + ).toMatchObject([ + { + _type: "release", + path: "Release 1", + title: "Release 1", + hash: "ee1478c38c24f36e", + year: 2000, + type: "Album", + artist_id: 1, + }, + { + _type: "release", + path: "Release 2", + title: "Release 2", + hash: "4af3d5d9da84e183", + year: 2000, + type: "Album", + artist_id: 1, + }, + ]); + expect(importedReleases[0].tracks.length).toBe(5); + expect(importedReleases[1].tracks.length).toBe(5); }); }); -describe('release - editRelease function', () => { - it('shows a warning if the new path already exists', async () => { +describe("editRelease function", () => { + it("shows a warning if the new path already exists", async () => { const { editRelease } = releaseController(defaultParams); const result = await editRelease([]); expect(result).toEqual([]); }); - it('updates the release info without moving the folder if the passed path is the old one', async () => { + it("just renames the discs if no other info is changed", async () => { const { editRelease } = releaseController(defaultParams); - const spy = vi.spyOn(fsExtra, 'move'); - const release = getFakeRelease(1); - prisma.release.update.mockImplementation(({ data }) => ({ ...release, ...data })); + const spy = vi.spyOn(fsExtra, "move"); + const releases = getFakeReleasesForArtist(1, 2); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.createMany({ data: releases }); + + const result = (await editRelease([ + { + ...releases[0], + newPath: "Release 1", + newDiscTitle: "New Disc 1", + newTitle: "Release 1", + newYear: 2000, + newType: "Album" as ReleaseType, + }, + { + ...releases[1], + newPath: "Release 2", + newDiscTitle: "New Disc 2", + newTitle: "Release 2", + newYear: 2000, + newType: "Album" as ReleaseType, + }, + ])) as ReleaseWithArtist[]; + + expect(result).toMatchObject([ + { discTitle: "New Disc 1" }, + { discTitle: "New Disc 2" }, + ]); + + expect(spy).not.toHaveBeenCalled(); + }); + + it("updates the release info without moving the folder if the passed path is the old one", async (context) => { + const directory = await mockFs( + { + "/LIBRARY_PATH/A/Artist 1": { + "[Album]": { + "2000 - Release 1": { + "01 - Track 1.mp3": "", + "02 - Track 2.mp3": "", + "03 - Track 3.mp3": "", + "04 - Track 4.mp3": "", + "05 - Track 5.mp3": "", + }, + "2000 - Release 2": { + "01 - Track 1.mp3": "", + "02 - Track 2.mp3": "", + "03 - Track 3.mp3": "", + "04 - Track 4.mp3": "", + "05 - Track 5.mp3": "", + }, + }, + }, + }, + context.task.id + ); + + const { editRelease } = releaseController({ + ...defaultParams, + withPath: (key, folderPath) => path.join(directory, key, folderPath), + }); + const spy = vi.spyOn(fsExtra, "move"); + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); const newInfo = { - newPath: 'Album One', - newDiscTitle: 'Album Edited', - newTitle: 'Album Edited', + newPath: "Album One", + newDiscTitle: "Album Edited", + newTitle: "Album Edited", newYear: 1999, - newType: 'Album' as ReleaseType, + newType: "Album" as ReleaseType, }; - const result = await editRelease([{ ...release, ...newInfo }]); + const result = (await editRelease([ + { ...release, ...newInfo }, + ])) as ReleaseWithArtist[]; expect(result[0]).toMatchObject({ - path: 'Album One', + path: "Album One", discTitle: null, - title: 'Album Edited', + title: "Album Edited", year: 1999, - type: 'Album' as ReleaseType, + type: "Album" as ReleaseType, }); expect(spy).not.toHaveBeenCalled(); }); - it('shows a warning if the new path contains any ../ sequence', async () => { + it("shows a warning if the new path contains any ../ sequence", async () => { const { editRelease } = releaseController(defaultParams); - const moveSpy = vi.spyOn(fsExtra, 'move'); - const dialogSpy = vi.spyOn(dialog, 'showMessageBoxSync'); - const release = getFakeRelease(1); + const moveSpy = vi.spyOn(fsExtra, "move"); + const dialogSpy = vi.spyOn(dialog, "showMessageBoxSync"); const newInfo = { - newPath: '../Album One', - newDiscTitle: 'Album Edited', - newTitle: 'Album Edited', - newType: 'EP' as ReleaseType, - newYear: 2000 + newPath: "../Album One", + newDiscTitle: "Album Edited", + newTitle: "Album Edited", + newType: "EP" as ReleaseType, + newYear: 2000, }; - const result = await editRelease([{ - ...release, - ...newInfo - }]); + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + + const result = await editRelease([ + { + ...release, + ...newInfo, + }, + ]); - expect(dialogSpy).toHaveBeenCalledWith( - null, { - message: 'Error while renaming', + expect(dialogSpy).toHaveBeenCalledWith(null, { + message: "Error while renaming", detail: "Path cannot contain any '../' sequence", - type: 'error', - buttons: ['OK'], + type: "error", + buttons: ["OK"], }); expect(result).toBe(false); expect(moveSpy).not.toHaveBeenCalled(); }); - it('shows a warning if the new path exists', async (context) => { - const directory = await mockFs({ - '/LIBRARY_PATH/A/Artist/[Album]/1999 - New Album Path': {} - }, context.task.id); + it("shows a warning if the new path exists", async (context) => { + const directory = await mockFs( + { + "/LIBRARY_PATH/A/Artist 1/[Album]/": { + "2000 - Release 1": {}, + "2000 - New Album Path": {}, + }, + }, + context.task.id + ); + + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); const { editRelease } = releaseController({ ...defaultParams, withPath: (key, folderPath) => path.join(directory, key, folderPath), }); - prisma.artist.findFirst.mockResolvedValue( - { ...getFakeArtist(1), releases: [] } as ArtistWithReleases - ); - const moveSpy = vi.spyOn(fsExtra, 'move'); - const dialogSpy = vi.spyOn(dialog, 'showMessageBoxSync'); - const release = getFakeRelease(1); + const moveSpy = vi.spyOn(fsExtra, "move"); + const dialogSpy = vi.spyOn(dialog, "showMessageBoxSync"); + const newInfo = { - newPath: 'New Album Path', - newDiscTitle: 'Album Edited', - newTitle: 'Album Edited', - newType: 'Album' as ReleaseType, - newYear: 1999 + newPath: "New Album Path", + newDiscTitle: "Album Edited", + newTitle: "Album Edited", + newType: "Album" as ReleaseType, + newYear: 2000, }; - const result = await editRelease([{ - ...release, - ...newInfo - }]); + const result = await editRelease([ + { + ...release, + ...newInfo, + }, + ]); - expect(dialogSpy).toHaveBeenCalledWith( - null, { - message: 'Error while renaming', + expect(dialogSpy).toHaveBeenCalledWith(null, { + message: "Error while renaming", detail: `Path ${newInfo.newPath} already exists`, - type: 'error', - buttons: ['OK'], + type: "error", + buttons: ["OK"], }); expect(result).toBe(false); expect(moveSpy).not.toHaveBeenCalled(); }); - it('shows a warning if the old path does not exist', async (context) => { - const directory = await mockFs({ - '/LIBRARY_PATH': {} - }, context.task.id); + it("shows a warning if the old path does not exist", async (context) => { + const directory = await mockFs( + { + "/LIBRARY_PATH": {}, + }, + context.task.id + ); + + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); const { editRelease } = releaseController({ ...defaultParams, withPath: (key, folderPath) => path.join(directory, key, folderPath), }); - prisma.artist.findFirst.mockResolvedValue({ ...getFakeArtist(1), releases: [] } as ArtistWithReleases); - const dialogSpy = vi.spyOn(dialog, 'showMessageBoxSync'); - const release = { - ...getFakeRelease(1), - path: 'not-existing' - }; + const dialogSpy = vi.spyOn(dialog, "showMessageBoxSync"); const newInfo = { - newPath: 'New Album Path', - newDiscTitle: 'Album Edited', - newTitle: 'Album Edited', - newType: 'EP' as ReleaseType, - newYear: 2000 + newPath: "New Album Path", + newDiscTitle: "Album Edited", + newTitle: "Album Edited", + newType: "EP" as ReleaseType, + newYear: 2000, }; - const result = await editRelease([{ - ...release, - ...newInfo - }]); + const result = await editRelease([ + { + ...release, + ...newInfo, + }, + ]); - expect(dialogSpy).toHaveBeenCalledWith( - null, { - message: 'Error while renaming', - type: 'warning', - detail: `Release 1 not found at: ${directory}/LIBRARY_PATH/A/Artist/[Album]/1999 - not-existing`, - buttons: ['OK'], + expect(dialogSpy).toHaveBeenCalledWith(null, { + message: "Error while renaming", + type: "warning", + detail: `Release 1 not found at: ${directory}/LIBRARY_PATH/A/Artist 1/[Album]/2000 - Release 1`, + buttons: ["OK"], }); expect(result).toBe(false); }); - it('should move the release files and update it accordingly', async (context) => { - const directory = await mockFs({ - '/LIBRARY_PATH/A/Artist/[Album]/1999 - Album One': { - '01 - track 1.mp3': '' + it("should move the release files and update it accordingly", async (context) => { + const directory = await mockFs( + { + "/LIBRARY_PATH/A/Artist 1/[Album]/2000 - Release 1": { + "01 - Track 1.mp3": "", + }, + "/LIBRARY_PATH/A/Artist 1/[EP]": {}, + "/COVERS_PATH": { + "ee1478c38c24f36e-cover.jpg": "", + }, }, - '/LIBRARY_PATH/A/Artist/[EP]': {}, - '/COVERS_PATH': { - 'e6ff3253fb407e5f-cover.jpg': '', - }, - }, context.task.id); + context.task.id + ); + + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); const { editRelease } = releaseController({ ...defaultParams, withPath: (key, folderPath) => path.join(directory, key, folderPath), }); - prisma.artist.findFirst.mockResolvedValue({ ...getFakeArtist(1), releases: [] } as ArtistWithReleases); - prisma.release.update.mockImplementation(({ data }) => ({ ...release, ...data })); - - const release = getFakeRelease(1); const newInfo = { - newPath: 'New Album Path', - newDiscTitle: 'Album Edited', - newTitle: 'Album Edited', - newType: 'EP' as ReleaseType, - newYear: 2000 + newPath: "New Release Path", + newDiscTitle: "Release Edited", + newTitle: "Release Edited", + newType: "EP" as ReleaseType, + newYear: 2001, }; - const result = await editRelease([{ ...release, ...newInfo }]); + const result = (await editRelease([ + { ...release, ...newInfo }, + ])) as ReleaseWithArtist[]; expect(result[0]).toMatchObject({ - path: 'New Album Path', + path: "New Release Path", discTitle: null, - title: 'Album Edited', - type: 'EP' as ReleaseType, - year: 2000 + title: "Release Edited", + type: "EP" as ReleaseType, + year: 2001, }); - expect(existsSync( - path.join(directory, 'LIBRARY_PATH/A/Artist/[Album]/1999 - Album One')) + expect( + existsSync( + path.join(directory, "LIBRARY_PATH/A/Artist 1/[Album]/2000 - Release 1") + ) ).toBe(false); - expect(existsSync( - path.join(directory, 'LIBRARY_PATH/A/Artist/[EP]/2000 - New Album Path')) + expect( + existsSync( + path.join( + directory, + "LIBRARY_PATH/A/Artist 1/[EP]/2001 - New Release Path" + ) + ) ).toBe(true); - expect(existsSync( - path.join(directory, 'COVERS_PATH/e6ff3253fb407e5f-cover.jpg')) + expect( + existsSync(path.join(directory, "COVERS_PATH/ee1478c38c24f36e-cover.jpg")) ).toBe(false); - expect(existsSync( - path.join(directory, 'COVERS_PATH/a916d4005e922133-cover.jpg')) + expect( + existsSync(path.join(directory, "COVERS_PATH/e1d0657d4ba3bd51-cover.jpg")) ).toBe(true); }); - it('should skip moving the current cover if it does not exist', async (context) => { - const directory = await mockFs({ - '/LIBRARY_PATH/A/Artist/[Album]/1999 - Album One': { - '01 - track 1.mp3': '' + it("should skip moving the current cover if it does not exist", async (context) => { + const directory = await mockFs( + { + "/LIBRARY_PATH/A/Artist 1/[Album]/2000 - Release 1": { + "01 - Track 1.mp3": "", + }, + "/LIBRARY_PATH/A/Artist 1/[EP]": {}, }, - '/LIBRARY_PATH/A/Artist/[EP]': {}, - }, context.task.id); + context.task.id + ); + + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); const { editRelease } = releaseController({ ...defaultParams, withPath: (key, folderPath) => path.join(directory, key, folderPath), }); - prisma.artist.findFirst.mockResolvedValue( - { ...getFakeArtist(1), releases: [] } as ArtistWithReleases - ); - prisma.release.update.mockImplementation(({ data }) => ({ ...release, ...data })); - - const release = getFakeRelease(1); const newInfo = { - newPath: 'New Album Path', - newDiscTitle: 'Album Edited', - newTitle: 'Album Edited', - newType: 'EP' as ReleaseType, - newYear: 2000 + newPath: "New Release Path", + newDiscTitle: "Release Edited", + newTitle: "Release Edited", + newType: "EP" as ReleaseType, + newYear: 2001, }; - const result = await editRelease([{ ...release, ...newInfo }]); + const result = (await editRelease([ + { ...release, ...newInfo }, + ])) as ReleaseWithArtist[]; expect(result[0]).toMatchObject({ - path: 'New Album Path', + path: "New Release Path", discTitle: null, - title: 'Album Edited', - type: 'EP' as ReleaseType, - year: 2000 + title: "Release Edited", + type: "EP" as ReleaseType, + year: 2001, }); - expect(existsSync( - path.join(directory, 'COVERS_PATH/a916d4005e922133-cover.jpg')) + expect( + existsSync(path.join(directory, "COVERS_PATH/e1d0657d4ba3bd51-cover.jpg")) ).toBe(false); }); }); -describe('importCovers function', () => { - it('searches the covers of the given releases and returns those with positive results', async () => { +describe("importCovers function", () => { + it("searches the covers of the given releases and returns those with positive results", async () => { { + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + const send = vi.fn(); const { importCovers } = releaseController({ ...defaultParams, send }); - const release = getFakeRelease(1); - await importCovers([release]); - expect(send).toHaveBeenCalledWith('coverUpdate', [release]); + await importCovers([release as ReleaseWithArtist]); + expect(send).toHaveBeenCalledWith("coverUpdate", [release]); } { - // const send = vi.fn(); - // const { importCovers } = releaseController({ ...defaultParams, send }); - // const release = getFakeRelease(3); - // await importCovers([release]); - // expect(send).not.toHaveBeenCalled(); + const release = getFakeReleasesForArtist(1, 3).at(2); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + + const send = vi.fn(); + const { importCovers } = releaseController({ ...defaultParams, send }); + await importCovers([release as ReleaseWithArtist]); + expect(send).not.toHaveBeenCalled(); } }); }); -describe('importMissingCovers function', () => { - it('imports the covers of the releases without an existing cover file', async (context) => { - const directory = await mockFs({ - 'COVERS_PATH/e6ff3253fb407e5f-cover.jpg': '', - }, context.task.id); +describe("importMissingCovers function", () => { + it("imports the covers of the releases without an existing cover file", async (context) => { + const directory = await mockFs( + { + "COVERS_PATH/ee1478c38c24f36e-cover.jpg": "", + }, + context.task.id + ); + const releases = getFakeReleasesForArtist(1, 2); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.createMany({ data: releases }); + const send = vi.fn(); const { importMissingCovers } = releaseController({ ...defaultParams, @@ -363,26 +624,29 @@ describe('importMissingCovers function', () => { withPath: (key, folderPath) => path.join(directory, key, folderPath), }); { - const releases = [getFakeRelease(1)]; - await importMissingCovers(releases); + await importMissingCovers([releases[0]] as ReleaseWithArtist[]); expect(send).not.toHaveBeenCalled(); } { - const releases = [ - getFakeRelease(1), - getFakeRelease(2), - ]; - await importMissingCovers(releases); - expect(send).toHaveBeenCalledWith('coverUpdate', [releases[1]]); + await importMissingCovers(releases as ReleaseWithArtist[]); + expect(send).toHaveBeenCalledWith("coverUpdate", [releases[1]]); } }); }); -describe('deleteCover function', () => { - it('deletes the coves of the given release', async (context) => { - const directory = await mockFs({ - 'COVERS_PATH/e6ff3253fb407e5f-cover.jpg': '', - }, context.task.id); +describe("deleteCover function", () => { + it("deletes the coves of the given release", async (context) => { + const directory = await mockFs( + { + "COVERS_PATH/ee1478c38c24f36e-cover.jpg": "", + }, + context.task.id + ); + + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + const send = vi.fn(); const { deleteCover } = releaseController({ ...defaultParams, @@ -390,33 +654,37 @@ describe('deleteCover function', () => { withPath: (key, folderPath) => path.join(directory, key, folderPath), }); - const release = getFakeRelease(1); await deleteCover(release); - expect(send).toHaveBeenCalledWith('coverUpdate', [release]); - expect(existsSync(withPath('COVERS_PATH', 'e6ff3253fb407e5f-cover.jpg'))).toBe(false); + expect(send).toHaveBeenCalledWith("coverUpdate", [release]); + expect( + existsSync(withPath("COVERS_PATH", "e6ff3253fb407e5f-cover.jpg")) + ).toBe(false); }); }); -describe('downloadCover function', () => { - it('does nothing is no release if found', async () => { +describe("downloadCover function", () => { + it("does nothing is no release if found", async () => { const { downloadCover } = releaseController(defaultParams); - prisma.release.findFirst.mockResolvedValue(null); - const result = await downloadCover({ id: 1, url: 'https://example.com/pic.jpg' }); + const result = await downloadCover({ + id: 1, + url: "https://example.com/pic.jpg", + }); expect(result).toBeFalsy(); }); - it('downloads the passed url and stores as the cover for the given release id', async (context) => { + it("downloads the passed url and stores as the cover for the given release id", async (context) => { const directory = await mockFs({ COVERS_PATH: {} }, context.task.id); - const getSetting = ((key: string) => { - if (key === 'LIBRARY_PATH' || key === 'COVERS_PATH') { + const getSetting = (key: string) => { + if (key === "LIBRARY_PATH" || key === "COVERS_PATH") { return path.join(directory, key); } return key; - }); + }; { const send = vi.fn(); - const release = getFakeRelease(1); - prisma.release.findFirst.mockResolvedValue(release); + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); const { downloadCover } = releaseController({ ...defaultParams, @@ -425,70 +693,116 @@ describe('downloadCover function', () => { withPath: (key, folderPath) => path.join(directory, key, folderPath), }); - const result = await downloadCover({ id: 1, url: 'https://example.com/pic.jpg' }); + const result = await downloadCover({ + id: 1, + url: "https://example.com/pic.jpg", + }); expect(result).toBeTruthy(); - expect(existsSync( - path.join(directory, `/COVERS_PATH/${release.hash}-cover.jpg`)) + expect( + existsSync( + path.join(directory, `/COVERS_PATH/${release.hash}-cover.jpg`) + ) ).toBe(true); - expect(send).toHaveBeenCalledWith( - 'coverUpdate', [release] - ); + expect(send).toHaveBeenCalledWith("coverUpdate", expect.anything()); } { const send = vi.fn(); - const release = getFakeRelease(2); - prisma.release.findFirst.mockResolvedValue(release); + const release = getFakeReleasesForArtist(1, 2).at(1); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + const { downloadCover } = releaseController({ ...defaultParams, withPath: (key, folderPath) => path.join(directory, key, folderPath), }); - const result = await downloadCover({ id: 2, url: 'https://example.com/not-found.jpg' }); + const result = await downloadCover({ + id: 2, + url: "https://example.com/not-found.jpg", + }); expect(result).toBe(false); - expect(existsSync( - path.join(directory, `/COVERS_PATH/${release.hash}-cover.jpg`)) + expect( + existsSync( + path.join(directory, `/COVERS_PATH/${release.hash}-cover.jpg`) + ) ).toBe(false); expect(send).not.toHaveBeenCalled(); } }); }); -describe('refreshReleaseContents function', () => { - it('does nothing is no release if found', async () => { - prisma.release.findFirst.mockResolvedValue(null); +describe("refreshReleaseContents function", () => { + it("does nothing is no release if found", async () => { const { refreshReleaseContents } = releaseController(defaultParams); const result = await refreshReleaseContents(1); expect(result).toBeFalsy(); }); - it('updates the track information for the given release and returns it', async () => { - const release = { ...getFakeRelease(1), subReleases: [] as ReleaseWithArtist[] }; - prisma.release.findFirst.mockResolvedValue(release); - prisma.track.create.mockImplementation( - ({ data }) => Promise.resolve(getTrackFromData(data)) + it("updates the track information for the given release and returns it", async (context) => { + const directory = await mockFs( + { + "/LIBRARY_PATH/A/Artist 1": { + "[Album]": { + "2000 - Release 1": { + "01 - Track 1.mp3": "", + "02 - Track 2.mp3": "", + "03 - Track 3.mp3": "", + "04 - Track 4.mp3": "", + "05 - Track 5.mp3": "", + }, + }, + }, + }, + context.task.id ); - prisma.release.update.mockResolvedValue({ - ...release, - tracks: FULL_TRACKS - } as ReleaseWithArtistAndTracks); - const { refreshReleaseContents } = releaseController(defaultParams); - const result = await refreshReleaseContents(1) as ReleaseWithArtistAndTracks[]; + + const LIBRARY_PATH = path.join(directory, "LIBRARY_PATH"); + + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + + const { refreshReleaseContents } = releaseController({ + ...defaultParams, + getSetting: (key: string) => + key === "LIBRARY_PATH" ? LIBRARY_PATH : key, + }); + const result = (await refreshReleaseContents( + 1 + )) as ReleaseWithArtistAndTracks[]; + expect(result[0]).toMatchObject(release); expect(result[0].tracks.length).toBe(5); }); }); -describe('refreshCurrentArtistReleases function', () => { - it('updates the track information for releases of the current selected artist', async () => { - const release = { ...getFakeRelease(1), subReleases: [] as ReleaseWithArtist[], tracks: [] as Track[] }; - prisma.release.findFirst.mockResolvedValue(release); - prisma.track.create.mockImplementation( - ({ data }) => Promise.resolve(getTrackFromData(data)) - ); - prisma.release.update.mockResolvedValue({ - ...release, - tracks: FULL_TRACKS - } as ReleaseWithArtistAndTracks); +describe("refreshCurrentArtistReleases function", () => { + it("does nothing is no release should be refreshed", async () => { + const send = vi.fn(); + const setImporting = vi.fn(); + const { refreshCurrentArtistReleases } = releaseController({ + ...defaultParams, + send, + state: { + setImporting, + getCurrentArtist: () => ({ + releases: [] as Release[], + }), + } as unknown as StateManager, + }); + + await refreshCurrentArtistReleases(); + + expect(send).not.toHaveBeenCalled(); + expect(setImporting).not.toHaveBeenCalled(); + }); + + it("updates the track information for releases of the current selected artist", async () => { + const artist = getFakeArtist(1); + const release = getFakeReleasesForArtist(artist.id).at(0); + await prisma.artist.create({ data: artist }); + await prisma.release.create({ data: release }); + const send = vi.fn(); const { refreshCurrentArtistReleases } = releaseController({ ...defaultParams, @@ -496,91 +810,94 @@ describe('refreshCurrentArtistReleases function', () => { state: { setImporting: vi.fn(), getCurrentArtist: () => ({ - ...getFakeArtist(1), - releases: [release] + ...artist, + releases: [{ ...release, tracks: [] as Track[] }], }), } as unknown as StateManager, }); await refreshCurrentArtistReleases(); - expect(send).toHaveBeenCalledWith('mutate', ['artists', 1]); + expect(send).toHaveBeenCalledWith("mutate", ["artists", 1]); }); }); -describe('refreshEntityRelease function', () => { - it('updates the track information for releases of the passed entity', async () => { - const release = { ...getFakeRelease(1), subReleases: [] as ReleaseWithArtistAndTracks[], tracks: [] as Track[] }; - const artist = { ...getFakeArtist(1), releases: [release] }; - prisma.release.findFirst.mockResolvedValue(release); - prisma.track.create.mockImplementation( - ({ data }) => Promise.resolve(getTrackFromData(data)) - ); - prisma.release.update.mockResolvedValue({ - ...release, - tracks: FULL_TRACKS - } as ReleaseWithArtistAndTracks); +describe("refreshEntityRelease function", () => { + it("updates the track information for releases of the passed entity", async () => { + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + const send = vi.fn(); - const { refreshEntityRelease } = releaseController({ ...defaultParams, send }); - await refreshEntityRelease(artist); - expect(send).toHaveBeenCalledWith('mutate', ['artists', 1]); + const { refreshEntityRelease } = releaseController({ + ...defaultParams, + send, + }); + const artist = await prisma.artist.findFirst({ where: { id: 1 } }); + await refreshEntityRelease({ + ...artist, + _type: "artist", + releases: [], + }); + expect(send).toHaveBeenCalledWith("mutate", ["artists", 1]); }); }); -describe('ungroupSelectedRelease function', () => { - it('does nothing if no release is selected', async () => { +describe("ungroupSelectedRelease function", () => { + it("does nothing if no release is selected", async () => { const send = vi.fn(); const { ungroupSelectedRelease } = releaseController({ ...defaultParams, send, state: { - getSelectedReleases: () => [] + getSelectedReleases: () => [], } as StateManager, }); await ungroupSelectedRelease(); expect(send).not.toHaveBeenCalled(); }); - it('ungroups the selected release', async () => { - prisma.release.update.mockImplementation( - ({ data }) => Promise.resolve(getTrackFromData(data)) - ); - const release = { - ...getFakeRelease(1), - subReleases: [ - { - ...getFakeRelease(2), - mainReleaseId: 1 - } - ] - } + it("ungroups the selected release", async () => { + const releases = getFakeReleasesForArtist(1, 2); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.createMany({ data: releases }); + await prisma.release.update({ + where: { id: 2 }, + data: { mainReleaseId: 1 }, + }); + const updatedRelease = await prisma.release.findFirst({ + where: { id: 1 }, + include: { subReleases: true }, + }); + const send = vi.fn(); const { ungroupSelectedRelease } = releaseController({ ...defaultParams, send, state: { - getSelectedReleases: () => [release] + getSelectedReleases: () => + [updatedRelease] as ReleaseWithArtistAndSubreleases[], } as StateManager, }); await ungroupSelectedRelease(); - send('mutate', [ - ['releases', 'latest'], - ['artists', release.artist_id] + send("mutate", [ + ["releases", "latest"], + ["artists", releases[0].artist_id], ]); - expect(send).toHaveBeenCalledWith('mutate', [ - ['releases', 'latest'], - ['artists', 1] + expect(send).toHaveBeenCalledWith("mutate", [ + ["releases", "latest"], + ["artists", 1], ]); - expect(send).toHaveBeenCalledWith('clearSelection'); + expect(send).toHaveBeenCalledWith("clearSelection"); }); }); -describe('importFolderFromDialog function', () => { - it('does nothing if no folder is picked', async () => { +describe("importFolderFromDialog function", () => { + it("does nothing if no folder is picked", async () => { const send = vi.fn(); const { importFolderFromDialog } = releaseController({ ...defaultParams, openFolderDialog: vi.fn(), send, state: { - getCurrentArtist: () => null + getCurrentArtist: () => null, } as StateManager, }); @@ -588,7 +905,7 @@ describe('importFolderFromDialog function', () => { expect(send).not.toHaveBeenCalled(); }); - it('imports the contents of the folder picked in the dialog', async () => { + it("imports the contents of the folder picked in the dialog", async () => { const artist = getFakeArtist(1); const send = vi.fn(); const { importFolderFromDialog } = releaseController({ @@ -596,24 +913,21 @@ describe('importFolderFromDialog function', () => { openFolderDialog: (folder) => [folder], send, state: { - getCurrentArtist: () => artist + getCurrentArtist: () => artist, } as StateManager, }); await importFolderFromDialog(); - expect(send).toHaveBeenCalledWith('mutate', [['releases', 'latest']]); + expect(send).toHaveBeenCalledWith("mutate", [["releases", "latest"]]); }); }); -describe('addAdditionalArtist function', () => { - it('adds the given additional artist to the given release', async () => { - const release = getFakeRelease(1); - const artist = getFakeArtist(2); - - prisma.release.update.mockResolvedValue(({ - ...release, - additionalArtists: [...release.additionalArtists, artist] - })); +describe("addAdditionalArtist function", () => { + it("adds the given additional artist to the given release", async () => { + const release = getFakeReleasesForArtist(1).at(0); + const artists = getFakeArtists({ length: 2 }); + await prisma.artist.createMany({ data: artists }); + await prisma.release.create({ data: release }); const send = vi.fn(); @@ -622,57 +936,194 @@ describe('addAdditionalArtist function', () => { openFolderDialog: vi.fn(), send, state: { - getCurrentArtist: () => null + getCurrentArtist: () => null, } as StateManager, }); const updatedRelease = await addAdditionalArtist({ release_id: release.id, - artist_id: artist.id, + artist_id: artists[1].id, }); - expect(updatedRelease.additionalArtists).toContainEqual(artist); + expect(updatedRelease.additionalArtists[0]).toMatchObject(artists[1]); - expect(send).toHaveBeenCalledWith('mutate', [ - ['releases', release.id], - ['artists', release.artist.id], - ['artists', artist.id], + expect(send).toHaveBeenCalledWith("mutate", [ + ["releases", release.id], + ["artists", artists[0].id], + ["artists", artists[1].id], ]); }); }); -describe('removeAdditionalArtist function', () => { - it('removes the given additional artist from the given release', async () => { - const artist = getFakeArtist(2); - const release = { ...getFakeRelease(1), additionalArtists: [artist] }; - - prisma.release.update.mockResolvedValue(({ - ...release, - additionalArtists: release.additionalArtists.filter(x => x.id !== artist.id) - })); +describe("removeAdditionalArtist function", () => { + it("removes the given additional artist from the given release", async () => { + const release = getFakeReleasesForArtist(1).at(0); + const artists = getFakeArtists({ length: 2 }); + await prisma.artist.createMany({ data: artists }); + await prisma.release.create({ data: release }); const send = vi.fn(); - const { removeAdditionalArtist } = releaseController({ + const { addAdditionalArtist, removeAdditionalArtist } = releaseController({ ...defaultParams, openFolderDialog: vi.fn(), send, state: { - getCurrentArtist: () => null + getCurrentArtist: () => null, } as StateManager, }); + await addAdditionalArtist({ + release_id: release.id, + artist_id: artists[1].id, + }); + const updatedRelease = await removeAdditionalArtist({ release_id: release.id, - artist_id: artist.id, + artist_id: artists[1].id, + }); + + expect(updatedRelease.additionalArtists).not.toContainEqual(artists[1]); + + expect(send).toHaveBeenCalledWith("mutate", [ + ["releases", release.id], + ["artists", artists[0].id], + ["artists", artists[1].id], + ]); + }); +}); + +describe("deleteRelease function", () => { + it("deletes the passed release from library", async () => { + const releases = getFakeReleasesForArtist(1); + const artist = getFakeArtist(1); + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + + const { deleteRelease, groupReleases } = releaseController(defaultParams); + + await groupReleases({ + mainRelease: { + id: 1, + title: "main release title", + }, + discInfo: [ + { id: 1, title: "disc 1", number: 1 }, + { id: 2, title: "disc 2", number: 2 }, + ], + }); + + await deleteRelease(1); + + const updatedReleases = await prisma.release.findMany(); + expect(updatedReleases).toMatchObject(releases.slice(2)); + }); +}); + +describe("deleteReleases function", () => { + it("does nothing is the cancel button is pressed", async () => { + const showMessageBoxSync = vi + .spyOn(dialog, "showMessageBoxSync") + .mockImplementation(() => 1); + + const send = vi.fn(); + const { deleteReleases } = releaseController({ + ...defaultParams, + openFolderDialog: vi.fn(), + send, + }); + + await deleteReleases([4, 5]); + expect(send).not.toHaveBeenCalled(); + showMessageBoxSync.mockRestore(); + }); + + it("deletes the passed releases from library", async () => { + const releases = getFakeReleasesForArtist(1); + const artist = getFakeArtist(1); + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + const send = vi.fn(); + + const { deleteReleases } = releaseController({ + ...defaultParams, + openFolderDialog: vi.fn(), + send, }); - expect(updatedRelease.additionalArtists).not.toContainEqual(artist); + await deleteReleases([4, 5]); + + const updatedReleases = await prisma.release.findMany(); + const updatedArtist = await prisma.artist.findFirst({ + where: { id: 1 }, + include: { releases: true }, + }); - expect(send).toHaveBeenCalledWith('mutate', [ - ['releases', release.id], - ['artists', release.artist.id], - ['artists', artist.id], + expect(updatedReleases).toMatchObject(releases.slice(0, 3)); + expect(updatedArtist.releases).toMatchObject(releases.slice(0, 3)); + + expect(send).toHaveBeenCalledWith("mutate", [ + ["releases", "latest"], + ["releases", 4], + ["releases", 5], ]); + expect(send).toHaveBeenCalledWith("clearSelection"); + }); +}); + +describe("groupReleases function", () => { + it("groups the passed releases together", async () => { + const releases = getFakeReleasesForArtist(1); + const artist = getFakeArtist(1); + await prisma.artist.create({ data: artist }); + await prisma.release.createMany({ data: releases }); + + const { groupReleases } = releaseController(defaultParams); + + await groupReleases({ + mainRelease: { + id: 1, + title: "main release title", + }, + discInfo: [ + { id: 1, title: "disc 1", number: 1 }, + { id: 2, title: "disc 2", number: 2 }, + ], + }); + + const updatedRelease = await prisma.release.findFirst({ + where: { id: 1 }, + include: { + subReleases: { + include: { + mainRelease: true, + }, + }, + }, + }); + + expect(updatedRelease).toMatchObject({ + ...releases[0], + title: "main release title", + normalizedTitle: "main release title", + discTitle: "disc 1", + subReleases: [ + { + ...releases[1], + title: "main release title", + normalizedTitle: "main release title", + mainReleaseId: 1, + discNumber: 2, + discTitle: "disc 2", + }, + ], + }); + + expect(updatedRelease.subReleases[0].mainRelease).toMatchObject({ + ...releases[0], + title: "main release title", + normalizedTitle: "main release title", + discTitle: "disc 1", + }); }); -}); \ No newline at end of file +}); diff --git a/src/main/controllers/release.ts b/src/main/controllers/release.ts index 7deac71a..ace80ef2 100644 --- a/src/main/controllers/release.ts +++ b/src/main/controllers/release.ts @@ -216,7 +216,7 @@ export function releaseController({ return null; } const LIBRARY_PATH = getSetting("LIBRARY_PATH") as string; - const releaseData = parsePath(folder.replace(LIBRARY_PATH, "")); + const releaseData = parsePath(folder.split(LIBRARY_PATH).at(1)); if (!releaseData) { return null; @@ -280,6 +280,7 @@ export function releaseController({ { ...release, artist }, getSetting("LIBRARY_PATH") as string ); + const fullRelease = await addTracksToRelease(release.id, trackInfo); log("release:importSingleFolder", "upserted release:", fullRelease); @@ -370,7 +371,7 @@ export function releaseController({ await importCovers(releasesWithoutCover); } - async function deleteCover(release: Release) { + async function deleteCover(release: Pick) { const cover = withPath("COVERS_PATH", `${release.hash}-cover.jpg`); await unlink(cover); send("coverUpdate", [release]); @@ -411,6 +412,7 @@ export function releaseController({ const releasesToRefresh = state .getCurrentArtist() ?.releases.filter((x: ReleaseWithArtistAndTracks) => !x.tracks.length); + if (!releasesToRefresh.length) { return; } diff --git a/src/main/controllers/searchResult.test.ts b/src/main/controllers/searchResult.test.ts index c44e5899..d0d1747b 100644 --- a/src/main/controllers/searchResult.test.ts +++ b/src/main/controllers/searchResult.test.ts @@ -1,44 +1,102 @@ -import { getFakeArtist, getFakeRelease } from "@/test/utils"; +import prisma from "../db/prisma"; +import { clearPrisma } from "@/test/prisma-utils"; +import { + getFakeArtist, + getFakeCollections, + getFakeGroups, + getFakeReleasesForArtist, + getFakeTracksForRelease, +} from "../../test/seed"; import { searchResultController } from "./searchResult"; -import prisma from '../db/__mocks__/prisma'; -import { lowerCaseCompare } from "@/lib/utils"; - -vi.mock('../db/prisma'); - -describe('searchResult - search function', () => { - const artists = [ - getFakeArtist(1, undefined, { name: 'The Lovers', releases: [] }), - getFakeArtist(2, undefined, { name: 'The Haters', releases: [] }) - ]; - const releases = [ - getFakeRelease(1, 1, undefined, { title: 'I Love You', artist: artists[0] }), - getFakeRelease(2, 1, undefined, { title: 'I love you', artist: artists[0] }), - getFakeRelease(3, 2, undefined, { title: 'I Hate You', artist: artists[1] }), - getFakeRelease(4, 1, undefined, { title: 'I Hate You', artist: artists[0] }), - ]; - - it('returns the results for the given query string', async () => { - prisma.release.findMany.mockImplementation(({ where }) => { - return releases.filter( - x => { - return lowerCaseCompare( - x.normalizedTitle, - where.normalizedTitle.contains - ); - } - ); - }) - prisma.artist.findMany.mockResolvedValue([]); - prisma.collection.findMany.mockResolvedValue([]); - prisma.track.findMany.mockResolvedValue([]); - prisma.group.findMany.mockResolvedValue([]); + +afterEach(clearPrisma); + +describe("searchResult - search function", () => { + it("returns the results for the given query string", async () => { + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.createMany({ data: getFakeReleasesForArtist(1, 2) }); const { getSearchResults } = searchResultController(); - const results = await getSearchResults({ query: 'love' }); + const results = await getSearchResults({ query: "Release" }); + + expect(results).toMatchObject([ + { + id: 1, + type: "release", + links: { artist: "/artists/1", release: "/releases/1" }, + }, + { + id: 2, + type: "release", + links: { artist: "/artists/1", release: "/releases/2" }, + }, + ]); + }); + it("returns the tracks for the given query string", async () => { + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.createMany({ data: getFakeReleasesForArtist(1, 2) }); + await prisma.track.createMany({ data: getFakeTracksForRelease(1, 3) }); + + const { getSearchResults } = searchResultController(); + const results = await getSearchResults({ query: "Track" }); + + expect(results).toMatchObject([ + { + id: 1, + type: "track", + links: { artist: "/artists/1", track: "/releases/1?track_id=1" }, + }, + { + id: 2, + type: "track", + links: { artist: "/artists/1", track: "/releases/1?track_id=2" }, + }, + { + id: 3, + type: "track", + links: { artist: "/artists/1", track: "/releases/1?track_id=3" }, + }, + ]); + }); + + it("returns the groups for the given query string", async () => { + await prisma.group.createMany({ data: getFakeGroups({ length: 10 }) }); + + const { getSearchResults } = searchResultController(); + const results = await getSearchResults({ query: "Group 1" }); + expect(results).toMatchObject([ + { + id: 1, + type: "group", + links: { group: "/groups/1" }, + }, + { + id: 10, + type: "group", + links: { group: "/groups/10" }, + }, + ]); + }); + + it("returns the collections for the given query string", async () => { + await prisma.collection.createMany({ + data: getFakeCollections({ length: 10 }), + }); + + const { getSearchResults } = searchResultController(); + const results = await getSearchResults({ query: "Collection 1" }); expect(results).toMatchObject([ - { id: 1, type: 'release', links: { artist: '/artists/1', release: '/releases/1' } }, - { id: 2, type: 'release', links: { artist: '/artists/1', release: '/releases/2' } }, + { + id: 1, + type: "collection", + links: { collection: "/collections/1" }, + }, + { + id: 10, + type: "collection", + links: { collection: "/collections/10" }, + }, ]); }); -}); \ No newline at end of file +}); diff --git a/src/main/controllers/stats.test.ts b/src/main/controllers/stats.test.ts index eb54f398..d7f60ee4 100644 --- a/src/main/controllers/stats.test.ts +++ b/src/main/controllers/stats.test.ts @@ -1,49 +1,39 @@ -import { getFakeArtist, getFakeRelease, getFakeTrack } from "@/test/utils"; +import prisma from "../db/prisma"; import { statsController } from "./stats"; -import { Collection, Group, SearchableEntities } from "@/types/types"; -import prisma from '../db/__mocks__/prisma'; +import { clearPrisma } from "../../test/prisma-utils"; +import { + getFakeArtists, + getFakeReleasesForArtist, + getFakeTracksForRelease, + getFakeCollections, + getFakeGroups, +} from "../../test/seed"; -vi.mock('../db/prisma'); +afterEach(clearPrisma); -describe('statsController - getStats function', () => { - const artists = [ - getFakeArtist(1, undefined, { name: 'The Lovers', releases: [] }), - getFakeArtist(2, undefined, { name: 'The Haters', releases: [] }) - ]; +describe("statsController - getStats function", () => { + it("returns the library stats", async () => { + const artists = getFakeArtists({ length: 2 }); + const releases = artists.flatMap((x) => getFakeReleasesForArtist(x.id, 3)); + const tracks = releases.flatMap((x) => getFakeTracksForRelease(x.id, 5)); + const groups = getFakeGroups({ length: 3 }); + const collections = getFakeCollections({ length: 3 }); - const data = { - artist: artists, - release: [ - getFakeRelease(1, 1, undefined, { title: 'I Love You', artist: artists[0] }), - getFakeRelease(2, 1, undefined, { title: 'I love you', artist: artists[0] }), - getFakeRelease(3, 2, undefined, { title: 'I Hate You', artist: artists[1] }), - getFakeRelease(4, 1, undefined, { title: 'I Hate You', artist: artists[0] }), - ], - track: Array.from({ length: 5 }, (_, i) => getFakeTrack(i, i + 1, i + 1)), - group: [] as Group[], - collection: [] as Collection[], - }; - - it('returns the library stats', async () => { - Object.entries(data).forEach(([key, value]) => { - prisma[key as SearchableEntities].aggregate.mockResolvedValue({ - _count: { id: value.length }, - _avg: null, - _sum: null, - _min: null, - _max: null, - }) - }); + await prisma.artist.createMany({ data: artists }); + await prisma.release.createMany({ data: releases }); + await prisma.track.createMany({ data: tracks }); + await prisma.group.createMany({ data: groups }); + await prisma.collection.createMany({ data: collections }); const { getStats } = statsController(); const results = await getStats(); expect(results).toMatchObject({ artist: 2, - release: 4, - track: 5, - group: 0, - collection: 0 + release: 6, + track: 30, + group: 3, + collection: 3, }); }); -}); \ No newline at end of file +}); diff --git a/src/main/controllers/system.test.ts b/src/main/controllers/system.test.ts index 0ecaf16f..937f0bda 100644 --- a/src/main/controllers/system.test.ts +++ b/src/main/controllers/system.test.ts @@ -1,175 +1,185 @@ -import { getFakeRelease, getFakeArtist, getSetting, withPath } from "@/test/utils"; -import { shell, type IpcMainEvent } from 'electron'; -import prisma from '../db/__mocks__/prisma'; -import * as run from '../run'; +import prisma from "../db/prisma"; +import { clearPrisma } from "@/test/prisma-utils"; +import { getSetting, withPath } from "@/test/utils"; +import { + getFakeArtist, + getFakeReleasesForArtist, + getFakeTracksForRelease, +} from "../../test/seed"; +import { shell, type IpcMainEvent } from "electron"; +import * as run from "../run"; import { systemController } from "./system"; -import type { ReleaseWithArtistAndSubreleases, TrackWithRelease } from "@/types/types"; -vi.mock('../db/prisma'); -vi.mock('../run'); +afterEach(clearPrisma); -describe('system - playback function', () => { - it('does nothing is no release if found', async () => { +vi.mock("../run"); + +describe("system - playback function", () => { + it("does nothing is no release if found", async () => { const { playback } = systemController({ getSetting, withPath }); - prisma.release.findFirst.mockResolvedValue(null); const result = await playback({ release_id: 1 }); expect(result).toBeFalsy(); }); - it('does nothing is no track if found', async () => { + it("does nothing is no track if found", async () => { + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + const { playback } = systemController({ getSetting, withPath }); - prisma.track.findFirst.mockResolvedValue(null); const result = await playback({ release_id: 1, track_id: 1 }); expect(result).toBeFalsy(); }); - it('calls run with the right path if the release is found', async () => { + it("calls run with the right path if the release is found", async () => { + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + const { playback } = systemController({ getSetting, withPath }); - const spy = vi.spyOn(run, 'run'); - prisma.release.findFirst.mockResolvedValue(getFakeRelease(1)); + const spy = vi.spyOn(run, "run"); const result = await playback({ release_id: 1 }); + expect(result).toBeTruthy(); - expect(spy).toHaveBeenCalledWith( - 'open', ['-a', 'PLAYER_PATH', 'LIBRARY_PATH/A/Artist/[Album]/1999 - Album One'] - ); + expect(spy).toHaveBeenCalledWith("open", [ + "-a", + "PLAYER_PATH", + "LIBRARY_PATH/A/Artist 1/[Album]/2000 - Release 1", + ]); }); - it('calls run with the right paths if the release has subReleases', async () => { + it("calls run with the right paths if the release has subReleases", async () => { + const releases = getFakeReleasesForArtist(1, 2); + + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.createMany({ data: releases }); + await prisma.track.createMany({ data: getFakeTracksForRelease(1) }); + await prisma.release.update({ + where: { id: 2 }, + data: { mainReleaseId: 1 }, + }); + const { playback } = systemController({ getSetting, withPath }); - const spy = vi.spyOn(run, 'run'); - prisma.release.findFirst.mockResolvedValue({ - ...getFakeRelease(1), - subReleases: [ - getFakeRelease(2) - ] - } as ReleaseWithArtistAndSubreleases); + const spy = vi.spyOn(run, "run"); + const result = await playback({ release_id: 1 }); expect(result).toBeTruthy(); - expect(spy).toHaveBeenCalledWith( - 'open', [ - '-a', - 'PLAYER_PATH', - 'LIBRARY_PATH/A/Artist/[Album]/1999 - Album One', - 'LIBRARY_PATH/A/Artist/[Album]/2000 - Album Two' + expect(spy).toHaveBeenCalledWith("open", [ + "-a", + "PLAYER_PATH", + "LIBRARY_PATH/A/Artist 1/[Album]/2000 - Release 1", + "LIBRARY_PATH/A/Artist 1/[Album]/2000 - Release 2", ]); }); - it('calls run with the right path if the track is found', async () => { + it("calls run with the right path if the track is found", async () => { + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + await prisma.track.createMany({ data: getFakeTracksForRelease(1) }); + const { playback } = systemController({ getSetting, withPath }); - const spy = vi.spyOn(run, 'run'); - prisma.release.findFirst.mockResolvedValue(getFakeRelease(1)); - prisma.track.findFirst.mockResolvedValue({ - id: 11, - path: '01 - title.mp3', - releaseId: 1, - release: getFakeRelease(1) - } as TrackWithRelease); + const spy = vi.spyOn(run, "run"); const result = await playback({ release_id: 1, track_id: 1 }); expect(result).toBeTruthy(); - expect(spy).toHaveBeenCalledWith( - 'open', ['-a', 'PLAYER_PATH', 'LIBRARY_PATH/A/Artist/[Album]/1999 - Album One/01 - title.mp3'] - ); + expect(spy).toHaveBeenCalledWith("open", [ + "-a", + "PLAYER_PATH", + "LIBRARY_PATH/A/Artist 1/[Album]/2000 - Release 1/01 - Track 1.mp3", + ]); }); }); -it('calls run with the right path if the track is found inside multiple releases', async () => { - const { playback } = systemController({ getSetting, withPath }); - const spy = vi.spyOn(run, 'run'); - const release = getFakeRelease(1); - prisma.release.findFirst.mockResolvedValue(release); - prisma.track.findFirst.mockResolvedValue({ - id: 11, - path: '01 - title.mp3', - releaseId: 1, - release - } as TrackWithRelease); - const result = await playback({ release_id: 1, track_id: 1 }); - expect(result).toBeTruthy(); - expect(spy).toHaveBeenCalledWith( - 'open', [ - '-a', - 'PLAYER_PATH', - 'LIBRARY_PATH/A/Artist/[Album]/1999 - Album One/01 - title.mp3' - ]); -}); - -describe('system - openTagger function', () => { - it('does nothing is no release if found', async () => { +describe("system - openTagger function", () => { + it("does nothing is no release if found", async () => { const { openTagger } = systemController({ getSetting, withPath }); - prisma.release.findFirst.mockResolvedValue(null); const result = await openTagger(1); expect(result).toBeFalsy(); }); - it('calls run with the right path if the release if found', async () => { + it("calls run with the right path if the release if found", async () => { + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + const { openTagger } = systemController({ getSetting, withPath }); - const spy = vi.spyOn(run, 'run'); - prisma.release.findFirst.mockResolvedValue(getFakeRelease(1)); + const spy = vi.spyOn(run, "run"); const result = await openTagger(1); expect(result).toBeTruthy(); - expect(spy).toHaveBeenCalledWith( - 'open', ['-a', 'TAGGER_PATH', 'LIBRARY_PATH/A/Artist/[Album]/1999 - Album One'] - ); + expect(spy).toHaveBeenCalledWith("open", [ + "-a", + "TAGGER_PATH", + "LIBRARY_PATH/A/Artist 1/[Album]/2000 - Release 1", + ]); }); }); -describe('system revealEntityInFinder function', () => { - it('does nothing if no release is found', async () => { +describe("system revealEntityInFinder function", () => { + it("does nothing if no release is found", async () => { const { revealEntityInFinder } = systemController({ getSetting, withPath }); - prisma.release.findFirst.mockResolvedValue(null); - const result = await revealEntityInFinder('release', 1); + const result = await revealEntityInFinder("release", 1); expect(result).toBeFalsy(); }); - it('opens the folder in finder if release is found', async () => { + it("opens the folder in finder if release is found", async () => { + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + const { revealEntityInFinder } = systemController({ getSetting, withPath }); - const spy = vi.spyOn(shell, 'openPath'); - prisma.release.findFirst.mockResolvedValue(getFakeRelease(1)); - const result = await revealEntityInFinder('release', 1); + const spy = vi.spyOn(shell, "openPath"); + + const result = await revealEntityInFinder("release", 1); expect(result).toBeTruthy(); expect(spy).toHaveBeenCalledWith( - 'LIBRARY_PATH/A/Artist/[Album]/1999 - Album One' + "LIBRARY_PATH/A/Artist 1/[Album]/2000 - Release 1" ); }); - it('opens the folder in finder if an artist is found', async () => { + it("opens the folder in finder if an artist is found", async () => { + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + const { revealEntityInFinder } = systemController({ getSetting, withPath }); - const spy = vi.spyOn(shell, 'openPath'); - prisma.artist.findFirst.mockResolvedValue(getFakeArtist(1)); - const result = await revealEntityInFinder('artist', 1); + const spy = vi.spyOn(shell, "openPath"); + + const result = await revealEntityInFinder("artist", 1); expect(result).toBeTruthy(); - expect(spy).toHaveBeenCalledWith( - 'LIBRARY_PATH/A/Artist' - ); + expect(spy).toHaveBeenCalledWith("LIBRARY_PATH/A/Artist 1"); }); }); -describe('system - startDrag, function', () => { - it('should do nothing is the release is not found', async () => { - prisma.release.findFirst.mockResolvedValue(null); +describe("system - startDrag, function", () => { + it("should do nothing is the release is not found", async () => { const { startDrag } = systemController({ getSetting, withPath }); const event = { sender: { - startDrag: vi.fn() - } + startDrag: vi.fn(), + }, } as unknown as IpcMainEvent; - const spy = vi.spyOn(event.sender, 'startDrag') + const spy = vi.spyOn(event.sender, "startDrag"); await startDrag(1, event); expect(spy).not.toHaveBeenCalled(); }); - it('should pass the dragged folder to the drag event', async () => { - prisma.release.findFirst.mockResolvedValue(getFakeRelease(1)); + it("should pass the dragged folder to the drag event", async () => { + const release = getFakeReleasesForArtist(1).at(0); + await prisma.artist.create({ data: getFakeArtist(1) }); + await prisma.release.create({ data: release }); + const { startDrag } = systemController({ getSetting, withPath }); const event = { sender: { - startDrag: vi.fn() - } + startDrag: vi.fn(), + }, } as unknown as IpcMainEvent; - const spy = vi.spyOn(event.sender, 'startDrag') + const spy = vi.spyOn(event.sender, "startDrag"); await startDrag(1, event); - expect(spy).toHaveBeenCalledWith(expect.objectContaining({ - file: 'LIBRARY_PATH/A/Artist/[Album]/1999 - Album One' - })); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + file: "LIBRARY_PATH/A/Artist 1/[Album]/2000 - Release 1", + }) + ); }); -}); \ No newline at end of file +}); diff --git a/src/main/covers/__mocks__/index.ts b/src/main/covers/__mocks__/index.ts index 49ea63ce..5fd7ebe6 100644 --- a/src/main/covers/__mocks__/index.ts +++ b/src/main/covers/__mocks__/index.ts @@ -1,27 +1,29 @@ -import { beforeEach } from 'vitest'; -import { mockReset } from 'vitest-mock-extended'; -import { type Release } from '@/types/types'; -import { type GetImageFromURLParams } from '..'; -import path from 'path'; -import { outputFile } from 'fs-extra'; +import { beforeEach } from "vitest"; +import { mockReset } from "vitest-mock-extended"; +import { type Release } from "@/types/types"; +import { type GetImageFromURLParams } from ".."; +import path from "path"; +import { outputFile } from "fs-extra"; beforeEach(() => { mockReset(searchCover); mockReset(getImageFromURL); }); -export const getImageFromURL = vi.fn(async ({ outputPath, hash, url }: GetImageFromURLParams) => { - if (url.includes('not-found')) { - return false; +export const getImageFromURL = vi.fn( + async ({ outputPath, hash, url }: GetImageFromURLParams) => { + if (url.includes("not-found")) { + return false; + } + const fullOutputPath = path.join(outputPath, `${hash}-cover.jpg`); + await outputFile(fullOutputPath, "", "utf-8"); + return fullOutputPath; } - const fullOutputPath = path.join(outputPath, `${hash}-cover.jpg`); - await outputFile(fullOutputPath, '', 'utf-8'); - return fullOutputPath; -}); +); export const searchCover = vi.fn(async ({ release }: { release: Release }) => { if (release.id === 3) { return null; } return `${release.hash}-cover.jpg`; -}); \ No newline at end of file +}); diff --git a/src/main/covers/deezer.ts b/src/main/covers/deezer.ts index 4797ada8..3f08383f 100644 --- a/src/main/covers/deezer.ts +++ b/src/main/covers/deezer.ts @@ -1,75 +1,84 @@ -import type { Release, Artist, Track } from '@/types/types'; +import type { Release, Artist, Track } from "@/types/types"; import { log } from "../logger"; -import { lowerCaseCompare, normalizeDiacritics } from '@/lib/utils'; -import { version } from '../../../package.json'; -import { normalizeArtist, normalizeTitle } from '.'; +import { lowerCaseCompare, normalizeDiacritics } from "@/lib/utils"; +import { version } from "../../../package.json"; +import { normalizeArtist, normalizeTitle } from "."; type SearchParams = { - artist: string; - title: string; - track?: string; + artist: string; + title: string; + track?: string; }; type DeezerResponse = { - data: { - album: { - title: string; - cover_xl: string; - } - }[] + data: { + album: { + title: string; + cover_xl: string; + }; + }[]; }; function getDeezerQueryParam(params: SearchParams): string { - return Object.entries(params).reduce((memo, [key, value]) => ( - value ? - `${memo}${key}:"${normalizeDiacritics(value)}" ` - : memo - ), '').trim(); + return Object.entries(params) + .reduce( + (memo, [key, value]) => + value ? `${memo}${key}:"${normalizeDiacritics(value)}" ` : memo, + "" + ) + .trim(); } export async function search(params: SearchParams): Promise { - const queryParams = new URLSearchParams({ - strict: 'on', - order: 'ALBUM_ASC', - q: getDeezerQueryParam(params) - }); - const url = `https://api.deezer.com/search?${queryParams}`; - log('covers:deezer:search', url); - const response = await fetch( - url, - { headers: { "User-Agent": `playa/${version}` } } - ); - const data = await response.json(); - return data; + const queryParams = new URLSearchParams({ + strict: "on", + order: "ALBUM_ASC", + q: getDeezerQueryParam(params), + }); + const url = `https://api.deezer.com/search?${queryParams}`; + log("covers:deezer:search", url); + const response = await fetch(url, { + headers: { "User-Agent": `playa/${version}` }, + }); + const data = await response.json(); + return data; } type SearchCoverParams = { - release: Release; - artist: Artist; - track?: Track; + release: Pick; + artist: Pick; + track?: Pick; }; -export async function searchCover({ release, artist, track }: SearchCoverParams) { - const title = normalizeTitle(release.title); - const artistName = normalizeArtist(artist.name); - const response = await search({ - artist: artistName, - title, - track: track?.title - }); - if (!response?.data.length) { - log(`covers:deezer:searchCover', 'No response for: ${artistName} - ${title}`); - return null; - } - const result = response.data.find(x => lowerCaseCompare( - normalizeDiacritics(x.album.title), - normalizeDiacritics(title)) +export async function searchCover({ + release, + artist, + track, +}: SearchCoverParams) { + const title = normalizeTitle(release.title); + const artistName = normalizeArtist(artist.name); + const response = await search({ + artist: artistName, + title, + track: track?.title, + }); + if (!response?.data.length) { + log( + `covers:deezer:searchCover', 'No response for: ${artistName} - ${title}` ); + return null; + } + const result = response.data.find((x) => + lowerCaseCompare( + normalizeDiacritics(x.album.title), + normalizeDiacritics(title) + ) + ); - log('covers:deezer:searchCover', 'Searching:', artistName, title); - if (!result) { - log('covers:deezer:searchCover', 'No response for:', artistName, title); - return null; - } - return result.album.cover_xl; + log("covers:deezer:searchCover", "Searching:", artistName, title); + if (!result) { + log("covers:deezer:searchCover", "No response for:", artistName, title); + return null; + } + return result.album.cover_xl; } diff --git a/src/main/covers/discogs.ts b/src/main/covers/discogs.ts index bb1570f9..7e04ddf6 100644 --- a/src/main/covers/discogs.ts +++ b/src/main/covers/discogs.ts @@ -1,61 +1,71 @@ -import type { Release, Artist } from '@/types/types'; +import type { Release, Artist } from "@/types/types"; import { log } from "../logger"; -import { normalizeDiacritics } from '@/lib/utils'; -import { version } from '../../../package.json'; -import { normalizeArtist, normalizeTitle } from '.'; +import { normalizeDiacritics } from "@/lib/utils"; +import { version } from "../../../package.json"; +import { normalizeArtist, normalizeTitle } from "."; type SearchParams = { - artist: string; - title: string; - year?: number; + artist: string; + title: string; + year?: number; }; export type DiscogsSecrets = { - DISCOGS_KEY: string; - DISCOGS_SECRET: string; + DISCOGS_KEY: string; + DISCOGS_SECRET: string; }; -export async function search({ artist, title }: SearchParams, secrets: DiscogsSecrets) { - const params = new URLSearchParams({ - artist: normalizeDiacritics(artist), - title: normalizeDiacritics(title), - key: secrets.DISCOGS_KEY, - secret: secrets.DISCOGS_SECRET, - }); - const url = `https://api.discogs.com/database/search?${params}`; - log('covers:discogs:search', url); - const response = await fetch( - url, - { headers: { "User-Agent": `playa/${version}` } } - ); - const data = await response.json(); - return data; +export async function search( + { artist, title }: SearchParams, + secrets: DiscogsSecrets +) { + const params = new URLSearchParams({ + artist: normalizeDiacritics(artist), + title: normalizeDiacritics(title), + key: secrets.DISCOGS_KEY, + secret: secrets.DISCOGS_SECRET, + }); + const url = `https://api.discogs.com/database/search?${params}`; + log("covers:discogs:search", url); + const response = await fetch(url, { + headers: { "User-Agent": `playa/${version}` }, + }); + const data = await response.json(); + return data; } type SearchCoverParams = { - release: Release; - artist: Artist; + release: Pick; + artist: Pick; }; export async function searchCover( - { release, artist }: SearchCoverParams, - secrets: DiscogsSecrets + { release, artist }: SearchCoverParams, + secrets: DiscogsSecrets ) { - const title = normalizeTitle(release.title); - const artistName = normalizeArtist(artist.name); - const response = await search({ - artist: artistName, - title, - }, secrets); - if (!response?.results?.length) { - log('covers:discogs:searchCover', `No response for: ${artistName} - ${title}`); - return null; - } - const { cover_image } = response.results[0]; - log('covers:discogs:searchCover', 'Downloading:', artistName, title); - if (!cover_image || cover_image.endsWith('spacer.gif')) { - log('covers:discogs:searchCover', 'No response for:', artistName, title); - return null; - } - return cover_image; + const title = normalizeTitle(release.title); + const artistName = normalizeArtist(artist.name); + const response = await search( + { + artist: artistName, + title, + }, + secrets + ); + + if (!response?.results?.length) { + log( + "covers:discogs:searchCover", + `No response for: ${artistName} - ${title}` + ); + return null; + } + + const { cover_image } = response.results[0]; + log("covers:discogs:searchCover", "Downloading:", artistName, title); + if (!cover_image || cover_image.endsWith("spacer.gif")) { + log("covers:discogs:searchCover", "No response for:", artistName, title); + return null; + } + return cover_image; } diff --git a/src/main/covers/index.test.ts b/src/main/covers/index.test.ts new file mode 100644 index 00000000..90e8df43 --- /dev/null +++ b/src/main/covers/index.test.ts @@ -0,0 +1,95 @@ +import { getFakeArtist, getFakeReleasesForArtist } from "@/test/seed"; +import path from "node:path"; +import { searchCover, normalizeTitle, normalizeArtist } from "."; + +const spyFetch = vi.spyOn(globalThis, "fetch"); + +beforeEach(() => { + spyFetch.mockReset(); +}); + +afterAll(() => { + spyFetch.mockRestore(); +}); + +vi.mock("image-downloader", () => ({ + default: { + image: ({ dest }: { dest: string }) => { + if (dest.includes("ee1478c38c24f36e")) { + return dest; + } + throw new Error(); + }, + }, +})); + +const discogsSecrets = { + DISCOGS_KEY: "DISCOGS_KEY", + DISCOGS_SECRET: "DISCOGS_SECRET", +}; + +const outputPath = "path/to/output"; + +describe("searchCover function", async () => { + const artist = getFakeArtist(1); + const releases = getFakeReleasesForArtist(1, 3); + + it("searches for release cover", async () => { + spyFetch.mockImplementation(async (url: string) => { + if (url.includes("deezer")) { + return new Response(JSON.stringify({ data: [] })); + } + return new Response( + JSON.stringify({ + results: [ + { + cover_image: "https://path/to/image.jpg", + }, + ], + }) + ); + }); + + const result = await searchCover( + { release: releases[0], artist, outputPath }, + discogsSecrets + ); + + expect(result).toBe(path.join(outputPath, `${releases[0].hash}-cover.jpg`)); + }); + + it("returns false if no results are found", async () => { + const result = await searchCover( + { release: releases[2], artist, outputPath }, + discogsSecrets + ); + + expect(result).toBe(false); + }); + + it("returns false if download fails", async () => { + const result = await searchCover( + { release: releases[1], artist, outputPath }, + discogsSecrets + ); + + expect(result).toBe(false); + }); +}); + +describe("normalizeTitle function", () => { + it("replaces special characters", () => { + expect(normalizeTitle("Black : White! (w: Artist 2) CD1")).toBe( + "Black / White" + ); + }); +}); + +describe("normalizeArtist function", () => { + it("replaces special characters", () => { + expect(normalizeArtist("Malaria!")).toBe("Malaria"); + }); + it("replaces the various artist name", () => { + expect(normalizeArtist("_VV_AA_")).toBe("Various"); + }); +}); diff --git a/src/main/covers/index.ts b/src/main/covers/index.ts index 3b72d68d..c4e06709 100644 --- a/src/main/covers/index.ts +++ b/src/main/covers/index.ts @@ -1,10 +1,10 @@ -import path from 'path'; +import path from "path"; import download from "image-downloader"; import { log } from "../logger"; -import type { Release, Artist, Track } from '@/types/types'; -import { searchCover as deezerSearch } from './deezer'; -import { searchCover as discogsSearch, type DiscogsSecrets } from './discogs'; -import { VARIOUS_ARTISTS_NAME } from '@/lib/utils'; +import type { Release, Artist, Track } from "@/types/types"; +import { searchCover as deezerSearch } from "./deezer"; +import { searchCover as discogsSearch, type DiscogsSecrets } from "./discogs"; +import { VARIOUS_ARTISTS_NAME } from "@/lib/utils"; export type GetImageFromURLParams = { outputPath: string; @@ -12,7 +12,11 @@ export type GetImageFromURLParams = { url: string; }; -export async function getImageFromURL({ outputPath, hash, url }: GetImageFromURLParams) { +export async function getImageFromURL({ + outputPath, + hash, + url, +}: GetImageFromURLParams) { const dest = path.join(outputPath, `${hash}-cover.jpg`); await download.image({ url, dest }); return dest; @@ -35,9 +39,9 @@ export function normalizeArtist(artist: string) { } type SearchCoverParams = { - release: Release; - artist: Artist; - track?: Track; + release: Pick; + artist: Pick; + track?: Pick; outputPath: string; }; @@ -47,23 +51,23 @@ export async function searchCover( ) { const [deezerResult, discogsResult] = await Promise.all([ deezerSearch({ release, artist, track }), - discogsSearch({ release, artist }, secrets) + discogsSearch({ release, artist }, secrets), ]); if (!deezerResult && !discogsResult) { - log('covers:search', `No results for ${artist.name} - ${release.title}`); + log("covers:search", `No results for ${artist.name} - ${release.title}`); return false; } try { - log('covers:search', `Downloading ${deezerResult || discogsResult}`); + log("covers:search", `Downloading ${deezerResult || discogsResult}`); return await getImageFromURL({ outputPath, hash: release.hash, - url: deezerResult || discogsResult + url: deezerResult || discogsResult, }); } catch (error) { - log('covers:search', error); + log("covers:search", error); return false; } -} \ No newline at end of file +} diff --git a/src/main/db/__mocks__/prisma.ts b/src/main/db/__mocks__/prisma.ts deleted file mode 100644 index 21a2208f..00000000 --- a/src/main/db/__mocks__/prisma.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PrismaClient } from '@prisma/client-generated'; -import { beforeEach } from 'vitest'; -import { mockDeep, mockReset, DeepMockProxy } from 'vitest-mock-extended'; - -beforeEach(() => { - mockReset(prisma); -}); - -const prisma = mockDeep() as unknown as DeepMockProxy<{ - // this is needed to resolve the issue with circular types definition - // https://github.com/prisma/prisma/issues/10203 - [K in keyof PrismaClient]: Omit; -}>; -export default prisma; \ No newline at end of file diff --git a/src/main/db/artist.ts b/src/main/db/artist.ts index f0394d15..5bb909c3 100644 --- a/src/main/db/artist.ts +++ b/src/main/db/artist.ts @@ -7,11 +7,11 @@ import { } from "@/lib/utils"; import { withEntityType } from "@/types/types"; import type { - Artist, ArtistWithReleases, ArtistWithReleasesFull, PaginationParams, ArtistUpdate, + ReleaseWithArtist, } from "@/types/types"; export async function getArtist(id: number): Promise { @@ -95,20 +95,22 @@ export async function getArtist(id: number): Promise { return null; } - const { releases, appearsIn } = result as ArtistWithReleasesFull; + const { releases, appearsIn } = result; return withEntityType( { ...result, releases: withEntityType( sortReleasesByTypeAndYear([...releases, ...appearsIn]), "release" - ).map((x) => ({ + ).map((x: ReleaseWithArtist) => ({ ...x, artist: withEntityType(x.artist, "artist"), additionalArtists: withEntityType(x.additionalArtists, "artist"), })), relatedArtists: withEntityType( - result.relatedArtists.map(withCoverRelease), + result.relatedArtists.map((x) => + withCoverRelease(x as ArtistWithReleases) + ), "artist" ), groups: withEntityType(result.groups, "group"), @@ -117,48 +119,6 @@ export async function getArtist(id: number): Promise { ); } -export type GetArtistsParams = PaginationParams & { - startsWith?: string; -}; - -export async function getArtists({ - take = 50, - skip = 0, - startsWith, -}: GetArtistsParams) { - const where = - startsWith === "symbol" - ? { - path: { - startsWith: "0-9_", - }, - } - : { - name: { - startsWith, - }, - }; - - const [results, total] = await prisma.$transaction([ - prisma.artist.findMany({ - take, - skip, - where, - orderBy: { name: "asc" }, - include: { releases: true }, - }), - prisma.artist.count({ where }), - ]); - return { - pagination: { - take, - skip, - total, - }, - results: results.map(withReleaseCount), - }; -} - export async function getLatestArtists({ take = 50, skip = 0, @@ -203,7 +163,7 @@ export async function getLatestArtists({ }; } -export async function getAllArtists(): Promise { +export async function getAllArtists() { const result = await prisma.artist.findMany({ orderBy: { name: "asc" }, select: { @@ -225,6 +185,9 @@ export async function updateArtist(id: number, { name, path }: ArtistUpdate) { const result = await prisma.artist.update({ where: { id }, data: { name, normalizedName: normalizeDiacritics(name), path }, + include: { + relatedArtists: true, + }, }); return result ? withEntityType(result, "artist") : null; } @@ -275,7 +238,7 @@ export async function searchArtists({ query, exclude, take = 50, -}: SearchArtistsParams): Promise { +}: SearchArtistsParams) { const result = await prisma.artist.findMany({ take, where: { diff --git a/src/main/db/collection.ts b/src/main/db/collection.ts index a71c5e75..4eb6ea79 100644 --- a/src/main/db/collection.ts +++ b/src/main/db/collection.ts @@ -3,11 +3,8 @@ import { withEntityType } from "@/types/types"; import type { CollectionCreate, CollectionUpdate, - CollectionWithReleases, HasId, PaginationParams, - Release, - ReleaseWithArtist, } from "@/types/types"; export async function getCollections({ take = 50 }: PaginationParams) { @@ -71,7 +68,10 @@ function getSort({ sortBy, order }: SortParams) { }; } -export async function getCollection(id: number, sort = defaultSort) { +export async function getCollection( + id: number, + sort: SortParams = defaultSort +) { const result = await prisma.collection.findFirst({ where: { id }, include: { @@ -99,7 +99,7 @@ export async function getCollection(id: number, sort = defaultSort) { { ...result, releases: withEntityType( - result.releases.map((x: ReleaseWithArtist) => ({ + result.releases.map((x) => ({ ...x, artist: withEntityType(x.artist, "artist"), additionalArtists: withEntityType(x.additionalArtists, "artist"), @@ -153,11 +153,16 @@ export async function updateCollection( disconnect, }, }, + include: { + releases: { + select: { id: true }, + }, + }, }); return withEntityType(result, "collection"); } -export async function addReleasesToCollection(id: number, releases: Release[]) { +export async function addReleasesToCollection(id: number, releases: HasId[]) { const result = await prisma.collection.update({ where: { id, @@ -174,7 +179,7 @@ export async function addReleasesToCollection(id: number, releases: Release[]) { export async function removeReleasesFromCollection( id: number, release_ids: number[] -): Promise { +) { let result = await prisma.collection.update({ where: { id, @@ -198,20 +203,24 @@ export async function removeReleasesFromCollection( data: { coverReleaseId: null, }, + include: { + releases: { + select: { + id: true, + }, + }, + }, }); } return withEntityType(result, "collection"); } export async function deleteCollections(ids: number[]) { - return Promise.all(ids.map(deleteCollection)); + return prisma.collection.deleteMany({ where: { id: { in: ids } } }); } export async function deleteCollection(id: number) { - const result = await prisma.collection.delete({ - where: { id }, - }); - return result; + return prisma.collection.delete({ where: { id } }); } export async function setCollectionCoverRelease( diff --git a/src/main/db/group.ts b/src/main/db/group.ts index d9b17b93..b643d4dd 100644 --- a/src/main/db/group.ts +++ b/src/main/db/group.ts @@ -1,6 +1,11 @@ import prisma from "./prisma"; import { withEntityType } from "@/types/types"; -import type { GroupCreate, GroupUpdate, HasId, PaginationParams, Artist, GroupWithArtists } from '@/types/types'; +import type { + GroupCreate, + GroupUpdate, + HasId, + PaginationParams, +} from "@/types/types"; export async function getGroups({ take = 50 }: PaginationParams) { const results = await prisma.group.findMany({ @@ -11,43 +16,46 @@ export async function getGroups({ take = 50 }: PaginationParams) { include: { coverRelease: { include: { - artist: true - } + artist: true, + }, }, releases: { take: 1, where: { - mainRelease: null + mainRelease: null, }, include: { - artist: true - } - } - } + artist: true, + }, + }, + }, }, artists: { include: { coverRelease: { include: { - artist: true - } + artist: true, + }, }, releases: { take: 1, where: { - mainRelease: null + mainRelease: null, }, include: { - artist: true - } - } + artist: true, + }, + }, }, - } + }, }, }); return withEntityType( - results.map( - (x: GroupWithArtists) => ({ ...x, artists: withEntityType(x.artists, 'artist') })), 'group' + results.map((x) => ({ + ...x, + artists: withEntityType(x.artists, "artist"), + })), + "group" ); } @@ -57,12 +65,12 @@ export async function getAllGroups() { include: { artists: { select: { - id: true - } - } - } + id: true, + }, + }, + }, }); - return withEntityType(result, 'group'); + return withEntityType(result, "group"); } export async function getGroup(id: number) { @@ -70,82 +78,86 @@ export async function getGroup(id: number) { where: { id }, include: { artists: { - orderBy: { name: 'asc' }, + orderBy: { name: "asc" }, include: { coverRelease: { include: { - artist: true - } + artist: true, + }, }, releases: { where: { - mainRelease: null + mainRelease: null, }, include: { - artist: true - } - } + artist: true, + }, + }, }, - } + }, }, }); return result - ? withEntityType({ ...result, artists: withEntityType(result.artists, 'artist') }, 'group') : null; + ? withEntityType( + { ...result, artists: withEntityType(result.artists, "artist") }, + "group" + ) + : null; } export async function createGroup({ title, artists = [] }: GroupCreate) { const result = await prisma.group.create({ data: { title, - artists: { connect: artists.map(id => ({ id })) } - } + artists: { connect: artists.map((id) => ({ id })) }, + }, }); - return withEntityType(result, 'group'); + return withEntityType(result, "group"); } export async function updateGroup(id: number, { title, artists }: GroupUpdate) { const group = await prisma.group.findFirst({ where: { id }, include: { - artists: true + artists: true, }, }); if (!group) { return null; } - const connect = artists.map(id => ({ id })) + const connect = artists.map((id) => ({ id })); const disconnect = group.artists - .filter(({ id }: HasId) => artists.every(x => x != id)) + .filter(({ id }: HasId) => artists.every((x) => x != id)) .map(({ id }: HasId) => ({ id })); const result = await prisma.group.update({ where: { - id + id, }, data: { title, artists: { connect, - disconnect - } - } + disconnect, + }, + }, }); - return withEntityType(result, 'group'); + return withEntityType(result, "group"); } -export async function addArtistsToGroup(id: number, artists: Artist[]) { +export async function addArtistsToGroup(id: number, artists: HasId[]) { const result = await prisma.group.update({ where: { - id + id, }, data: { artists: { - connect: artists.map(({ id }) => ({ id })) - } - } + connect: artists.map(({ id }) => ({ id })), + }, + }, }); - return withEntityType(result, 'group'); + return withEntityType(result, "group"); } export async function removeArtistsFromGroup(id: number, artist_ids: number[]) { @@ -153,45 +165,51 @@ export async function removeArtistsFromGroup(id: number, artist_ids: number[]) { where: { id }, data: { artists: { - disconnect: artist_ids.map(id => ({ id })) - } + disconnect: artist_ids.map((id) => ({ id })), + }, }, include: { artists: { select: { - id: true - } - } - } + id: true, + }, + }, + }, }); if (artist_ids.includes(result.coverArtistId)) { result = await prisma.group.update({ where: { id }, data: { - coverArtistId: null - } + coverArtistId: null, + }, + include: { + artists: { + select: { + id: true, + }, + }, + }, }); } - return withEntityType(result, 'group'); + return withEntityType(result, "group"); } export async function deleteGroups(ids: number[]) { - return Promise.all(ids.map(deleteGroup)); + return prisma.group.deleteMany({ where: { id: { in: ids } } }); } export async function deleteGroup(id: number) { - const result = await prisma.group.delete({ - where: { id } + return prisma.group.delete({ + where: { id }, }); - return result; } export async function setGroupCoverArtist(group_id: number, artist_id: number) { const result = await prisma.group.update({ where: { id: group_id }, - data: { coverArtistId: artist_id } + data: { coverArtistId: artist_id }, }); - return withEntityType(result, 'group'); -} \ No newline at end of file + return withEntityType(result, "group"); +} diff --git a/src/main/db/importExport.ts b/src/main/db/importExport.ts index d7ff574a..dc20d917 100644 --- a/src/main/db/importExport.ts +++ b/src/main/db/importExport.ts @@ -30,15 +30,21 @@ export async function exportData({ dumpTable(table, tempPath) ) ); + await outputJSON(path.join(tempPath, `_info.json`), info, { spaces: 2, }); + + const zipPath = path.join(outputPath, `${exportName}.zip`); + try { - await zip(tempPath, path.join(outputPath, `${exportName}.zip`)); + await zip(tempPath, zipPath); } catch (error) { log("importExport:exportData", "Error zipping", error); } await remove(tempPath); + + return zipPath; } const includeMap: Record = { @@ -259,7 +265,6 @@ export async function importData({ ) ); onProgress("Importing additional relationships", true); - log("importExport:importData", "Done!"); onProgress("done"); } catch (error) { diff --git a/src/main/db/prisma.ts b/src/main/db/prisma.ts index 1ccfc0c9..aef594df 100644 --- a/src/main/db/prisma.ts +++ b/src/main/db/prisma.ts @@ -2,17 +2,23 @@ import { PrismaClient } from "@prisma/client-generated"; import path from "node:path"; -const url = - process.env.NODE_ENV === "development" - ? "file:data.db" - : `file:${path.join(process.resourcesPath, "data.db")}`; +function getUrl() { + const { NODE_ENV } = process.env; + if (NODE_ENV === "test") { + return ""; + } + if (NODE_ENV === "development") { + return "file:data.db"; + } + return `file:${path.join(process.resourcesPath, "data.db")}`; +} function getPrisma() { return typeof window === "undefined" ? new PrismaClient({ datasources: { db: { - url, + url: getUrl(), }, }, }) diff --git a/src/main/db/release.ts b/src/main/db/release.ts index 0441a5b1..71eb73da 100644 --- a/src/main/db/release.ts +++ b/src/main/db/release.ts @@ -8,8 +8,6 @@ import type { PaginationParams, Release, WithSubReleases, - ReleaseWithArtistAndSubreleases, - ReleaseWithArtist, } from "@/types/types"; import { normalizeDiacritics } from "@/lib/utils"; @@ -117,6 +115,7 @@ export async function addTracksToRelease(id: number, trackInfo: TrackInfo[]) { }, }, }); + return withEntityType(result, "release"); } @@ -138,6 +137,7 @@ export async function groupReleases({ where: { id }, data: { title: mainRelease.title, + normalizedTitle: normalizeDiacritics(mainRelease.title), mainReleaseId: mainRelease.id, discTitle: title, discNumber: number, @@ -150,6 +150,7 @@ export async function groupReleases({ }, data: { title: mainRelease.title, + normalizedTitle: normalizeDiacritics(mainRelease.title), discTitle: discInfo[0].title, discNumber: 1, subReleases: { @@ -217,13 +218,13 @@ export async function getReleases({ take = 50, skip = 0 }: PaginationParams) { total, }, results: withEntityType( - results.map((x: ReleaseWithArtist) => ({ + results.map((x) => ({ ...x, artist: withEntityType(x.artist, "artist"), additionalArtists: withEntityType(x.additionalArtists, "artist"), })), "release" - ) as ReleaseWithArtistAndSubreleases[], + ), }; } diff --git a/src/main/db/seed.ts b/src/main/db/seed.ts index aeb20d86..73124b42 100644 --- a/src/main/db/seed.ts +++ b/src/main/db/seed.ts @@ -1,97 +1,3 @@ -import prisma from "./prisma"; -import sha1 from "sha1"; -import { hashArtistName, hashRelease } from "../hash"; +import { seed } from "@/test/seed"; -const artists = Array.from({ length: 10 }, (_, i) => ({ - id: i + 1, - name: `Artist ${i + 1}`, - normalizedName: `Artist ${i + 1}`, - path: `A/Artist ${i + 1}`, - hash: hashArtistName(`Artist ${i + 1}`), -})); - -function getReleasesForArtist(artist_id: number, length = 5) { - return Array.from({ length }, (_, i) => ({ - id: (artist_id - 1) * length + i + 1, - title: `Release ${i + 1}`, - normalizedTitle: `Release ${i + 1}`, - type: "Album", - year: 2000, - path: `[Album]/2000 - Release ${i + 1}`, - hash: hashRelease({ - title: `Release ${i + 1}`, - type: "Album", - year: 2000, - artist_id, - }), - artist_id, - createdAt: `2025-0${i + 1}-0${i + 1}T21:41:31.693Z`, - })); -} - -function getTracksForRelease(releaseId: number, length = 5) { - return Array.from({ length }, (_, i) => ({ - id: (releaseId - 1) * length + i + 1, - title: `Track ${i + 1}`, - normalizedTitle: `Track ${i + 1}`, - path: `0${i + 1} - Track ${i + 1}.mp3`, - hash: sha1( - `${releaseId * length + i + 1}-0${i + 1} - Track ${i + 1}.mp3` - ).slice(0, 16), - duration: 180, - releaseId, - position: i + 1, - })); -} - -const collections = Array.from({ length: 3 }, (_, i) => ({ - id: i + 1, - title: `Collection ${i + 1}`, - releases: { - connect: Array.from({ length: 3 }, (_, j) => ({ id: j + 1 + 3 * i })), - }, -})); - -const groups = Array.from({ length: 3 }, (_, i) => ({ - id: i + 1, - title: `Group ${i + 1}`, - artists: { - connect: [{ id: i + 1 }], - }, -})); - -async function main() { - await Promise.all(artists.map((x) => prisma.artist.create({ data: x }))); - const releases = artists.flatMap((x) => getReleasesForArtist(x.id)); - await Promise.all(groups.map((x) => prisma.group.create({ data: x }))); - await Promise.all(releases.map((x) => prisma.release.create({ data: x }))); - await Promise.all( - releases.flatMap((r) => - getTracksForRelease(r.id).map((x) => prisma.track.create({ data: x })) - ) - ); - - await Promise.all( - collections.map((x) => prisma.collection.create({ data: x })) - ); - - await prisma.artist.update({ - where: { id: 1 }, - data: { - relatedArtists: { - connect: [{ id: 2 }], - }, - }, - }); - - await prisma.release.update({ - where: { id: 1 }, - data: { - additionalArtists: { - connect: [{ id: 2 }], - }, - }, - }); -} - -main(); +seed(); diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts new file mode 100644 index 00000000..1844ca1e --- /dev/null +++ b/src/main/settings.test.ts @@ -0,0 +1,66 @@ +import { initSettings, getSetting, getSettings, setSettings } from "./settings"; + +vi.mock("electron-settings", () => { + let settings: Record = {}; + return { + default: { + setSync: (newSettings: Record) => + (settings = newSettings), + getSync: (key?: string) => (key ? settings[key] : settings), + }, + }; +}); + +beforeEach(() => setSettings({})); + +const defaultSettings = { + PLAYER_PATH: "PLAYER_PATH", + LIBRARY_PATH: "LIBRARY_PATH", +}; + +vi.mock("fs-extra", () => ({ + readJSONSync: () => defaultSettings, +})); + +describe("getSettings", () => { + it("should populate the settings with defaults if nothing is stored", () => { + setSettings({ + EXISTING_KEY: "EXISTING_VALUE", + }); + initSettings(); + expect(getSettings()).toMatchObject({ + EXISTING_KEY: "EXISTING_VALUE", + }); + }); + + it("should recall existing settings if present", () => { + initSettings(); + expect(getSettings()).toMatchObject(defaultSettings); + }); +}); + +describe("getSetting", () => { + it("should return the setting by given key", () => { + initSettings(); + expect(getSetting("PLAYER_PATH")).toBe("PLAYER_PATH"); + }); + + it("should throw if no setting by given key is found", () => { + initSettings(); + expect(() => getSetting("NONEXISTING")).toThrowError( + `Cannot find NONEXISTING in settings` + ); + }); +}); + +describe("setSetting", () => { + it("should store the new settings", () => { + initSettings(); + setSettings({ + NEW_KEY: "NEW_VALUE", + }); + expect(getSettings()).toEqual({ + NEW_KEY: "NEW_VALUE", + }); + }); +}); diff --git a/src/main/settings.ts b/src/main/settings.ts index 7c6699e9..06190fd0 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,15 +1,15 @@ -import settings from 'electron-settings'; -import { isEmpty } from '@/lib/utils'; -import { readJSONSync } from 'fs-extra'; -import { log } from './logger'; +import settings from "electron-settings"; +import { isEmpty } from "@/lib/utils"; +import { readJSONSync } from "fs-extra"; +import { log } from "./logger"; export function initSettings() { let currentSettings = settings.getSync(); if (isEmpty(currentSettings)) { - currentSettings = readJSONSync('../../settings.json'); + currentSettings = readJSONSync("../../settings.json"); settings.setSync(currentSettings); } - log('settings:initSettings', currentSettings); + log("settings:initSettings", currentSettings); } export function getSetting(key: string) { @@ -24,6 +24,8 @@ export function getSettings() { return settings.getSync(); } -export function setSettings(newSettings: Record) { +export function setSettings( + newSettings: Record +) { return settings.setSync(newSettings); -} \ No newline at end of file +} diff --git a/src/main/state.test.ts b/src/main/state.test.ts index ec806cb1..9580f099 100644 --- a/src/main/state.test.ts +++ b/src/main/state.test.ts @@ -1,22 +1,26 @@ -import { ArtistWithReleasesFull, ReleaseWithArtistAndSubreleases } from "@/types/types"; +import prisma from "./db/prisma"; +import { clearPrisma } from "@/test/prisma-utils"; +import { + ArtistWithReleasesFull, + ReleaseWithArtistAndSubreleases, +} from "@/types/types"; import { StateManager } from "./state"; -import prisma from './db/__mocks__/prisma'; -import { getFakeArtist } from "@/test/utils"; +import { getFakeArtist } from "../test/seed"; -vi.mock('./db/prisma'); +afterEach(clearPrisma); -describe('StateManager - constructor', () => { - it('should initialize a new StateManager', () => { +describe("StateManager - constructor", () => { + it("should initialize a new StateManager", () => { const state = new StateManager(); expect(state.getState()).toEqual({ selectedReleases: [], currentArtist: null, isInputFocused: false, isImporting: false, - path: '', + path: "", }); }); - it('should not throw if no handler is provided', () => { + it("should not throw if no handler is provided", () => { expect(() => { const state = new StateManager(); state.setInputFocused(true); @@ -24,83 +28,122 @@ describe('StateManager - constructor', () => { }); }); -describe('StateManager - setCurrentArtist / getCurrentArtist', () => { - it('should set and get the corresponding value', () => { +describe("StateManager - setCurrentArtist / getCurrentArtist", () => { + it("should set and get the corresponding value", () => { const onChange = vi.fn(); const state = new StateManager(); state.onStateChange(onChange); state.setCurrentArtist({ id: 1 } as ArtistWithReleasesFull); expect(state.getCurrentArtist()).toMatchObject({ id: 1 }); - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - currentArtist: { id: 1 } - })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + currentArtist: { id: 1 }, + }) + ); }); }); -describe('StateManager - setSelectedReleases / getSelectedReleases', () => { - it('should set and get the corresponding value', () => { +describe("StateManager - setSelectedReleases / getSelectedReleases", () => { + it("should set and get the corresponding value", () => { const onChange = vi.fn(); const state = new StateManager(); state.onStateChange(onChange); - state.setSelectedReleases([{ id: 1 }, { id: 2 }] as ReleaseWithArtistAndSubreleases[]); + state.setSelectedReleases([ + { id: 1 }, + { id: 2 }, + ] as ReleaseWithArtistAndSubreleases[]); expect(state.getSelectedReleases()).toMatchObject([{ id: 1 }, { id: 2 }]); - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - selectedReleases: [{ id: 1 }, { id: 2 }] - })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + selectedReleases: [{ id: 1 }, { id: 2 }], + }) + ); }); }); -describe('StateManager - setInputFocused / isInputFocused', () => { - it('should set and get the corresponding value', () => { +describe("StateManager - setInputFocused / isInputFocused", () => { + it("should set and get the corresponding value", () => { const onChange = vi.fn(); const state = new StateManager(); state.onStateChange(onChange); state.setInputFocused(true); expect(state.isInputFocused()).toBe(true); - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - isInputFocused: true - })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + isInputFocused: true, + }) + ); }); }); -describe('StateManager - setImporting / isImporting', () => { - it('should set and get the corresponding value', () => { +describe("StateManager - setImporting / isImporting", () => { + it("should set and get the corresponding value", () => { const onChange = vi.fn(); const state = new StateManager(); state.onStateChange(onChange); state.setImporting(true); expect(state.isImporting()).toBe(true); - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - isImporting: true - })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + isImporting: true, + }) + ); }); }); -describe('StateManager - setPath', () => { - it('should set path and update the current artist correspondingly', async () => { +describe("StateManager - setPath", () => { + it("should set path and update the current artist correspondingly", async () => { { - const artist = getFakeArtist(1) - prisma.artist.findFirst.mockResolvedValue(artist); + const artist = getFakeArtist(1); + await prisma.artist.create({ data: artist }); const onChange = vi.fn(); const state = new StateManager(); state.onStateChange(onChange); - await state.setPath('/artists/1'); + await state.setPath("/artists/1"); expect(state.getCurrentArtist()).toMatchObject({ id: 1 }); - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - path: '/artists/1', - currentArtist: artist - })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/artists/1", + currentArtist: expect.objectContaining(artist), + }) + ); } { const onChange = vi.fn(); const state = new StateManager(); state.onStateChange(onChange); - await state.setPath('/'); - expect(state.getCurrentArtist()).toBe(null) - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ - path: '/', - currentArtist: null - })); + await state.setPath("/"); + expect(state.getCurrentArtist()).toBe(null); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/", + currentArtist: null, + }) + ); } }); -}); \ No newline at end of file +}); + +describe("StateManager - refreshCurrentArtist", () => { + it("should do nothing if no current artist is stored in state", async () => { + const state = new StateManager(); + expect(state.getCurrentArtist()).toBe(null); + await state.refreshCurrentArtist(); + expect(state.getCurrentArtist()).toBe(null); + }); + it("should refresh current artist", async () => { + const artist = getFakeArtist(1); + await prisma.artist.create({ data: artist }); + const state = new StateManager(); + state.setCurrentArtist(artist as ArtistWithReleasesFull); + expect(state.getCurrentArtist()).toMatchObject({ id: 1 }); + + await prisma.artist.update({ + where: { id: 1 }, + data: { name: "new name" }, + }); + + await state.refreshCurrentArtist(); + expect(state.getCurrentArtist()).toMatchObject({ name: "new name" }); + }); +}); diff --git a/src/main/utils.test.ts b/src/main/utils.test.ts index 0c7518b3..4c678ff3 100644 --- a/src/main/utils.test.ts +++ b/src/main/utils.test.ts @@ -1,97 +1,135 @@ -import { getFakeRelease } from "@/test/utils"; +import prisma from "./db/prisma"; +import { clearPrisma } from "@/test/prisma-utils"; import { getFolderContents, parsePath } from "./utils"; +import { getFakeArtist, getFakeReleasesForArtist } from "../test/seed"; +import path from "path"; +import { mockFs } from "@/test/mock-fs"; + +afterEach(clearPrisma); describe("parsePath function", () => { it("parses input correctly", () => { [ - 'A/Artist/[Album]/1999 - My Title', - '/A/Artist/[Album]/1999 - My Title', - 'A/Artist/[Album]/1999 - My Title/', - '/A/Artist/[Album]/1999 - My Title/', - ].forEach(path => { + "A/Artist/[Album]/1999 - My Title", + "/A/Artist/[Album]/1999 - My Title", + "A/Artist/[Album]/1999 - My Title/", + "/A/Artist/[Album]/1999 - My Title/", + ].forEach((path) => { const output = parsePath(path); expect(output).toEqual({ - title: 'My Title', - type: 'Album', + title: "My Title", + type: "Album", year: 1999, - path: 'My Title', - fullPath: 'A/Artist/[Album]/1999 - My Title', + path: "My Title", + fullPath: "A/Artist/[Album]/1999 - My Title", artist: { - name: 'Artist' - } + name: "Artist", + }, }); }); }); it("parses the V/A folder correctly", () => { - const output = parsePath('/[V:A]/[Compilation]/1999 - My Title'); + const output = parsePath("/[V:A]/[Compilation]/1999 - My Title"); expect(output).toEqual({ - title: 'My Title', - type: 'Compilation', + title: "My Title", + type: "Compilation", year: 1999, - path: 'My Title', - fullPath: '[V:A]/[Compilation]/1999 - My Title', + path: "My Title", + fullPath: "[V:A]/[Compilation]/1999 - My Title", artist: { - name: '_VV_AA_' - } + name: "_VV_AA_", + }, }); }); it("returns null if path is malformed", () => { - const output = parsePath('/A/Artist/[Album]/1999 - My Title/More/Stuff'); + const output = parsePath("/A/Artist/[Album]/1999 - My Title/More/Stuff"); expect(output).toEqual(null); }); - it('provides some default for unmatched titles', () => { - const output = parsePath('/A/Artist/[Album]/title'); + it("provides some default for unmatched titles", () => { + const output = parsePath("/A/Artist/[Album]/title"); expect(output).toEqual({ - title: 'title', - type: 'Album', + title: "title", + type: "Album", year: 0, - path: 'title', - fullPath: 'A/Artist/[Album]/title', + path: "title", + fullPath: "A/Artist/[Album]/title", artist: { - name: 'Artist' - } + name: "Artist", + }, }); }); }); -describe('getFolderContents function', () => { - it('returns the track information for the given release', async () => { - const release = getFakeRelease(1); - const trackInfo = await getFolderContents(release, 'LIBRARY_PATH'); +describe("getFolderContents function", () => { + it("returns the track information for the given release", async (context) => { + const directory = await mockFs( + { + "/LIBRARY_PATH/A/Artist 1": { + "[Album]": { + "2000 - Release 1": { + "01 - Track 1.mp3": "", + "02 - Track 2.mp3": "", + "03 - Track 3.mp3": "", + "04 - Track 4.mp3": "", + "05 - Track 5.mp3": "", + }, + }, + }, + }, + context.task.id + ); + + await prisma.artist.create({ data: getFakeArtist(1) }); + const release = await prisma.release.create({ + data: getFakeReleasesForArtist(1).at(0), + include: { artist: true }, + }); + + const trackInfo = await getFolderContents( + { + ...release, + artist: { + ...release.artist, + _type: "artist", + }, + }, + path.join(directory, "LIBRARY_PATH") + ); + expect(trackInfo).toEqual([ { - path: '01 - track_1.mp3', - title: 'Track 1', + path: "01 - Track 1.mp3", + title: "Track 1", duration: 123, - position: 1 + position: 1, }, { - path: '02 - track_2.mp3', - title: 'Track 2', + path: "02 - Track 2.mp3", + title: "Track 2", duration: 123, - position: 2 + position: 2, }, { - path: '03 - track_3.mp3', - title: 'Track 3', + path: "03 - Track 3.mp3", + title: "Track 3", duration: 123, - position: 3 + position: 3, }, { - path: '04 - track_4.mp3', - title: 'Track 4', + path: "04 - Track 4.mp3", + title: "Track 4", duration: 123, - position: 4 + position: 4, }, { - path: '05 - track_5.mp3', - title: 'Track 5', + path: "05 - Track 5.mp3", + title: "Track 5", duration: 123, - position: 5 - } + position: 5, + }, ]); }); -}); \ No newline at end of file +}); diff --git a/src/main/utils.ts b/src/main/utils.ts index c71c2ced..eea6e926 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -47,7 +47,7 @@ export async function crawlFolder(folder: string) { } export async function getFolderContents( - release: ReleaseWithArtist, + release: Pick, library_path: string ): Promise { const folder = path.join( diff --git a/src/renderer/components/RelatedArtistsEditor.test.tsx b/src/renderer/components/RelatedArtistsEditor.test.tsx index 5aa701a8..8767ea1b 100644 --- a/src/renderer/components/RelatedArtistsEditor.test.tsx +++ b/src/renderer/components/RelatedArtistsEditor.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { getFakeArtist, withQueryClientProvider } from "@/test/utils"; +import { getFakeArtist } from "@/test/seed"; +import { withQueryClientProvider } from "@/test/utils"; import RelatedArtistsEditor from "./RelatedArtistsEditor"; import api from "../__mocks__/api"; import { Artist, ArtistWithReleasesFull } from "@/types/types"; @@ -8,103 +9,103 @@ import { Artist, ArtistWithReleasesFull } from "@/types/types"; vi.mock("../api"); const relatedArtists = ["North", "South", "East", "West"].map((name, i) => ({ - ...getFakeArtist(i + 2), - name, + ...getFakeArtist(i + 2), + name, })) as Artist[]; describe("RelatedArtistsEditor component", () => { - it("renders correctly", async () => { - api.artist.getArtist.mockResolvedValue({ - ...getFakeArtist(1, undefined), - relatedArtists: [], - } as ArtistWithReleasesFull); - - render(withQueryClientProvider()); - const title = await screen.findByText("Related Artists"); - expect(title).toBeInTheDocument(); - const placeholder = await screen.findByText("No artists yet."); - expect(placeholder).toBeInTheDocument(); - }); - - it("renders the current related artists in the list", async () => { - api.artist.getArtist.mockResolvedValue({ - ...getFakeArtist(1, undefined), - relatedArtists, - } as ArtistWithReleasesFull); - - render(withQueryClientProvider()); - await Promise.all( - relatedArtists.map(async ({ name }) => { - const artist = await screen.findByText(name); - expect(artist).toBeInTheDocument(); - }) - ); - }); - - it("removes the artist from the list when clicking on the remove button", async () => { - let clicked = false; - - api.artist.getArtist.mockImplementation(() => - Promise.resolve({ - ...getFakeArtist(1, undefined), - relatedArtists: relatedArtists.filter((x) => - clicked ? x.id !== relatedArtists[0].id : true - ), - } as ArtistWithReleasesFull) - ); - api.artist.removeRelatedArtist.mockImplementation(() => - Promise.resolve((clicked = true)) - ); - - render(withQueryClientProvider()); - - const artist = await screen.findByText(relatedArtists[0].name); + it("renders correctly", async () => { + api.artist.getArtist.mockResolvedValue({ + ...getFakeArtist(1), + relatedArtists: [], + } as ArtistWithReleasesFull); + + render(withQueryClientProvider()); + const title = await screen.findByText("Related Artists"); + expect(title).toBeInTheDocument(); + const placeholder = await screen.findByText("No artists yet."); + expect(placeholder).toBeInTheDocument(); + }); + + it("renders the current related artists in the list", async () => { + api.artist.getArtist.mockResolvedValue({ + ...getFakeArtist(1), + relatedArtists, + } as ArtistWithReleasesFull); + + render(withQueryClientProvider()); + await Promise.all( + relatedArtists.map(async ({ name }) => { + const artist = await screen.findByText(name); expect(artist).toBeInTheDocument(); - - await userEvent.click( - screen.getByLabelText(`Remove artist: ${relatedArtists[0].name}`) - ); - expect(artist).not.toBeInTheDocument(); - }); - - it("adds the artist to the list when clicking on the add button", async () => { - const user = userEvent.setup(); - let clicked = false; - - api.artist.getArtist.mockImplementation(() => - Promise.resolve({ - ...getFakeArtist(1, undefined), - relatedArtists: clicked - ? [relatedArtists[0], relatedArtists[1], relatedArtists[2]] - : [relatedArtists[0], relatedArtists[1]], - } as ArtistWithReleasesFull) - ); - api.artist.addRelatedArtist.mockImplementation(() => - Promise.resolve((clicked = true)) - ); - api.artist.searchArtists.mockImplementation(({ query }) => - Promise.resolve( - (clicked - ? [relatedArtists[3]] - : [relatedArtists[2], relatedArtists[3]] - ).filter(({ name }) => name.includes(query)) - ) - ); - - render(withQueryClientProvider()); - const artist = await screen.findByText(relatedArtists[0].name); - expect(artist).toBeInTheDocument(); - user.click(screen.getByLabelText("Lookup Artists")); - await user.keyboard("East"); - - const suggestion = await screen.findByText("East"); - expect(suggestion).toBeInTheDocument(); - - await userEvent.click(await screen.findByLabelText("Add artist: East")); - - expect(suggestion).not.toBeInTheDocument(); - const addedArtist = await screen.findByTitle("[3]"); - - expect(addedArtist).toBeInTheDocument(); - }); + }) + ); + }); + + it("removes the artist from the list when clicking on the remove button", async () => { + let clicked = false; + + api.artist.getArtist.mockImplementation(() => + Promise.resolve({ + ...getFakeArtist(1), + relatedArtists: relatedArtists.filter((x) => + clicked ? x.id !== relatedArtists[0].id : true + ), + } as ArtistWithReleasesFull) + ); + api.artist.removeRelatedArtist.mockImplementation(() => + Promise.resolve((clicked = true)) + ); + + render(withQueryClientProvider()); + + const artist = await screen.findByText(relatedArtists[0].name); + expect(artist).toBeInTheDocument(); + + await userEvent.click( + screen.getByLabelText(`Remove artist: ${relatedArtists[0].name}`) + ); + expect(artist).not.toBeInTheDocument(); + }); + + it("adds the artist to the list when clicking on the add button", async () => { + const user = userEvent.setup(); + let clicked = false; + + api.artist.getArtist.mockImplementation(() => + Promise.resolve({ + ...getFakeArtist(1), + relatedArtists: clicked + ? [relatedArtists[0], relatedArtists[1], relatedArtists[2]] + : [relatedArtists[0], relatedArtists[1]], + } as ArtistWithReleasesFull) + ); + api.artist.addRelatedArtist.mockImplementation(() => + Promise.resolve((clicked = true)) + ); + api.artist.searchArtists.mockImplementation(({ query }) => + Promise.resolve( + (clicked + ? [relatedArtists[3]] + : [relatedArtists[2], relatedArtists[3]] + ).filter(({ name }) => name.includes(query)) + ) + ); + + render(withQueryClientProvider()); + const artist = await screen.findByText(relatedArtists[0].name); + expect(artist).toBeInTheDocument(); + user.click(screen.getByLabelText("Lookup Artists")); + await user.keyboard("East"); + + const suggestion = await screen.findByText("East"); + expect(suggestion).toBeInTheDocument(); + + await userEvent.click(await screen.findByLabelText("Add artist: East")); + + expect(suggestion).not.toBeInTheDocument(); + const addedArtist = await screen.findByTitle("[3]"); + + expect(addedArtist).toBeInTheDocument(); + }); }); diff --git a/src/test/mock-fs.ts b/src/test/mock-fs.ts index 5bdf808c..198084c7 100644 --- a/src/test/mock-fs.ts +++ b/src/test/mock-fs.ts @@ -1,8 +1,8 @@ -import path from 'path'; -import { ensureDir, outputFile, remove } from 'fs-extra'; -import { isEmpty } from 'lodash'; +import path from "path"; +import { ensureDir, outputFile, remove } from "fs-extra"; +import { isEmpty } from "lodash"; -const TMP_DIR = '__mock-fs__'; +const TMP_DIR = "__mock-fs__"; export type Tree = Record; @@ -10,17 +10,19 @@ async function createTree(tree: Tree, rootDir: string): Promise { if (isEmpty(tree)) { return ensureDir(rootDir); } - return Promise.all(Object.entries(tree).map(([key, value]): Promise => { - if (typeof value === 'string') { - return outputFile(path.join(rootDir, key), value, 'utf8'); - } - return createTree(value as Tree, path.join(rootDir, key)); - })); + return Promise.all( + Object.entries(tree).map(([key, value]): Promise => { + if (typeof value === "string") { + return outputFile(path.join(rootDir, key), value, "utf8"); + } + return createTree(value as Tree, path.join(rootDir, key)); + }) + ); } export async function mockFs(tree: Tree, context_id: string) { if (!context_id) { - return ''; + return ""; } const directory = path.join(__dirname, TMP_DIR, context_id); await ensureDir(directory); @@ -28,6 +30,6 @@ export async function mockFs(tree: Tree, context_id: string) { return directory; } -export async function mockFsCleanup(id = '') { +export async function mockFsCleanup(id = "") { return remove(path.join(__dirname, TMP_DIR, id)); -} \ No newline at end of file +} diff --git a/src/test/prisma-utils.tsx b/src/test/prisma-utils.tsx new file mode 100644 index 00000000..f2e47a42 --- /dev/null +++ b/src/test/prisma-utils.tsx @@ -0,0 +1,10 @@ +import prisma from "../main/db/prisma"; +import { + PrismockClientType, + relationshipStore, +} from "prismock/build/main/lib/client"; + +export async function clearPrisma() { + await (prisma as PrismockClientType).reset(); + relationshipStore.resetValues(); +} diff --git a/src/test/seed.ts b/src/test/seed.ts new file mode 100644 index 00000000..5664d5ed --- /dev/null +++ b/src/test/seed.ts @@ -0,0 +1,242 @@ +import prisma from "../main/db/prisma"; +import sha1 from "sha1"; +import { hashArtistName, hashRelease } from "../main/hash"; +import type { Release, HasId } from "@/types/types"; + +export function getFakeArtist(id = 1) { + const artist = getFakeArtists({ length: 1 }).at(0); + return { + ...artist, + id, + }; +} + +export function getFakeArtists({ length = 10 }) { + return Array.from({ length }, (_, i) => ({ + id: i + 1, + name: `Artist ${i + 1}`, + normalizedName: `Artist ${i + 1}`, + path: `A/Artist ${i + 1}`, + hash: hashArtistName(`Artist ${i + 1}`), + createdAt: getDate(i), + })); +} + +export function getFakeRelease(id = 1, override: Partial = {}) { + return { + id, + title: `Release ${id}`, + normalizedTitle: `Release ${id}`, + type: "Album" as const, + year: 2000, + path: `Release ${id}`, + hash: hashRelease({ + title: `Release ${id}`, + type: "Album", + year: 2000, + artist_id: override.artist_id, + }), + artist_id: override.artist_id, + discTitle: "", + discNumber: 1, + createdAt: getDate(id), + updatedAt: getDate(id), + mainReleaseId: null as number, + hideOnHomepage: false, + ...override, + }; +} + +export function getFakeReleasesForArtist(artist_id: number, length = 5) { + return Array.from({ length }, (_, i) => ({ + id: (artist_id - 1) * length + i + 1, + title: `Release ${i + 1}`, + normalizedTitle: `Release ${i + 1}`, + type: "Album" as const, + year: 2000, + path: `Release ${i + 1}`, + hash: hashRelease({ + title: `Release ${i + 1}`, + type: "Album", + year: 2000, + artist_id, + }), + artist_id, + discTitle: "", + discNumber: 1, + createdAt: getDate(i), + updatedAt: getDate(i), + mainReleaseId: null as number, + hideOnHomepage: false, + })); +} + +export function getFakeTracksForRelease(releaseId: number, length = 5) { + return Array.from({ length }, (_, i) => ({ + id: (releaseId - 1) * length + i + 1, + title: `Track ${i + 1}`, + normalizedTitle: `Track ${i + 1}`, + path: `0${i + 1} - Track ${i + 1}.mp3`, + hash: sha1( + `${releaseId * length + i + 1}-0${i + 1} - Track ${i + 1}.mp3` + ).slice(0, 16), + duration: 180, + releaseId, + position: i + 1, + })); +} + +export function getFakeCollection(id = 1) { + const collection = getFakeCollections({ length: 1 }).at(0); + return { + ...collection, + id, + }; +} + +export function getFakeCollections({ + length = 3, + releases = [], +}: { + length: number; + releases?: HasId[]; +}) { + const connect = releases?.length ? { releases: { connect: releases } } : {}; + return Array.from({ length }, (_, i) => ({ + id: i + 1, + title: `Collection ${i + 1}`, + ...connect, + })); +} + +export function getFakeGroups({ + length = 3, + artists = [], +}: { + length: number; + artists?: HasId[]; +}) { + const connect = artists?.length ? { artists: { connect: artists } } : {}; + return Array.from({ length }, (_, i) => ({ + id: i + 1, + title: `Group ${i + 1}`, + coverArtistId: artists?.at(0)?.id || null, + ...connect, + })); +} + +export async function seed() { + const artists = getFakeArtists({ length: 10 }); + const releases = artists.flatMap((x) => getFakeReleasesForArtist(x.id)); + await prisma.artist.createMany({ data: artists }); + await prisma.release.createMany({ data: releases }); + await prisma.track.createMany({ + data: releases.flatMap((r) => getFakeTracksForRelease(r.id)), + }); + await Promise.all( + getFakeGroups({ length: 3 }).map((x) => + prisma.group.create({ + data: { + ...x, + artists: { connect: artists.slice(0, 3).map(({ id }) => ({ id })) }, + }, + }) + ) + ); + + await Promise.all( + getFakeCollections({ length: 3 }).map((x, i) => + prisma.collection.create({ + data: { + ...x, + releases: { + connect: Array.from({ length: 3 }, (_, j) => ({ + id: j + 1 + 3 * i, + })), + }, + }, + }) + ) + ); + + await prisma.artist.update({ + where: { id: 1 }, + data: { + relatedArtists: { + connect: [{ id: 2 }], + }, + }, + }); + + await prisma.artist.update({ + where: { id: 3 }, + data: { + relatedArtists: { + connect: [{ id: 4 }], + }, + }, + }); + + await prisma.release.update({ + where: { id: 1 }, + data: { + additionalArtists: { + connect: [{ id: 2 }], + }, + }, + }); + await prisma.release.update({ + where: { id: 2 }, + data: { + additionalArtists: { + connect: [{ id: 2 }], + }, + }, + }); + await prisma.release.update({ + where: { id: 1 }, + data: { + additionalArtists: { + connect: [{ id: 3 }], + }, + }, + }); + await prisma.release.update({ + where: { id: 3 }, + data: { + additionalArtists: { + connect: [{ id: 4 }], + }, + }, + }); +} + +export async function getData() { + return { + artists: await prisma.artist.findMany({ + include: { + groups: { select: { id: true } }, + appearsIn: { select: { id: true } }, + relatedArtists: { select: { id: true } }, + coverGroups: { select: { id: true } }, + }, + }), + releases: await prisma.release.findMany({ + include: { + subReleases: { select: { id: true } }, + coverCollections: { select: { id: true } }, + }, + }), + tracks: await prisma.track.findMany(), + groups: await prisma.group.findMany(), + collections: await prisma.collection.findMany(), + }; +} + +function getDate(id: number) { + const month = (id % 12) + 1; + const day = (id % 28) + 1; + return new Date(`2025-${pad(month)}-${pad(day)}T21:41:31.693Z`); +} + +const pad = (n = 1) => (n < 10 ? `0${n}` : n); diff --git a/src/test/utils.tsx b/src/test/utils.tsx index d9e25aca..34bb904c 100644 --- a/src/test/utils.tsx +++ b/src/test/utils.tsx @@ -1,320 +1,43 @@ import type { ReactNode } from "react"; import { MemoryRouter } from "react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { - ReleaseWithArtist, - Artist, - Track, - ArtistWithReleases, - CollectionWithReleases, - WithRelatedArtists, - GroupWithArtists, - WithGroups, - WithAppearances, -} from "@/types/types"; import path from "path"; -import { normalizeDiacritics } from "@/lib/utils"; - -export function withRouter(children: ReactNode) { - return {children}; -} const queryClient = new QueryClient(); export function withQueryClientProvider(children: ReactNode) { - return ( - - {children} - - ); -} - -const timestamp = new Date("2025-04-04T14:52:56.879Z"); - -const artistHashMap: Record< - string, - Pick -> = { - "6c3f3d3203630ce7": { - id: 1, - path: "A/Artist", - name: "Artist", - normalizedName: "Artist", - coverReleaseId: 1, - }, -}; - -export function getFakeCollection( - id: number, - overwrite?: Partial -) { - return { - _type: "collection", - id, - title: "My Collection", - createdAt: timestamp, - updatedAt: timestamp, - ...overwrite, - }; -} - -export function getFakeGroup( - id: number, - overwrite?: Partial -) { - return { - _type: "group", - id, - title: "My Group", - createdAt: timestamp, - updatedAt: timestamp, - ...overwrite, - }; -} - -export function getFakeArtistByHash(hash: string): ArtistWithReleases { - const id = artistHashMap[hash].id; - if (!id) { - return getFakeArtist(1); - } - return getFakeArtist(id, hash); -} - -export function getFakeArtist( - id: number, - hash?: string, - overwrite?: Partial -): ArtistWithReleases & WithRelatedArtists & WithGroups & WithAppearances { - const foundHash = Object.keys(artistHashMap).find( - (hash) => artistHashMap[hash]?.id === id - ); - - const data = artistHashMap[foundHash] || ({} as ArtistWithReleases); - - id = id || data.id || 1; - return { - _type: "artist", - id, - name: "Artist", - normalizedName: "Artist", - createdAt: timestamp, - updatedAt: timestamp, - hash: hash || `artist-hash-${id}`, - path: "artist-path", - coverReleaseId: 1, - releases: [], - relatedArtists: [], - groups: [], - appearsIn: [], - ...data, - ...overwrite, - }; -} - -const releaseHashMap: Record< - string, - Pick< - ReleaseWithArtist, - "id" | "artist_id" | "title" | "normalizedTitle" | "year" | "path" - > -> = { - e6ff3253fb407e5f: { - id: 1, - artist_id: 1, - title: "Album One", - normalizedTitle: "Album One", - path: "Album One", - year: 1999, - }, - b66649708b05af8e: { - id: 2, - artist_id: 1, - title: "Album Two", - normalizedTitle: "Album Two", - path: "Album Two", - year: 2000, - }, -}; - -export function getFakeReleaseByHash(hash: string): ReleaseWithArtist { - const match = releaseHashMap[hash]; - if (!match) { - return getFakeRelease(1); - } - return getFakeRelease(match.id, match.artist_id, hash); -} - -export function getFakeRelease( - release_id: number, - artist_id?: number, - hash?: string, - overwrite?: Partial -): ReleaseWithArtist { - const foundHash = Object.keys(releaseHashMap).find( - (hash) => releaseHashMap[hash]?.id === release_id - ); - - const data = releaseHashMap[foundHash] || ({} as ReleaseWithArtist); - - artist_id = artist_id || data.artist_id || 1; - - function getNormalizedTitle() { - if (overwrite?.normalizedTitle) { - return overwrite.normalizedTitle; - } - if (overwrite?.title) { - return normalizeDiacritics(overwrite.title); - } - if (data?.normalizedTitle) { - return data.normalizedTitle; - } - return "release title"; - } - - return { - _type: "release", - id: release_id, - path: "release-path", - title: "release title", - createdAt: timestamp, - updatedAt: timestamp, - hash: hash || foundHash || `release-hash-${release_id}`, - year: 1999, - type: "Album", - hideOnHomepage: false, - discTitle: null, - discNumber: 1, - mainReleaseId: null, - artist_id, - artist: getFakeArtist(artist_id), - additionalArtists: [], - ...data, - ...overwrite, - normalizedTitle: getNormalizedTitle(), - }; -} - -export function getTrackPaths(length = 5) { - return Array.from({ length }, (_, i) => getTrackPathFromIndex(i)); + return ( + {children} + ); } -export function getTrackFromData( - data: Pick & { - releaseId?: number; - } -): Track { - return { - _type: "track", - id: parseInt(`${data?.releaseId || 1}${data.position}`), - createdAt: timestamp, - updatedAt: timestamp, - releaseId: data.releaseId || 1, - normalizedTitle: normalizeDiacritics(data.title), - ...data, - }; -} - -export function getFakeTrack(index = 0, track_id = 1, releaseId = 1): Track { - const path = getTrackPathFromIndex(index); - return { - _type: "track", - id: track_id, - createdAt: timestamp, - updatedAt: timestamp, - releaseId, - path, - title: `Track ${index + 1}`, - normalizedTitle: `Track ${index + 1}`, - position: index + 1, - duration: 123, - hash: `track-hash-${track_id}`, - }; -} - -function getTrackPathFromIndex(index = 1, extension = ".mp3") { - return `${index < 9 ? "0" : ""}${index + 1} - track_${ - index + 1 - }${extension}`; +export function withRouter(children: ReactNode) { + return {children}; } -export const FULL_TRACKS = [ - { - _type: "track", - id: 11, - createdAt: timestamp, - updatedAt: timestamp, - releaseId: 1, - path: "01 - track_1.mp3", - title: "Track 1", - duration: 123, - position: 1, - hash: "2fbad3d560262706", - }, - { - _type: "track", - id: 12, - createdAt: timestamp, - updatedAt: timestamp, - releaseId: 1, - path: "02 - track_2.mp3", - title: "Track 2", - duration: 123, - position: 2, - hash: "898229a56a967dab", - }, - { - _type: "track", - id: 13, - createdAt: timestamp, - updatedAt: timestamp, - releaseId: 1, - path: "03 - track_3.mp3", - title: "Track 3", - duration: 123, - position: 3, - hash: "49d2b4621a291e4f", - }, - { - _type: "track", - id: 14, - createdAt: timestamp, - updatedAt: timestamp, - releaseId: 1, - path: "04 - track_4.mp3", - title: "Track 4", - duration: 123, - position: 4, - hash: "556ac59172a802c3", - }, - { - _type: "track", - id: 15, - createdAt: timestamp, - updatedAt: timestamp, - releaseId: 1, - path: "05 - track_5.mp3", - title: "Track 5", - duration: 123, - position: 5, - hash: "cb763ce03c4bcb15", - }, -]; - const settings = { - PLAYER_PATH: "PLAYER_PATH", - TAGGER_PATH: "TAGGER_PATH", - DISCOGS_KEY: "DISCOGS_KEY", - DISCOGS_SECRET: "DISCOGS_SECRET", - LIBRARY_PATH: "LIBRARY_PATH", - COVERS_PATH: "COVERS_PATH", + PLAYER_PATH: "PLAYER_PATH", + TAGGER_PATH: "TAGGER_PATH", + DISCOGS_KEY: "DISCOGS_KEY", + DISCOGS_SECRET: "DISCOGS_SECRET", + LIBRARY_PATH: "LIBRARY_PATH", + COVERS_PATH: "COVERS_PATH", } as const; export function getSetting(key: keyof typeof settings) { - return settings[key]; + return settings[key]; } export function withPath(key: keyof typeof settings, folderPath: string) { - return path.join(getSetting(key), folderPath); + return path.join(getSetting(key), folderPath); } export const send = vi.fn(); + +export function withoutDates( + x: T & { createdAt: string | Date; updatedAt: string | Date } +): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { createdAt, updatedAt, ...rest } = x; + return rest; +} diff --git a/src/test/vitest.setup.main.ts b/src/test/vitest.setup.main.ts index 7e2dca3f..9d82e9b9 100644 --- a/src/test/vitest.setup.main.ts +++ b/src/test/vitest.setup.main.ts @@ -1,61 +1,71 @@ -import { afterEach } from 'vitest'; -import path from 'path'; -import { getTrackPaths } from './utils'; -import { mockFsCleanup } from './mock-fs'; -import prisma from '@/main/db/__mocks__/prisma'; +import { afterEach } from "vitest"; +import path from "path"; +import { mockFsCleanup } from "./mock-fs"; +import type { PrismaClient } from "@prisma/client"; +import { createPrismock } from "prismock"; +import { clearPrisma } from "./prisma-utils"; -type GlobbyOptions = { cwd: string, onlyDirectories: boolean }; - -vi.mock("electron", () => ({ - shell: { - openPath: vi.fn(), - }, - dialog: { - showMessageBoxSync: vi.fn(), - showOpenDialogSync: vi.fn((...args) => [args[1].defaultPath]), - }, -})); +vi.mock("@prisma/client-generated", async () => { + const actual = await vi.importActual( + "@prisma/client-generated" + ); + const PrismaClient = createPrismock(actual.Prisma); + return { + ...actual, + PrismaClient, + }; +}); -vi.mock('globby', () => ({ - globby: (_: string, { cwd, onlyDirectories }: GlobbyOptions): string[] => { - if (cwd.includes('empty/folder')) { - return []; - } - if (onlyDirectories) { - if (cwd.includes('Single Folder')) { - return []; - } - return [ - '1999 - Album One', - '2000 - Album Two' - ]; - } - return getTrackPaths(); - } -})); +vi.mock("electron", () => { + return { + shell: { + openPath: vi.fn(), + }, + dialog: { + showMessageBoxSync: vi.fn(), + showOpenDialogSync: vi.fn((...args) => [args[1].defaultPath]), + }, + app: { + getPath: vi.fn(), + relaunch: vi.fn(), + exit: vi.fn(), + }, + Menu: { + setApplicationMenu: vi.fn(), + getApplicationMenu: vi.fn(() => ({ + append: vi.fn(), + })), + }, + MenuItem: vi.fn(), + ipcMain: { + handle: vi.fn(), + on: vi.fn(), + }, + }; +}); -vi.mock('music-metadata', () => ({ +vi.mock("music-metadata", () => ({ parseFile: async (filePath: string) => { - const index = parseInt(path.basename(filePath).split('-').at(0)); + const index = parseInt(path.basename(filePath).split("-").at(0)); return Promise.resolve({ common: { title: `Track ${index}`, track: { - no: index - } + no: index, + }, }, format: { - duration: 123 - } + duration: 123, + }, }); - } + }, })); beforeEach(async (context) => { await mockFsCleanup(context.task.id); - prisma.$transaction.mockImplementation((x: unknown) => Promise.resolve(x)); + clearPrisma(); }); afterEach(async (context) => { await mockFsCleanup(context.task.id); -}); \ No newline at end of file +}); diff --git a/src/types/types.ts b/src/types/types.ts index 3351fda7..20b25084 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,125 +1,156 @@ -import { Controllers, send } from '@/main/controllers/init'; +import { Controllers, send } from "@/main/controllers/init"; // eslint-disable-next-line import/no-named-as-default -import Prisma, { ReleaseType } from '@prisma/client-generated'; +import Prisma, { ReleaseType } from "@prisma/client-generated"; type Artist = Prisma.Artist & { - _type: 'artist', - coverRelease?: ReleaseWithArtistAndSubreleases + _type: "artist"; + coverRelease?: ReleaseWithArtistAndSubreleases; }; -type Release = Prisma.Release & { _type: 'release' }; +type Release = Prisma.Release & { _type: "release" }; type Collection = Prisma.Collection & { - _type: 'collection', - coverRelease?: ReleaseWithArtistAndSubreleases + _type: "collection"; + coverRelease?: ReleaseWithArtistAndSubreleases; }; type Group = Prisma.Group & { - _type: 'group', + _type: "group"; coverArtist?: ArtistWithReleases; }; -type Track = Prisma.Track & { _type: 'track' }; +type Track = Prisma.Track & { _type: "track" }; export type { Artist, Release, Collection, Track, ReleaseType, Group }; -export type Entities = 'collection' | 'release' | 'artist' | 'searchResult' | 'track' | 'group'; -export type SearchableEntities = 'collection' | 'release' | 'artist' | 'track' | 'group'; +export type Entities = + | "collection" + | "release" + | "artist" + | "searchResult" + | "track" + | "group"; +export type SearchableEntities = + | "collection" + | "release" + | "artist" + | "track" + | "group"; export type Stats = Record; -export type HasId = { id: number; }; -export type HasTitle = { title: string; }; - -export type CollectionCreate = { title: string, releases?: number[] }; -export type CollectionUpdate = { title: string, releases: number[] }; -export type GroupCreate = { title: string, artists?: number[] }; -export type GroupUpdate = { title: string, artists: number[] }; -export type ArtistUpdate = Pick; -export type TrackInfo = Pick; +export type HasId = { id: number }; +export type HasTitle = { title: string }; + +export type CollectionCreate = { title: string; releases?: number[] }; +export type CollectionUpdate = { title: string; releases: number[] }; +export type GroupCreate = { title: string; artists?: number[] }; +export type GroupUpdate = { title: string; artists: number[] }; +export type ArtistUpdate = Pick; +export type TrackInfo = Pick; export type ReleaseCountByType = Record; export type TrackWithRelease = Track & { release: ReleaseWithArtist }; export type WithReleases = { releases: ReleaseWithArtist[]; -} +}; type WithReleasesAndSubreleases = { releases: ReleaseWithArtistAndSubreleases[]; -} +}; type WithReleasesAndSubreleasesAndTracks = { releases: ReleaseWithArtistAndTracksAndSubreleases[]; -} +}; type WithArtist = { artist: Artist; -} +}; type WithArtistsAndReleases = { artists: ArtistWithReleases[]; -} +}; type WithTracks = { tracks: Track[]; -} +}; type WithCollections = { collections: Collection[]; -} +}; export type WithGroups = { groups: Group[]; -} +}; export type WithRelatedArtists = { relatedArtists: Artist[]; -} +}; export type WithAdditionalArtists = { additionalArtists: Artist[]; -} +}; export type WithAppearances = { appearsIn: ReleaseWithArtist[]; -} +}; export type ArtistWithRelatedArtists = Artist & WithRelatedArtists; export type ArtistWithReleases = Artist & WithReleasesAndSubreleases; -export type ArtistWithReleasesFull = Artist & WithRelatedArtists & WithReleasesAndSubreleasesAndTracks & WithGroups & WithAppearances; -export type CollectionWithReleases = Collection & WithReleasesAndSubreleasesAndTracks; +export type ArtistWithReleasesFull = Artist & + WithRelatedArtists & + WithReleasesAndSubreleasesAndTracks & + WithGroups & + WithAppearances; +export type CollectionWithReleases = Collection & + WithReleasesAndSubreleasesAndTracks; export type ReleaseWithArtist = Release & WithArtist & WithAdditionalArtists; -export type ReleaseWithArtistAndSubreleases = Release & WithArtist & WithAdditionalArtists & WithSubReleases; -export type ReleaseWithArtistAndTracks = Release & WithArtist & WithAdditionalArtists & WithTracks; -export type ReleaseWithArtistAndTracksAndSubreleases = Release & WithArtist & WithAdditionalArtists & WithTracks & WithSubReleases; -export type ReleaseWithArtistAndTracksAndSubreleasesAndCollections = Release & WithArtist & WithAdditionalArtists & WithTracks & WithSubReleases & WithCollections; +export type ReleaseWithArtistAndSubreleases = Release & + WithArtist & + WithAdditionalArtists & + WithSubReleases; +export type ReleaseWithArtistAndTracks = Release & + WithArtist & + WithAdditionalArtists & + WithTracks; +export type ReleaseWithArtistAndTracksAndSubreleases = Release & + WithArtist & + WithAdditionalArtists & + WithTracks & + WithSubReleases; +export type ReleaseWithArtistAndTracksAndSubreleasesAndCollections = Release & + WithArtist & + WithAdditionalArtists & + WithTracks & + WithSubReleases & + WithCollections; export type ArtistWithReleaseCount = ArtistWithReleases & { - releaseCount: ReleaseCountByType + releaseCount: ReleaseCountByType; }; export type GroupWithArtists = Group & WithArtistsAndReleases; export type WithSubReleases = { subReleases: ReleaseWithArtistAndTracks[]; -} +}; export type Pagination = { take: number; skip: number; total: number; -} +}; export type WithPagination = { pagination: Pagination; -} +}; export type PaginatedResults = WithPagination & { results: T[]; -} +}; export type PaginationParams = { - take?: number, - skip?: number -} + take?: number; + skip?: number; +}; export type SearchParams = Record; export type SearchResult = { - _type: 'searchResult', + _type: "searchResult"; id: number; type: SearchableEntities; title: string; @@ -128,7 +159,7 @@ export type SearchResult = { description: string; links: Partial>; coverRelease?: ReleaseWithArtist; -} +}; export type Sidebars = "music" | "artists" | "collections" | "groups"; @@ -136,19 +167,25 @@ export type Entries = { [K in keyof T]: [K, T[K]]; }[keyof T][]; -export type ViewMode = 'grid' | 'list' | 'compact'; +export type ViewMode = "grid" | "list" | "compact"; export type Settings = Record; type WithEntityType = T & { _type: Entities }; export function withEntityType(item: T, _type: Entities): WithEntityType; -export function withEntityType(item: T[], _type: Entities): WithEntityType[]; -export function withEntityType(item: T | T[], _type: Entities): WithEntityType | WithEntityType[] { +export function withEntityType( + item: T[], + _type: Entities +): WithEntityType[]; +export function withEntityType( + item: T | T[], + _type: Entities +): WithEntityType | WithEntityType[] { if (Array.isArray(item)) { - return item.map(x => withEntityType(x, _type)); + return item.map((x) => withEntityType(x, _type)); } - return { ...item, _type } + return { ...item, _type }; } export type NewReleaseInfo = { @@ -157,23 +194,35 @@ export type NewReleaseInfo = { newTitle: string; newType: ReleaseType; newYear: number; -} +}; -export type EditReleaseParam = ( - Pick - & NewReleaseInfo -); +export type EditReleaseParam = Pick< + Release, + | "id" + | "path" + | "hash" + | "title" + | "artist_id" + | "year" + | "type" + | "discTitle" + | "discNumber" +> & + NewReleaseInfo; export type MenuParams = { controllers: Controllers; send: typeof send; }; -export type Context = CollectionWithReleases | ArtistWithReleases | GroupWithArtists; +export type Context = + | CollectionWithReleases + | ArtistWithReleases + | GroupWithArtists; export type Unpacked = T extends (infer U)[] ? U : T; export type BaseQuery = { isPending: boolean; error: Error; -} \ No newline at end of file +}; diff --git a/vite.main.config.ts b/vite.main.config.ts index 802c431b..68c13e1f 100644 --- a/vite.main.config.ts +++ b/vite.main.config.ts @@ -21,7 +21,14 @@ export default defineConfig({ coverage: { provider: "istanbul", include: ["src/main"], - exclude: ["**/__mocks__/*"], + exclude: [ + "**/__mocks__/*", + "**/cover-placeholder.ts", + "**/seed.ts", + "**/logger.ts", + "**/run.ts", + "**/menu/*", + ], }, }, });