diff --git a/package.json b/package.json
index e75d2b6..0e15b35 100644
--- a/package.json
+++ b/package.json
@@ -49,10 +49,13 @@
"@astrojs/check": "^0.9.6",
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.1.0",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"eslint": "^10.0.2",
"eslint-plugin-astro": "^1.6.0",
+ "jsdom": "^28.1.0",
"lefthook": "^2.1.1",
"prettier": "^3.8.1",
"prettier-plugin-astro": "^0.14.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bcda115..4047757 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -66,6 +66,12 @@ importers:
'@capacitor/cli':
specifier: ^8.1.0
version: 8.1.0
+ '@testing-library/jest-dom':
+ specifier: ^6.9.1
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@typescript-eslint/eslint-plugin':
specifier: ^8.56.1
version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
@@ -78,6 +84,9 @@ importers:
eslint-plugin-astro:
specifier: ^1.6.0
version: 1.6.0(eslint@10.0.2(jiti@2.6.1))
+ jsdom:
+ specifier: ^28.1.0
+ version: 28.1.0(@noble/hashes@1.8.0)
lefthook:
specifier: ^2.1.1
version: 2.1.1
@@ -92,10 +101,26 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.0.18
- version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)
+ version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(yaml@2.8.2)
packages:
+ '@acemir/cssom@0.9.31':
+ resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
+
+ '@adobe/css-tools@4.4.4':
+ resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
+
+ '@asamuzakjp/css-color@5.0.1':
+ resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@6.8.1':
+ resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
'@astrojs/check@0.9.6':
resolution: {integrity: sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA==}
hasBin: true
@@ -222,6 +247,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/runtime@7.28.6':
+ resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -234,6 +263,10 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
'@capacitor/android@8.1.0':
resolution: {integrity: sha512-z0acTPxj5DCy/U2FU7w+GA93oC+wdyKnsOcRg5rutDmSYa8Do1tzYqApKgf+hnuTNPbtrCTHp0Zy1cLiK/4MEw==}
peerDependencies:
@@ -284,6 +317,37 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
+ '@csstools/color-helpers@6.0.2':
+ resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.1.1':
+ resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.0.2':
+ resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.0.28':
+ resolution: {integrity: sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==}
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
'@dabh/diagnostics@2.0.8':
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
@@ -817,6 +881,15 @@ packages:
resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ '@exodus/bytes@1.14.1':
+ resolution: {integrity: sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@fastify/accept-negotiator@2.0.1':
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
@@ -1692,6 +1765,29 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@trapezedev/gradle-parse@7.1.3':
resolution: {integrity: sha512-WQVF5pEJ5o/mUyvfGTG9nBKx9Te/ilKM3r2IT69GlbaooItT5ao7RyF1MUTBNjHLPk/xpGUY3c6PyVnjDlz0Vw==}
@@ -1710,6 +1806,9 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -2053,6 +2152,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
@@ -2079,6 +2182,9 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
@@ -2234,6 +2340,9 @@ packages:
peerDependencies:
ajv: 4.11.8 - 8
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
big-integer@1.6.52:
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
engines: {node: '>=0.6'}
@@ -2625,6 +2734,9 @@ packages:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -2637,6 +2749,10 @@ packages:
resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
+ cssstyle@6.1.0:
+ resolution: {integrity: sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==}
+ engines: {node: '>=20'}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -2648,6 +2764,10 @@ packages:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -2692,6 +2812,9 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -2826,6 +2949,12 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
dom-serializer@1.4.1:
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
@@ -3490,6 +3619,10 @@ packages:
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
engines: {node: ^16.14.0 || >=18.0.0}
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
@@ -3499,6 +3632,10 @@ packages:
http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
+ http-proxy-agent@7.0.2:
+ resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
+ engines: {node: '>= 14'}
+
http-shutdown@1.2.2:
resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -3706,6 +3843,9 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -3810,6 +3950,15 @@ packages:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
+ jsdom@28.1.0:
+ resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -4124,6 +4273,10 @@ packages:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4692,6 +4845,9 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+ parse5@8.0.0:
+ resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
+
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@@ -4830,6 +4986,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
@@ -4885,6 +5045,9 @@ packages:
peerDependencies:
react: ^19.2.4
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -5115,6 +5278,10 @@ packages:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'}
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -5354,6 +5521,9 @@ packages:
engines: {node: '>=16'}
hasBin: true
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
synckit@0.11.12:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -5439,6 +5609,13 @@ packages:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'}
+ tldts-core@7.0.23:
+ resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==}
+
+ tldts@7.0.23:
+ resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==}
+ hasBin: true
+
tmp-promise@3.0.3:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
@@ -5456,9 +5633,17 @@ packages:
tomlify-j0.4@3.0.0:
resolution: {integrity: sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ==}
+ tough-cookie@6.0.0:
+ resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
+ engines: {node: '>=16'}
+
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -5592,6 +5777,10 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+ undici@7.22.0:
+ resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
+ engines: {node: '>=20.18.1'}
+
unicorn-magic@0.1.0:
resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==}
engines: {node: '>=18'}
@@ -5937,6 +6126,10 @@ packages:
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
@@ -5947,6 +6140,18 @@ packages:
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -6045,6 +6250,10 @@ packages:
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
hasBin: true
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
xml2js@0.5.0:
resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==}
engines: {node: '>=4.0.0'}
@@ -6061,6 +6270,9 @@ packages:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
xpath@0.0.27:
resolution: {integrity: sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ==}
engines: {node: '>=0.6.0'}
@@ -6185,6 +6397,28 @@ packages:
snapshots:
+ '@acemir/cssom@0.9.31': {}
+
+ '@adobe/css-tools@4.4.4': {}
+
+ '@asamuzakjp/css-color@5.0.1':
+ dependencies:
+ '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+ lru-cache: 11.2.6
+
+ '@asamuzakjp/dom-selector@6.8.1':
+ dependencies:
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.0.3
+ css-tree: 3.1.0
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.2.6
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
'@astrojs/check@0.9.6(prettier-plugin-astro@0.14.1)(prettier@3.8.1)(typescript@5.9.3)':
dependencies:
'@astrojs/language-server': 2.16.3(prettier-plugin-astro@0.14.1)(prettier@3.8.1)(typescript@5.9.3)
@@ -6436,6 +6670,8 @@ snapshots:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
+ '@babel/runtime@7.28.6': {}
+
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -6459,6 +6695,10 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.1.0
+
'@capacitor/android@8.1.0(@capacitor/core@8.1.0)':
dependencies:
'@capacitor/core': 8.1.0
@@ -6558,6 +6798,28 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
+ '@csstools/color-helpers@6.0.2': {}
+
+ '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.0.2
+ '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.0.28': {}
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
'@dabh/diagnostics@2.0.8':
dependencies:
'@so-ric/colorspace': 1.1.6
@@ -6866,6 +7128,10 @@ snapshots:
'@eslint/core': 1.1.0
levn: 0.4.1
+ '@exodus/bytes@1.14.1(@noble/hashes@1.8.0)':
+ optionalDependencies:
+ '@noble/hashes': 1.8.0
+
'@fastify/accept-negotiator@2.0.1': {}
'@fastify/busboy@3.2.0': {}
@@ -7879,6 +8145,36 @@ snapshots:
tailwindcss: 4.2.1
vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/runtime': 7.28.6
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.4.4
+ aria-query: 5.3.2
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@babel/runtime': 7.28.6
+ '@testing-library/dom': 10.4.1
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
'@trapezedev/gradle-parse@7.1.3': {}
'@trapezedev/project@7.1.3(@types/node@25.3.0)(typescript@5.9.3)':
@@ -7923,6 +8219,8 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
+ '@types/aria-query@5.0.4': {}
+
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.0
@@ -8387,6 +8685,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ ansi-styles@5.2.0: {}
+
ansi-styles@6.2.3: {}
ansis@4.2.0: {}
@@ -8423,6 +8723,10 @@ snapshots:
argparse@2.0.1: {}
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
aria-query@5.3.2: {}
array-buffer-byte-length@1.0.2:
@@ -8662,6 +8966,10 @@ snapshots:
jsonpointer: 5.0.1
leven: 3.1.0
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
big-integer@1.6.52: {}
bindings@1.5.0:
@@ -9091,6 +9399,8 @@ snapshots:
css-what@6.2.2: {}
+ css.escape@1.5.1: {}
+
cssesc@3.0.0: {}
cssfilter@0.0.10: {}
@@ -9099,12 +9409,26 @@ snapshots:
dependencies:
css-tree: 2.2.1
+ cssstyle@6.1.0:
+ dependencies:
+ '@asamuzakjp/css-color': 5.0.1
+ '@csstools/css-syntax-patches-for-csstree': 1.0.28
+ css-tree: 3.1.0
+ lru-cache: 11.2.6
+
csstype@3.2.3: {}
dargs@7.0.0: {}
data-uri-to-buffer@4.0.1: {}
+ data-urls@7.0.0(@noble/hashes@1.8.0):
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1(@noble/hashes@1.8.0)
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -9144,6 +9468,8 @@ snapshots:
decamelize@1.2.0: {}
+ decimal.js@10.6.0: {}
+
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
@@ -9278,6 +9604,10 @@ snapshots:
dlv@1.1.3: {}
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
dom-serializer@1.4.1:
dependencies:
domelementtype: 2.3.0
@@ -10168,12 +10498,25 @@ snapshots:
dependencies:
lru-cache: 10.4.3
+ html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
+ dependencies:
+ '@exodus/bytes': 1.14.1(@noble/hashes@1.8.0)
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
html-escaper@3.0.3: {}
html-void-elements@3.0.0: {}
http-cache-semantics@4.2.0: {}
+ http-proxy-agent@7.0.2:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
http-shutdown@1.2.2: {}
https-proxy-agent@7.0.6:
@@ -10373,6 +10716,8 @@ snapshots:
is-plain-obj@4.1.0: {}
+ is-potential-custom-element-name@1.0.1: {}
+
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -10466,6 +10811,33 @@ snapshots:
dependencies:
argparse: 2.0.1
+ jsdom@28.1.0(@noble/hashes@1.8.0):
+ dependencies:
+ '@acemir/cssom': 0.9.31
+ '@asamuzakjp/dom-selector': 6.8.1
+ '@bramus/specificity': 2.4.2
+ '@exodus/bytes': 1.14.1(@noble/hashes@1.8.0)
+ cssstyle: 6.1.0
+ data-urls: 7.0.0(@noble/hashes@1.8.0)
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0)
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ is-potential-custom-element-name: 1.0.1
+ parse5: 8.0.0
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.0
+ undici: 7.22.0
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1(@noble/hashes@1.8.0)
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+ - supports-color
+
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
@@ -10741,6 +11113,8 @@ snapshots:
luxon@3.7.2: {}
+ lz-string@1.5.0: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -11502,6 +11876,10 @@ snapshots:
dependencies:
entities: 6.0.1
+ parse5@8.0.0:
+ dependencies:
+ entities: 6.0.1
+
path-browserify@1.0.1: {}
path-exists@3.0.0: {}
@@ -11635,6 +12013,12 @@ snapshots:
prettier@3.8.1: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
prismjs@1.30.0: {}
process-nextick-args@2.0.1: {}
@@ -11677,6 +12061,8 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
+ react-is@17.0.2: {}
+
react-refresh@0.17.0: {}
react@19.2.4: {}
@@ -12011,6 +12397,10 @@ snapshots:
sax@1.4.4: {}
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
scheduler@0.27.0: {}
semver@5.7.2: {}
@@ -12325,6 +12715,8 @@ snapshots:
picocolors: 1.1.1
sax: 1.4.4
+ symbol-tree@3.2.4: {}
+
synckit@0.11.12:
dependencies:
'@pkgr/core': 0.2.9
@@ -12440,6 +12832,12 @@ snapshots:
tinyrainbow@3.0.3: {}
+ tldts-core@7.0.23: {}
+
+ tldts@7.0.23:
+ dependencies:
+ tldts-core: 7.0.23
+
tmp-promise@3.0.3:
dependencies:
tmp: 0.2.5
@@ -12454,8 +12852,16 @@ snapshots:
tomlify-j0.4@3.0.0: {}
+ tough-cookie@6.0.0:
+ dependencies:
+ tldts: 7.0.23
+
tr46@0.0.3: {}
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
tree-kill@1.2.2: {}
trim-lines@3.0.1: {}
@@ -12575,6 +12981,8 @@ snapshots:
undici-types@7.18.2: {}
+ undici@7.22.0: {}
+
unicorn-magic@0.1.0: {}
unified@11.0.5:
@@ -12733,7 +13141,7 @@ snapshots:
optionalDependencies:
vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)
- vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2):
+ vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))
@@ -12758,6 +13166,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 25.3.0
+ jsdom: 28.1.0(@noble/hashes@1.8.0)
transitivePeerDependencies:
- jiti
- less
@@ -12868,12 +13277,28 @@ snapshots:
vscode-uri@3.1.0: {}
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
web-namespaces@2.0.1: {}
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1(@noble/hashes@1.8.0):
+ dependencies:
+ '@exodus/bytes': 1.14.1(@noble/hashes@1.8.0)
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
@@ -13003,6 +13428,8 @@ snapshots:
dependencies:
sax: 1.4.4
+ xml-name-validator@5.0.0: {}
+
xml2js@0.5.0:
dependencies:
sax: 1.4.4
@@ -13017,6 +13444,8 @@ snapshots:
xmlbuilder@15.1.1: {}
+ xmlchars@2.2.0: {}
+
xpath@0.0.27: {}
xpath@0.0.32: {}
diff --git a/src/components/ui/__tests__/StarRating.test.tsx b/src/components/ui/__tests__/StarRating.test.tsx
new file mode 100644
index 0000000..977bd33
--- /dev/null
+++ b/src/components/ui/__tests__/StarRating.test.tsx
@@ -0,0 +1,113 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { StarRating } from '../StarRating';
+
+describe('StarRating', () => {
+ // --- Read-only display ---
+
+ it('should render with img role when not interactive', () => {
+ render();
+
+ const container = screen.getByRole('img');
+ expect(container).toBeDefined();
+ });
+
+ it('should display rating in aria-label', () => {
+ render();
+
+ const container = screen.getByRole('img');
+ expect(container.getAttribute('aria-label')).toBe('Rating: 3.5 out of 5 stars');
+ });
+
+ it('should display 0 rating when rating is null', () => {
+ render();
+
+ const container = screen.getByRole('img');
+ expect(container.getAttribute('aria-label')).toBe('Rating: 0 out of 5 stars');
+ });
+
+ it('should not render interactive buttons when onRate is not provided', () => {
+ render();
+
+ const buttons = screen.queryAllByRole('button');
+ expect(buttons).toHaveLength(0);
+ });
+
+ // --- Interactive mode ---
+
+ it('should render with radiogroup role when interactive', () => {
+ render( {}} />);
+
+ const container = screen.getByRole('radiogroup');
+ expect(container).toBeDefined();
+ });
+
+ it('should render 10 interactive buttons when onRate is provided', () => {
+ render( {}} />);
+
+ // 5 stars x 2 halves = 10 buttons
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(10);
+ });
+
+ it('should call onRate with full star value when right half is clicked', () => {
+ const onRate = vi.fn();
+ render();
+
+ // "3 stars" button is the right half of the 3rd star
+ const button = screen.getByLabelText('3 stars');
+ fireEvent.click(button);
+
+ expect(onRate).toHaveBeenCalledWith(3);
+ });
+
+ it('should call onRate with half star value when left half is clicked', () => {
+ const onRate = vi.fn();
+ render();
+
+ // "2.5 stars" button is the left half of the 3rd star
+ const button = screen.getByLabelText('2.5 stars');
+ fireEvent.click(button);
+
+ expect(onRate).toHaveBeenCalledWith(2.5);
+ });
+
+ it('should call onRate with 0.5 when left half of first star is clicked', () => {
+ const onRate = vi.fn();
+ render();
+
+ const button = screen.getByLabelText('0.5 stars');
+ fireEvent.click(button);
+
+ expect(onRate).toHaveBeenCalledWith(0.5);
+ });
+
+ it('should call onRate with 5 when right half of last star is clicked', () => {
+ const onRate = vi.fn();
+ render();
+
+ const button = screen.getByLabelText('5 stars');
+ fireEvent.click(button);
+
+ expect(onRate).toHaveBeenCalledWith(5);
+ });
+
+ it('should have accessible labels for all half-star increments', () => {
+ render( {}} />);
+
+ const expectedLabels = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5];
+ for (const label of expectedLabels) {
+ expect(screen.getByLabelText(`${label} stars`)).toBeDefined();
+ }
+ });
+
+ // --- SVG rendering ---
+
+ it('should render exactly 5 star SVGs', () => {
+ const { container } = render();
+
+ const svgs = container.querySelectorAll('svg');
+ expect(svgs).toHaveLength(5);
+ });
+});
diff --git a/src/features/group/__tests__/AddSong.test.tsx b/src/features/group/__tests__/AddSong.test.tsx
new file mode 100644
index 0000000..dc30aad
--- /dev/null
+++ b/src/features/group/__tests__/AddSong.test.tsx
@@ -0,0 +1,165 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { AddSong } from '../AddSong';
+
+describe('AddSong', () => {
+ let mockOnAddSong: ReturnType Promise>>;
+
+ beforeEach(() => {
+ mockOnAddSong = vi.fn<(url: string) => Promise>();
+ });
+
+ it('should render input and submit button', () => {
+ render();
+
+ expect(screen.getByPlaceholderText('Paste a Spotify or YouTube link...')).toBeDefined();
+ expect(screen.getByRole('button', { name: 'Add' })).toBeDefined();
+ });
+
+ it('should disable submit button when input is empty', () => {
+ render();
+
+ const button = screen.getByRole('button', { name: 'Add' });
+ expect(button.hasAttribute('disabled')).toBe(true);
+ });
+
+ it('should enable submit button when input has value', () => {
+ render();
+
+ const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
+ fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });
+
+ const button = screen.getByRole('button', { name: 'Add' });
+ expect(button.hasAttribute('disabled')).toBe(false);
+ });
+
+ it('should call onAddSong with trimmed URL on submit', async () => {
+ mockOnAddSong.mockResolvedValue(undefined);
+ render();
+
+ const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
+ fireEvent.change(input, { target: { value: ' https://open.spotify.com/track/abc ' } });
+
+ const form = input.closest('form')!;
+ fireEvent.submit(form);
+
+ await waitFor(() => {
+ expect(mockOnAddSong).toHaveBeenCalledWith('https://open.spotify.com/track/abc');
+ });
+ });
+
+ it('should clear input on successful submission', async () => {
+ mockOnAddSong.mockResolvedValue(undefined);
+ render();
+
+ const input = screen.getByPlaceholderText(
+ 'Paste a Spotify or YouTube link...',
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });
+
+ const form = input.closest('form')!;
+ fireEvent.submit(form);
+
+ await waitFor(() => {
+ expect(input.value).toBe('');
+ });
+ });
+
+ it('should show error message when onAddSong rejects', async () => {
+ mockOnAddSong.mockRejectedValue(new Error('Song not found'));
+ render();
+
+ const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
+ fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/notfound' } });
+
+ const form = input.closest('form')!;
+ fireEvent.submit(form);
+
+ await waitFor(() => {
+ const alert = screen.getByRole('alert');
+ expect(alert.textContent).toBe('Song not found');
+ });
+ });
+
+ it('should show "Unknown error" when onAddSong rejects with non-Error', async () => {
+ mockOnAddSong.mockRejectedValue('string error');
+ render();
+
+ const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
+ fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });
+
+ const form = input.closest('form')!;
+ fireEvent.submit(form);
+
+ await waitFor(() => {
+ const alert = screen.getByRole('alert');
+ expect(alert.textContent).toBe('Unknown error');
+ });
+ });
+
+ it('should show "Resolving..." button text while submitting', async () => {
+ // Never resolve so we can see the loading state
+ mockOnAddSong.mockReturnValue(new Promise(() => {}));
+ render();
+
+ const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
+ fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });
+
+ const form = input.closest('form')!;
+ fireEvent.submit(form);
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Resolving...' })).toBeDefined();
+ });
+ });
+
+ it('should disable input while resolving', async () => {
+ mockOnAddSong.mockReturnValue(new Promise(() => {}));
+ render();
+
+ const input = screen.getByPlaceholderText(
+ 'Paste a Spotify or YouTube link...',
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });
+
+ const form = input.closest('form')!;
+ fireEvent.submit(form);
+
+ await waitFor(() => {
+ expect(input.disabled).toBe(true);
+ });
+ });
+
+ it('should clear error when input value changes after error', async () => {
+ mockOnAddSong.mockRejectedValue(new Error('fail'));
+ render();
+
+ const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
+ fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });
+
+ const form = input.closest('form')!;
+ fireEvent.submit(form);
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toBeDefined();
+ });
+
+ // Change input to clear error
+ fireEvent.change(input, { target: { value: 'new value' } });
+
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
+
+ it('should not call onAddSong when input is only whitespace', () => {
+ render();
+
+ const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
+ fireEvent.change(input, { target: { value: ' ' } });
+
+ const form = input.closest('form')!;
+ fireEvent.submit(form);
+
+ expect(mockOnAddSong).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/features/group/__tests__/SongCard.test.tsx b/src/features/group/__tests__/SongCard.test.tsx
new file mode 100644
index 0000000..66d8e57
--- /dev/null
+++ b/src/features/group/__tests__/SongCard.test.tsx
@@ -0,0 +1,173 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { SongCard } from '../SongCard';
+import type { Member, SongWithVotes, Vote } from '../../../lib/types';
+
+// --- Factories ---
+
+function makeSong(overrides: Partial = {}): SongWithVotes {
+ return {
+ id: 'song-1',
+ round_id: 'round-1',
+ member_id: 'member-1',
+ title: 'Bohemian Rhapsody',
+ artist: 'Queen',
+ album: 'A Night at the Opera',
+ thumbnail_url: 'https://i.scdn.co/image/abc',
+ platform_links: [{ platform: 'spotify', url: 'https://open.spotify.com/track/abc' }],
+ odesli_page_url: 'https://song.link/s/abc',
+ created_at: '2026-01-01T00:00:00Z',
+ votes: [],
+ avgRating: 0,
+ totalVotes: 0,
+ ...overrides,
+ };
+}
+
+function makeMember(overrides: Partial = {}): Member {
+ return {
+ id: 'member-1',
+ group_id: 'group-1',
+ name: 'Alice',
+ avatar: '🎵',
+ is_admin: false,
+ created_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+ };
+}
+
+function makeVote(overrides: Partial = {}): Vote {
+ return {
+ id: 'vote-1',
+ song_id: 'song-1',
+ member_id: 'member-2',
+ rating: 4,
+ created_at: '2026-01-02T00:00:00Z',
+ ...overrides,
+ };
+}
+
+const defaultProps = {
+ memberId: 'member-2',
+ members: [makeMember({ id: 'member-1', name: 'Alice' })],
+ onRate: vi.fn(),
+ locale: 'en' as const,
+};
+
+describe('SongCard', () => {
+ it('should render song title and artist', () => {
+ render();
+
+ expect(screen.getByText('Bohemian Rhapsody')).toBeDefined();
+ expect(screen.getByText('Queen')).toBeDefined();
+ });
+
+ it('should display who added the song', () => {
+ render();
+
+ expect(screen.getByText('Alice')).toBeDefined();
+ });
+
+ it('should display "?" when song author is not in members list', () => {
+ const song = makeSong({ member_id: 'unknown-member' });
+ render();
+
+ expect(screen.getByText('?')).toBeDefined();
+ });
+
+ it('should render album art when thumbnail_url is valid HTTPS', () => {
+ render();
+
+ const img = screen.getByRole('img');
+ expect(img.getAttribute('src')).toBe('https://i.scdn.co/image/abc');
+ });
+
+ it('should not render img when thumbnail_url is null', () => {
+ const song = makeSong({ thumbnail_url: null });
+ render();
+
+ expect(screen.queryByRole('img')).toBeNull();
+ });
+
+ it('should not render img when thumbnail_url uses HTTP', () => {
+ const song = makeSong({ thumbnail_url: 'http://insecure.com/image.jpg' });
+ render();
+
+ // The safeThumbUrl function should strip non-https URLs
+ expect(screen.queryByRole('img')).toBeNull();
+ });
+
+ it('should show "Your track" when song belongs to current member', () => {
+ const song = makeSong({ member_id: 'member-2' });
+ render();
+
+ expect(screen.getByText('Your track')).toBeDefined();
+ });
+
+ it('should show star rating when song does not belong to current member', () => {
+ const song = makeSong({ member_id: 'member-1' });
+ render();
+
+ // Should show interactive star rating (radiogroup)
+ expect(screen.getByRole('radiogroup')).toBeDefined();
+ });
+
+ it('should not show interactive stars when song belongs to current member', () => {
+ const song = makeSong({ member_id: 'member-2' });
+ render();
+
+ expect(screen.queryByRole('radiogroup')).toBeNull();
+ });
+
+ it('should display average rating', () => {
+ const song = makeSong({ avgRating: 3.5, totalVotes: 4 });
+ render();
+
+ expect(screen.getByText('3.5')).toBeDefined();
+ expect(screen.getByText('(4)')).toBeDefined();
+ });
+
+ it('should display the current user vote rating when they have voted', () => {
+ const vote = makeVote({ member_id: 'member-2', rating: 4.5 });
+ const song = makeSong({
+ votes: [vote],
+ avgRating: 4.5,
+ totalVotes: 1,
+ });
+ render();
+
+ // Both the user's rating and the group average show "4.5"
+ const elements = screen.getAllByText('4.5');
+ expect(elements).toHaveLength(2);
+ });
+
+ it('should render platform links', () => {
+ const song = makeSong({
+ platform_links: [
+ { platform: 'spotify', url: 'https://open.spotify.com/track/abc' },
+ { platform: 'youtubeMusic', url: 'https://music.youtube.com/watch?v=abc' },
+ ],
+ });
+ render();
+
+ expect(screen.getByText('Spotify')).toBeDefined();
+ expect(screen.getByText('YT Music')).toBeDefined();
+ });
+
+ it('should render album cover alt text with album name when available', () => {
+ const song = makeSong({ album: 'A Night at the Opera' });
+ render();
+
+ const img = screen.getByRole('img');
+ expect(img.getAttribute('alt')).toBe('A Night at the Opera cover');
+ });
+
+ it('should use title in alt text when album is null', () => {
+ const song = makeSong({ album: null });
+ render();
+
+ const img = screen.getByRole('img');
+ expect(img.getAttribute('alt')).toBe('Bohemian Rhapsody cover');
+ });
+});
diff --git a/src/features/group/__tests__/useGroup.test.ts b/src/features/group/__tests__/useGroup.test.ts
new file mode 100644
index 0000000..ca20d15
--- /dev/null
+++ b/src/features/group/__tests__/useGroup.test.ts
@@ -0,0 +1,228 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useGroup } from '../useGroup';
+import type { Round } from '../../../lib/types';
+
+// --- Mocks at boundaries ---
+
+const mockEnsureCurrentRound = vi.fn();
+vi.mock('../../../lib/rounds', () => ({
+ ensureCurrentRound: (...args: unknown[]) => mockEnsureCurrentRound(...args),
+}));
+
+const mockResolveSongLink = vi.fn();
+vi.mock('../../../lib/odesli', () => ({
+ resolveSongLink: (...args: unknown[]) => mockResolveSongLink(...args),
+}));
+
+vi.mock('../../../lib/supabase', () => ({
+ supabase: {
+ from: () => ({
+ select: () => ({
+ eq: () => ({
+ order: () => Promise.resolve({ data: [], error: null }),
+ }),
+ in: () => Promise.resolve({ data: [], error: null }),
+ }),
+ insert: () => ({
+ select: () => ({
+ single: () => Promise.resolve({ data: null, error: null }),
+ }),
+ }),
+ upsert: () => Promise.resolve({ error: null }),
+ }),
+ },
+}));
+
+// --- Factories ---
+
+function makeRound(overrides: Partial = {}): Round {
+ return {
+ id: 'round-1',
+ group_id: 'group-1',
+ number: 1,
+ starts_at: '2026-02-23T00:00:00.000Z',
+ ends_at: '2026-03-01T23:59:59.999Z',
+ created_at: '2026-02-23T00:00:00.000Z',
+ ...overrides,
+ };
+}
+
+describe('useGroup', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should start in loading state', () => {
+ mockEnsureCurrentRound.mockReturnValue(new Promise(() => {}));
+
+ const { result } = renderHook(() => useGroup('group-1', 'member-1'));
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.songs).toEqual([]);
+ expect(result.current.round).toBeNull();
+ expect(result.current.error).toBeNull();
+ });
+
+ it('should load round and empty songs on initialization', async () => {
+ const round = makeRound();
+ mockEnsureCurrentRound.mockResolvedValue(round);
+
+ const { result } = renderHook(() => useGroup('group-1', 'member-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.round).toEqual(round);
+ expect(result.current.songs).toEqual([]);
+ expect(result.current.error).toBeNull();
+ });
+
+ it('should set error when ensureCurrentRound fails', async () => {
+ mockEnsureCurrentRound.mockRejectedValue(new Error('DB connection failed'));
+
+ const { result } = renderHook(() => useGroup('group-1', 'member-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.error).toBe('DB connection failed');
+ expect(result.current.round).toBeNull();
+ });
+
+ it('should pass groupId to ensureCurrentRound', async () => {
+ mockEnsureCurrentRound.mockResolvedValue(makeRound());
+
+ renderHook(() => useGroup('my-group-id', 'member-1'));
+
+ await waitFor(() => {
+ expect(mockEnsureCurrentRound).toHaveBeenCalledWith('my-group-id');
+ });
+ });
+
+ it('should throw error when addSong is called without a round', async () => {
+ mockEnsureCurrentRound.mockRejectedValue(new Error('fail'));
+
+ const { result } = renderHook(() => useGroup('group-1', 'member-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ await expect(result.current.addSong('https://open.spotify.com/track/abc')).rejects.toThrow(
+ 'Cannot add song: no active round or member',
+ );
+ });
+
+ it('should throw error when addSong is called without a member ID', async () => {
+ mockEnsureCurrentRound.mockResolvedValue(makeRound());
+
+ const { result } = renderHook(() => useGroup('group-1', null));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ await expect(result.current.addSong('https://open.spotify.com/track/abc')).rejects.toThrow(
+ 'Cannot add song: no active round or member',
+ );
+ });
+
+ it('should throw error when voteSong is called without a member ID', async () => {
+ mockEnsureCurrentRound.mockResolvedValue(makeRound());
+
+ const { result } = renderHook(() => useGroup('group-1', null));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ await expect(result.current.voteSong('song-1', 4)).rejects.toThrow('Cannot vote: not a member');
+ });
+
+ it('should throw error when rating exceeds 5', async () => {
+ mockEnsureCurrentRound.mockResolvedValue(makeRound());
+
+ const { result } = renderHook(() => useGroup('group-1', 'member-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ // 5.3 rounds to 5.5 which is > 5
+ await expect(result.current.voteSong('song-1', 5.3)).rejects.toThrow(
+ 'Rating must be between 0 and 5',
+ );
+ });
+
+ it('should throw error when rating is negative', async () => {
+ mockEnsureCurrentRound.mockResolvedValue(makeRound());
+
+ const { result } = renderHook(() => useGroup('group-1', 'member-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ await expect(result.current.voteSong('song-1', -1)).rejects.toThrow(
+ 'Rating must be between 0 and 5',
+ );
+ });
+
+ it('should default songsPerRound to 3', () => {
+ mockEnsureCurrentRound.mockReturnValue(new Promise(() => {}));
+
+ const { result } = renderHook(() => useGroup('group-1', 'member-1'));
+
+ // Verify the hook initializes (the default is in the function signature)
+ expect(result.current.songs).toEqual([]);
+ });
+
+ it('should expose refetch, addSong, and voteSong functions', async () => {
+ mockEnsureCurrentRound.mockResolvedValue(makeRound());
+
+ const { result } = renderHook(() => useGroup('group-1', 'member-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(typeof result.current.refetch).toBe('function');
+ expect(typeof result.current.addSong).toBe('function');
+ expect(typeof result.current.voteSong).toBe('function');
+ });
+
+ it('should use provided initial members', () => {
+ mockEnsureCurrentRound.mockReturnValue(new Promise(() => {}));
+
+ const members = [
+ {
+ id: 'member-1',
+ group_id: 'group-1',
+ name: 'Alice',
+ avatar: '🎵',
+ is_admin: false,
+ created_at: '2026-01-01T00:00:00Z',
+ },
+ ];
+
+ const { result } = renderHook(() => useGroup('group-1', 'member-1', 3, members));
+
+ expect(result.current.members).toEqual(members);
+ });
+
+ it('should set error with generic message when non-Error is thrown', async () => {
+ mockEnsureCurrentRound.mockRejectedValue('some string error');
+
+ const { result } = renderHook(() => useGroup('group-1', 'member-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.error).toBe('Failed to load group data');
+ });
+});
diff --git a/src/features/group/useGroup.ts b/src/features/group/useGroup.ts
index 307ba01..f6dd2f9 100644
--- a/src/features/group/useGroup.ts
+++ b/src/features/group/useGroup.ts
@@ -35,7 +35,7 @@ function enrichSong(song: Song, votes: Vote[]): SongWithVotes {
export function useGroup(
groupId: string,
memberId: string | null,
- songsPerRound: number = 5,
+ songsPerRound: number = 3,
initialMembers: Member[] = [],
): UseGroupResult {
const [round, setRound] = useState(null);
diff --git a/src/features/join/CreateGroup.tsx b/src/features/join/CreateGroup.tsx
index 9e2c3bc..f29b5ca 100644
--- a/src/features/join/CreateGroup.tsx
+++ b/src/features/join/CreateGroup.tsx
@@ -45,7 +45,7 @@ export function CreateGroup({ locale }: CreateGroupProps) {
const slug = generateCode();
const { error: groupError } = await supabase
.from('groups')
- .insert({ name: trimmed, slug, songs_per_round: 5 })
+ .insert({ name: trimmed, slug, songs_per_round: 3 })
.select()
.single();
diff --git a/src/features/join/__tests__/JoinGroup.test.tsx b/src/features/join/__tests__/JoinGroup.test.tsx
new file mode 100644
index 0000000..65ca473
--- /dev/null
+++ b/src/features/join/__tests__/JoinGroup.test.tsx
@@ -0,0 +1,174 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { JoinGroup } from '../JoinGroup';
+import type { Member } from '../../../lib/types';
+
+// --- Mocks at boundaries ---
+
+const mockSetMemberId = vi.fn();
+vi.mock('../../../lib/storage', () => ({
+ setMemberId: (...args: unknown[]) => mockSetMemberId(...args),
+}));
+
+const mockSupabaseInsert = vi.fn();
+const mockSupabaseSelect = vi.fn();
+
+vi.mock('../../../lib/supabase', () => ({
+ supabase: {
+ from: () => ({
+ select: () => ({
+ eq: () => ({
+ order: () => Promise.resolve({ data: [], error: null }),
+ single: () => mockSupabaseSelect(),
+ }),
+ }),
+ insert: () => ({
+ select: () => ({
+ single: () => mockSupabaseInsert(),
+ }),
+ }),
+ }),
+ },
+}));
+
+// --- Factories ---
+
+function makeMember(overrides: Partial = {}): Member {
+ return {
+ id: 'member-1',
+ group_id: 'group-1',
+ name: 'Alice',
+ avatar: '🎵',
+ is_admin: false,
+ created_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+ };
+}
+
+// Mock window.location.reload
+const mockReload = vi.fn();
+Object.defineProperty(window, 'location', {
+ value: { reload: mockReload },
+ writable: true,
+});
+
+describe('JoinGroup', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // --- Member selection step ---
+
+ it('should show member selection when members are provided', () => {
+ const members = [
+ makeMember({ id: 'm1', name: 'Alice' }),
+ makeMember({ id: 'm2', name: 'Bob' }),
+ ];
+
+ render();
+
+ expect(screen.getByText('Who are you?')).toBeDefined();
+ expect(screen.getByText('Alice')).toBeDefined();
+ expect(screen.getByText('Bob')).toBeDefined();
+ });
+
+ it('should show create form when no members exist', () => {
+ render();
+
+ expect(screen.getByPlaceholderText('Your name')).toBeDefined();
+ expect(screen.getByText('Join group')).toBeDefined();
+ });
+
+ it('should save member ID and reload when selecting existing member', () => {
+ const members = [makeMember({ id: 'm1', name: 'Alice' })];
+
+ render();
+
+ const aliceButton = screen.getByRole('option', { name: /Alice/ });
+ fireEvent.click(aliceButton);
+
+ expect(mockSetMemberId).toHaveBeenCalledWith('test-group', 'm1');
+ expect(mockReload).toHaveBeenCalled();
+ });
+
+ it('should switch to create form when "I\'m new here" is clicked', () => {
+ const members = [makeMember({ id: 'm1', name: 'Alice' })];
+
+ render();
+
+ fireEvent.click(screen.getByText("I'm new here"));
+
+ expect(screen.getByPlaceholderText('Your name')).toBeDefined();
+ });
+
+ // --- Create member step ---
+
+ it('should show error when name is too short', async () => {
+ render();
+
+ const input = screen.getByPlaceholderText('Your name');
+ fireEvent.change(input, { target: { value: 'A' } });
+
+ const form = input.closest('form')!;
+ fireEvent.submit(form);
+
+ await waitFor(() => {
+ const alert = screen.getByRole('alert');
+ expect(alert.textContent).toBe('Name is required');
+ });
+ });
+
+ it('should disable submit button when name is empty', () => {
+ render();
+
+ const button = screen.getByRole('button', { name: 'Join group' });
+ expect(button.hasAttribute('disabled')).toBe(true);
+ });
+
+ it('should render emoji picker for avatar selection', () => {
+ render();
+
+ expect(screen.getByText('Pick your avatar')).toBeDefined();
+ expect(screen.getByRole('radiogroup')).toBeDefined();
+ });
+
+ it('should show back button to member selection when members exist', () => {
+ const members = [makeMember({ id: 'm1', name: 'Alice' })];
+
+ render();
+
+ // Switch to create step
+ fireEvent.click(screen.getByText("I'm new here"));
+
+ // Should show a back button
+ const backButton = screen.getByText('Who are you?');
+ expect(backButton).toBeDefined();
+ });
+
+ it('should render join title', () => {
+ render();
+
+ expect(screen.getByText('Join this group')).toBeDefined();
+ });
+
+ it('should render listbox for member selection', () => {
+ const members = [makeMember({ id: 'm1', name: 'Alice' })];
+
+ render();
+
+ expect(screen.getByRole('listbox')).toBeDefined();
+ });
+
+ it('should display member avatars in the selection list', () => {
+ const members = [
+ makeMember({ id: 'm1', name: 'Alice', avatar: '🎵' }),
+ makeMember({ id: 'm2', name: 'Bob', avatar: '🎸' }),
+ ];
+
+ render();
+
+ expect(screen.getByText('🎵')).toBeDefined();
+ expect(screen.getByText('🎸')).toBeDefined();
+ });
+});
diff --git a/src/features/leaderboard/__tests__/useLeaderboard.test.ts b/src/features/leaderboard/__tests__/useLeaderboard.test.ts
new file mode 100644
index 0000000..fe6e309
--- /dev/null
+++ b/src/features/leaderboard/__tests__/useLeaderboard.test.ts
@@ -0,0 +1,356 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useLeaderboard } from '../useLeaderboard';
+import type { Member, Round, Song, Vote } from '../../../lib/types';
+
+// --- Configurable supabase mock ---
+
+let mockData: {
+ members: { data: Member[] | null; error: unknown };
+ rounds: { data: Round[] | null; error: unknown };
+ songs: { data: Song[] | null; error: unknown };
+ votes: { data: Vote[] | null; error: unknown };
+};
+
+function resetMockData() {
+ mockData = {
+ members: { data: [], error: null },
+ rounds: { data: [], error: null },
+ songs: { data: [], error: null },
+ votes: { data: [], error: null },
+ };
+}
+
+vi.mock('../../../lib/supabase', () => ({
+ supabase: {
+ from: (table: string) => {
+ const tableData = () => {
+ switch (table) {
+ case 'members':
+ return mockData.members;
+ case 'rounds':
+ return mockData.rounds;
+ case 'songs':
+ return mockData.songs;
+ case 'votes':
+ return mockData.votes;
+ default:
+ return { data: [], error: null };
+ }
+ };
+
+ const chain: Record = {};
+ const resolve = () => Promise.resolve(tableData());
+
+ // Build a chainable mock that always resolves with the table's data
+ chain.select = () => chain;
+ chain.eq = () => chain;
+ chain.in = () => resolve();
+ chain.order = () => resolve();
+ chain.then = (fn: (v: unknown) => void) => resolve().then(fn);
+
+ return chain;
+ },
+ },
+}));
+
+// --- Factories ---
+
+function makeMember(overrides: Partial = {}): Member {
+ return {
+ id: 'member-1',
+ group_id: 'group-1',
+ name: 'Alice',
+ avatar: '🎵',
+ is_admin: false,
+ created_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+ };
+}
+
+function makeRound(overrides: Partial = {}): Round {
+ return {
+ id: 'round-1',
+ group_id: 'group-1',
+ number: 1,
+ starts_at: '2026-01-01T00:00:00Z',
+ ends_at: '2026-01-07T23:59:59Z',
+ created_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+ };
+}
+
+function makeSong(overrides: Partial = {}): Song {
+ return {
+ id: 'song-1',
+ round_id: 'round-1',
+ member_id: 'member-1',
+ title: 'Test Song',
+ artist: 'Test Artist',
+ album: null,
+ thumbnail_url: null,
+ platform_links: [],
+ odesli_page_url: null,
+ created_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+ };
+}
+
+function makeVote(overrides: Partial = {}): Vote {
+ return {
+ id: 'vote-1',
+ song_id: 'song-1',
+ member_id: 'member-2',
+ rating: 4,
+ created_at: '2026-01-02T00:00:00Z',
+ ...overrides,
+ };
+}
+
+describe('useLeaderboard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ resetMockData();
+ });
+
+ it('should start in loading state', () => {
+ const { result } = renderHook(() => useLeaderboard('group-1'));
+
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.members).toEqual([]);
+ expect(result.current.pastRounds).toEqual([]);
+ expect(result.current.error).toBeNull();
+ });
+
+ it('should return members with zero stats when no rounds exist', async () => {
+ const alice = makeMember({ id: 'alice', name: 'Alice' });
+ mockData.members = { data: [alice], error: null };
+ mockData.rounds = { data: [], error: null };
+
+ const { result } = renderHook(() => useLeaderboard('group-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.members).toHaveLength(1);
+ expect(result.current.members[0].totalScore).toBe(0);
+ expect(result.current.members[0].songsAdded).toBe(0);
+ expect(result.current.members[0].avgReceived).toBe(0);
+ expect(result.current.members[0].roundsWon).toBe(0);
+ });
+
+ it('should calculate totalScore as sum of all ratings received', async () => {
+ const alice = makeMember({ id: 'alice' });
+ const currentRound = makeRound({ id: 'round-1', number: 1 });
+ const song = makeSong({ id: 'song-a', member_id: 'alice', round_id: 'round-1' });
+ const votes = [
+ makeVote({ id: 'v1', song_id: 'song-a', member_id: 'bob', rating: 4 }),
+ makeVote({ id: 'v2', song_id: 'song-a', member_id: 'carol', rating: 3 }),
+ ];
+
+ mockData.members = { data: [alice], error: null };
+ mockData.rounds = { data: [currentRound], error: null };
+ mockData.songs = { data: [song], error: null };
+ mockData.votes = { data: votes, error: null };
+
+ const { result } = renderHook(() => useLeaderboard('group-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.members[0].totalScore).toBe(7); // 4 + 3
+ });
+
+ it('should calculate avgReceived as mean of all ratings', async () => {
+ const alice = makeMember({ id: 'alice' });
+ const currentRound = makeRound({ id: 'round-1', number: 1 });
+ const song = makeSong({ id: 'song-a', member_id: 'alice', round_id: 'round-1' });
+ const votes = [
+ makeVote({ id: 'v1', song_id: 'song-a', member_id: 'bob', rating: 4 }),
+ makeVote({ id: 'v2', song_id: 'song-a', member_id: 'carol', rating: 2 }),
+ ];
+
+ mockData.members = { data: [alice], error: null };
+ mockData.rounds = { data: [currentRound], error: null };
+ mockData.songs = { data: [song], error: null };
+ mockData.votes = { data: votes, error: null };
+
+ const { result } = renderHook(() => useLeaderboard('group-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.members[0].avgReceived).toBe(3); // (4 + 2) / 2
+ });
+
+ it('should count songsAdded per member', async () => {
+ const alice = makeMember({ id: 'alice' });
+ const currentRound = makeRound({ id: 'round-1', number: 1 });
+ const songs = [
+ makeSong({ id: 'song-1', member_id: 'alice', round_id: 'round-1' }),
+ makeSong({ id: 'song-2', member_id: 'alice', round_id: 'round-1' }),
+ ];
+
+ mockData.members = { data: [alice], error: null };
+ mockData.rounds = { data: [currentRound], error: null };
+ mockData.songs = { data: songs, error: null };
+ mockData.votes = { data: [], error: null };
+
+ const { result } = renderHook(() => useLeaderboard('group-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.members[0].songsAdded).toBe(2);
+ });
+
+ it('should sort members by totalScore descending', async () => {
+ const alice = makeMember({ id: 'alice', name: 'Alice' });
+ const bob = makeMember({ id: 'bob', name: 'Bob' });
+ const round = makeRound({ id: 'round-1', number: 1 });
+ const songs = [
+ makeSong({ id: 'song-a', member_id: 'alice', round_id: 'round-1' }),
+ makeSong({ id: 'song-b', member_id: 'bob', round_id: 'round-1' }),
+ ];
+ const votes = [
+ makeVote({ id: 'v1', song_id: 'song-a', member_id: 'bob', rating: 2 }),
+ makeVote({ id: 'v2', song_id: 'song-b', member_id: 'alice', rating: 5 }),
+ ];
+
+ mockData.members = { data: [alice, bob], error: null };
+ mockData.rounds = { data: [round], error: null };
+ mockData.songs = { data: songs, error: null };
+ mockData.votes = { data: votes, error: null };
+
+ const { result } = renderHook(() => useLeaderboard('group-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.members[0].id).toBe('bob'); // score 5
+ expect(result.current.members[1].id).toBe('alice'); // score 2
+ });
+
+ it('should not count current round toward roundsWon', async () => {
+ const alice = makeMember({ id: 'alice' });
+ const currentRound = makeRound({ id: 'round-1', number: 1 });
+ const song = makeSong({ id: 'song-a', member_id: 'alice', round_id: 'round-1' });
+ const votes = [makeVote({ id: 'v1', song_id: 'song-a', member_id: 'bob', rating: 5 })];
+
+ mockData.members = { data: [alice], error: null };
+ // Most recent round (index 0) is treated as current
+ mockData.rounds = { data: [currentRound], error: null };
+ mockData.songs = { data: [song], error: null };
+ mockData.votes = { data: votes, error: null };
+
+ const { result } = renderHook(() => useLeaderboard('group-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.members[0].roundsWon).toBe(0);
+ });
+
+ it('should count past round wins correctly', async () => {
+ const alice = makeMember({ id: 'alice' });
+ const bob = makeMember({ id: 'bob' });
+ // Descending: round-2 is current, round-1 is past
+ const currentRound = makeRound({ id: 'round-2', number: 2 });
+ const pastRound = makeRound({ id: 'round-1', number: 1 });
+ const songs = [
+ makeSong({ id: 'alice-s1', member_id: 'alice', round_id: 'round-1' }),
+ makeSong({ id: 'bob-s1', member_id: 'bob', round_id: 'round-1' }),
+ ];
+ const votes = [
+ // Alice wins round 1 (higher avg)
+ makeVote({ id: 'v1', song_id: 'alice-s1', member_id: 'bob', rating: 5 }),
+ makeVote({ id: 'v2', song_id: 'bob-s1', member_id: 'alice', rating: 2 }),
+ ];
+
+ mockData.members = { data: [alice, bob], error: null };
+ mockData.rounds = { data: [currentRound, pastRound], error: null };
+ mockData.songs = { data: songs, error: null };
+ mockData.votes = { data: votes, error: null };
+
+ const { result } = renderHook(() => useLeaderboard('group-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ const aliceStats = result.current.members.find((m) => m.id === 'alice')!;
+ expect(aliceStats.roundsWon).toBe(1);
+ });
+
+ it('should build pastRounds summary excluding current round', async () => {
+ const alice = makeMember({ id: 'alice', name: 'Alice' });
+ const currentRound = makeRound({ id: 'round-2', number: 2 });
+ const pastRound = makeRound({ id: 'round-1', number: 1 });
+ const song = makeSong({
+ id: 'song-a',
+ member_id: 'alice',
+ round_id: 'round-1',
+ title: 'Bohemian Rhapsody',
+ artist: 'Queen',
+ });
+ const votes = [makeVote({ id: 'v1', song_id: 'song-a', member_id: 'bob', rating: 4.5 })];
+
+ mockData.members = { data: [alice], error: null };
+ mockData.rounds = { data: [currentRound, pastRound], error: null };
+ mockData.songs = { data: [song], error: null };
+ mockData.votes = { data: votes, error: null };
+
+ const { result } = renderHook(() => useLeaderboard('group-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.pastRounds).toHaveLength(1);
+ expect(result.current.pastRounds[0]).toEqual({
+ number: 1,
+ winner: 'Alice',
+ topSong: 'Bohemian Rhapsody',
+ topArtist: 'Queen',
+ topScore: 4.5,
+ });
+ });
+
+ it('should set error when members fetch fails', async () => {
+ mockData.members = { data: null, error: { message: 'connection refused' } };
+ mockData.rounds = { data: [], error: null };
+
+ const { result } = renderHook(() => useLeaderboard('group-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.error).toContain('Failed to fetch members');
+ });
+
+ it('should return empty pastRounds when there is only current round', async () => {
+ const alice = makeMember({ id: 'alice' });
+ const currentRound = makeRound({ id: 'round-1', number: 1 });
+
+ mockData.members = { data: [alice], error: null };
+ mockData.rounds = { data: [currentRound], error: null };
+ mockData.songs = { data: [], error: null };
+ mockData.votes = { data: [], error: null };
+
+ const { result } = renderHook(() => useLeaderboard('group-1'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.pastRounds).toEqual([]);
+ });
+});
diff --git a/src/hooks/__tests__/useMember.test.ts b/src/hooks/__tests__/useMember.test.ts
new file mode 100644
index 0000000..69f1487
--- /dev/null
+++ b/src/hooks/__tests__/useMember.test.ts
@@ -0,0 +1,134 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useMember } from '../useMember';
+import type { Member } from '../../lib/types';
+
+// --- Mocks at boundaries ---
+
+const mockMaybeSingle = vi.fn();
+const mockEq = vi.fn(() => ({ maybeSingle: mockMaybeSingle }));
+const mockSelect = vi.fn(() => ({ eq: mockEq }));
+const mockFrom = vi.fn((_table: string) => ({ select: mockSelect }));
+
+vi.mock('../../lib/supabase', () => ({
+ supabase: {
+ from: (table: string) => mockFrom(table),
+ },
+}));
+
+vi.mock('../../lib/storage', () => ({
+ getMemberId: vi.fn(),
+}));
+
+import { getMemberId } from '../../lib/storage';
+
+const VALID_UUID = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d';
+
+function makeMember(overrides: Partial = {}): Member {
+ return {
+ id: VALID_UUID,
+ group_id: 'group-1',
+ name: 'Alice',
+ avatar: '🎵',
+ is_admin: false,
+ created_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+ };
+}
+
+describe('useMember', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return null member when no member ID is stored', async () => {
+ vi.mocked(getMemberId).mockReturnValue(null);
+
+ const { result } = renderHook(() => useMember('test-group'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.member).toBeNull();
+ });
+
+ it('should fetch and return member when valid ID is stored', async () => {
+ const member = makeMember();
+ vi.mocked(getMemberId).mockReturnValue(VALID_UUID);
+ mockEq.mockReturnValue({
+ maybeSingle: vi.fn().mockResolvedValue({ data: member, error: null }),
+ });
+
+ const { result } = renderHook(() => useMember('test-group'));
+
+ expect(result.current.isLoading).toBe(true);
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.member).toEqual(member);
+ });
+
+ it('should return null member when supabase returns error', async () => {
+ vi.mocked(getMemberId).mockReturnValue(VALID_UUID);
+ mockEq.mockReturnValue({
+ maybeSingle: vi.fn().mockResolvedValue({
+ data: null,
+ error: { message: 'not found' },
+ }),
+ });
+
+ const { result } = renderHook(() => useMember('test-group'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.member).toBeNull();
+ });
+
+ it('should return null member when supabase returns null data', async () => {
+ vi.mocked(getMemberId).mockReturnValue(VALID_UUID);
+ mockEq.mockReturnValue({
+ maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
+ });
+
+ const { result } = renderHook(() => useMember('test-group'));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.member).toBeNull();
+ });
+
+ it('should call getMemberId with the slug', async () => {
+ vi.mocked(getMemberId).mockReturnValue(null);
+
+ renderHook(() => useMember('my-cool-group'));
+
+ await waitFor(() => {
+ expect(getMemberId).toHaveBeenCalledWith('my-cool-group');
+ });
+ });
+
+ it('should query supabase members table with the stored ID', async () => {
+ vi.mocked(getMemberId).mockReturnValue(VALID_UUID);
+ mockEq.mockReturnValue({
+ maybeSingle: vi.fn().mockResolvedValue({ data: makeMember(), error: null }),
+ });
+
+ renderHook(() => useMember('test-group'));
+
+ await waitFor(() => {
+ expect(mockFrom).toHaveBeenCalledWith('members');
+ });
+ });
+});
diff --git a/src/lib/__tests__/rate-limiter.test.ts b/src/lib/__tests__/rate-limiter.test.ts
new file mode 100644
index 0000000..5d7e38b
--- /dev/null
+++ b/src/lib/__tests__/rate-limiter.test.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { createRateLimiter } from '../rate-limiter';
+
+describe('createRateLimiter', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should allow requests under the limit', () => {
+ const limiter = createRateLimiter({ maxRequests: 3, windowMs: 60_000 });
+
+ expect(limiter.check('192.168.1.1')).toBe(true);
+ expect(limiter.check('192.168.1.1')).toBe(true);
+ expect(limiter.check('192.168.1.1')).toBe(true);
+ });
+
+ it('should block requests that exceed the limit', () => {
+ const limiter = createRateLimiter({ maxRequests: 3, windowMs: 60_000 });
+
+ expect(limiter.check('192.168.1.1')).toBe(true);
+ expect(limiter.check('192.168.1.1')).toBe(true);
+ expect(limiter.check('192.168.1.1')).toBe(true);
+ expect(limiter.check('192.168.1.1')).toBe(false);
+ expect(limiter.check('192.168.1.1')).toBe(false);
+ });
+
+ it('should track each key independently', () => {
+ const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 });
+
+ expect(limiter.check('ip-a')).toBe(true);
+ expect(limiter.check('ip-a')).toBe(true);
+ expect(limiter.check('ip-a')).toBe(false);
+
+ // Different key should still be allowed
+ expect(limiter.check('ip-b')).toBe(true);
+ expect(limiter.check('ip-b')).toBe(true);
+ expect(limiter.check('ip-b')).toBe(false);
+ });
+
+ it('should allow requests again after the window expires (sliding window)', () => {
+ const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 });
+
+ expect(limiter.check('ip-a')).toBe(true);
+ expect(limiter.check('ip-a')).toBe(true);
+ expect(limiter.check('ip-a')).toBe(false);
+
+ // Advance time past the window
+ vi.advanceTimersByTime(60_001);
+
+ expect(limiter.check('ip-a')).toBe(true);
+ });
+
+ it('should use sliding window — only old timestamps expire', () => {
+ const limiter = createRateLimiter({ maxRequests: 3, windowMs: 60_000 });
+
+ // t=0s: first request
+ expect(limiter.check('ip-a')).toBe(true);
+
+ // t=20s: second request
+ vi.advanceTimersByTime(20_000);
+ expect(limiter.check('ip-a')).toBe(true);
+
+ // t=40s: third request
+ vi.advanceTimersByTime(20_000);
+ expect(limiter.check('ip-a')).toBe(true);
+
+ // t=40s: fourth request — blocked
+ expect(limiter.check('ip-a')).toBe(false);
+
+ // t=61s: first request has expired, but second and third are still within window
+ vi.advanceTimersByTime(21_000);
+ expect(limiter.check('ip-a')).toBe(true);
+
+ // Now at limit again (requests at t=20, t=40, t=61)
+ expect(limiter.check('ip-a')).toBe(false);
+ });
+
+ it('should clean up stale entries when cleanup runs', () => {
+ const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 });
+
+ // Create entries for many keys (100 checks triggers internal cleanup)
+ for (let i = 0; i < 99; i++) {
+ limiter.check(`ip-${i}`);
+ }
+ expect(limiter.size()).toBe(99);
+
+ // Advance past the window so all entries become stale
+ vi.advanceTimersByTime(60_001);
+
+ // The 100th check triggers cleanup
+ limiter.check('ip-new');
+
+ // Only the new entry should remain after cleanup
+ expect(limiter.size()).toBe(1);
+ });
+
+ it('should use default values when no options are provided', () => {
+ const limiter = createRateLimiter();
+
+ // Default is 10 requests per 60s — should allow 10
+ for (let i = 0; i < 10; i++) {
+ expect(limiter.check('ip-a')).toBe(true);
+ }
+ expect(limiter.check('ip-a')).toBe(false);
+ });
+
+ it('should handle empty string key', () => {
+ const limiter = createRateLimiter({ maxRequests: 1, windowMs: 60_000 });
+
+ expect(limiter.check('')).toBe(true);
+ expect(limiter.check('')).toBe(false);
+ });
+});
diff --git a/src/lib/__tests__/security.test.ts b/src/lib/__tests__/security.test.ts
new file mode 100644
index 0000000..ca72b3c
--- /dev/null
+++ b/src/lib/__tests__/security.test.ts
@@ -0,0 +1,250 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { getMemberId, setMemberId } from '../storage';
+import { resolveSongLink } from '../odesli';
+
+// --- Mocks ---
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('Security: storage XSS prevention', () => {
+ const mockStorage = new Map();
+
+ beforeEach(() => {
+ mockStorage.clear();
+
+ const mockLocalStorage = {
+ getItem: (key: string) => mockStorage.get(key) ?? null,
+ setItem: (key: string, value: string) => mockStorage.set(key, value),
+ removeItem: (key: string) => mockStorage.delete(key),
+ };
+
+ vi.stubGlobal('window', { localStorage: mockLocalStorage });
+ vi.stubGlobal('localStorage', mockLocalStorage);
+ });
+
+ it('should reject slug containing script tags', () => {
+ const result = getMemberId('');
+ expect(result).toBeNull();
+ });
+
+ it('should reject slug with URL-encoded XSS payload', () => {
+ const result = getMemberId('%3Cscript%3Ealert(1)%3C/script%3E');
+ expect(result).toBeNull();
+ });
+
+ it('should reject slug with special characters', () => {
+ const result = getMemberId('../../etc/passwd');
+ expect(result).toBeNull();
+ });
+
+ it('should reject slug with HTML entities', () => {
+ const result = getMemberId('test&group');
+ expect(result).toBeNull();
+ });
+
+ it('should reject slug with quotes', () => {
+ const result = getMemberId("test'group");
+ expect(result).toBeNull();
+ });
+
+ it('should reject member ID with script payload', () => {
+ setMemberId('my-group', '');
+ expect(mockStorage.has('aux:members:my-group')).toBe(false);
+ });
+
+ it('should reject member ID with SQL injection attempt', () => {
+ setMemberId('my-group', "'; DROP TABLE members; --");
+ expect(mockStorage.has('aux:members:my-group')).toBe(false);
+ });
+
+ it('should sanitize slug in storage key to prevent key injection', () => {
+ // Even if someone bypasses the SLUG_RE check externally,
+ // the storage key function strips non-alphanumeric chars
+ const validUuid = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d';
+ setMemberId('a', validUuid);
+
+ // The storage key should be clean
+ expect(mockStorage.get('aux:members:a')).toBe(validUuid);
+ });
+});
+
+describe('Security: URL validation in odesli', () => {
+ it('should reject javascript: protocol URLs', async () => {
+ await expect(resolveSongLink('javascript:alert(document.cookie)')).rejects.toThrow(
+ 'URL must use HTTPS',
+ );
+ });
+
+ it('should reject data: protocol URLs', async () => {
+ await expect(resolveSongLink('data:text/html,')).rejects.toThrow(
+ 'URL must use HTTPS',
+ );
+ });
+
+ it('should reject URLs with embedded credentials', async () => {
+ await expect(resolveSongLink('https://evil@open.spotify.com/track/abc')).rejects.toThrow();
+ });
+
+ it('should reject HTTP URLs to prevent MITM', async () => {
+ await expect(resolveSongLink('http://open.spotify.com/track/abc')).rejects.toThrow(
+ 'URL must use HTTPS',
+ );
+ });
+
+ it('should reject URLs from non-music domains', async () => {
+ await expect(resolveSongLink('https://evil.com/track/abc')).rejects.toThrow(
+ 'URL must be from a supported music platform',
+ );
+ });
+
+ it('should reject URLs with XSS payloads in path', async () => {
+ await expect(resolveSongLink('https://evil.com/')).rejects.toThrow(
+ 'URL must be from a supported music platform',
+ );
+ });
+
+ it('should reject file: protocol URLs', async () => {
+ await expect(resolveSongLink('file:///etc/passwd')).rejects.toThrow('URL must use HTTPS');
+ });
+
+ it('should truncate excessively long titles to prevent storage abuse', async () => {
+ const mockFetch = vi.mocked(fetch);
+ const longTitle = 'A'.repeat(500);
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: () =>
+ Promise.resolve({
+ entityUniqueId: 'SPOTIFY_SONG::abc',
+ pageUrl: 'https://song.link/s/abc',
+ entitiesByUniqueId: {
+ 'SPOTIFY_SONG::abc': {
+ id: 'abc',
+ type: 'song',
+ title: longTitle,
+ artistName: 'Artist',
+ thumbnailUrl: 'https://i.scdn.co/image/abc',
+ platforms: ['spotify'],
+ },
+ },
+ linksByPlatform: {
+ spotify: {
+ entityUniqueId: 'SPOTIFY_SONG::abc',
+ url: 'https://open.spotify.com/track/abc',
+ },
+ },
+ }),
+ } as unknown as Response);
+
+ const result = await resolveSongLink('https://open.spotify.com/track/abc');
+
+ // Title should be truncated to 300 characters
+ expect(result.title.length).toBeLessThanOrEqual(300);
+ });
+
+ it('should only allow HTTPS URLs in platform links', async () => {
+ const mockFetch = vi.mocked(fetch);
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: () =>
+ Promise.resolve({
+ entityUniqueId: 'SPOTIFY_SONG::abc',
+ pageUrl: 'https://song.link/s/abc',
+ entitiesByUniqueId: {
+ 'SPOTIFY_SONG::abc': {
+ id: 'abc',
+ type: 'song',
+ title: 'Test',
+ artistName: 'Artist',
+ thumbnailUrl: 'https://i.scdn.co/image/abc',
+ platforms: ['spotify'],
+ },
+ },
+ linksByPlatform: {
+ spotify: {
+ entityUniqueId: 'SPOTIFY_SONG::abc',
+ url: 'https://open.spotify.com/track/abc',
+ },
+ malicious: {
+ entityUniqueId: 'MAL::abc',
+ url: 'javascript:alert(1)',
+ },
+ httpLink: {
+ entityUniqueId: 'HTTP::abc',
+ url: 'http://insecure.com/track/abc',
+ },
+ },
+ }),
+ } as unknown as Response);
+
+ const result = await resolveSongLink('https://open.spotify.com/track/abc');
+
+ // Only HTTPS links should be included
+ const urls = result.platformLinks.map((l) => l.url);
+ for (const url of urls) {
+ expect(url.startsWith('https://')).toBe(true);
+ }
+ });
+
+ it('should return null thumbnail for non-HTTPS thumbnail URLs', async () => {
+ const mockFetch = vi.mocked(fetch);
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: () =>
+ Promise.resolve({
+ entityUniqueId: 'SPOTIFY_SONG::abc',
+ pageUrl: 'https://song.link/s/abc',
+ entitiesByUniqueId: {
+ 'SPOTIFY_SONG::abc': {
+ id: 'abc',
+ type: 'song',
+ title: 'Test',
+ artistName: 'Artist',
+ thumbnailUrl: 'http://insecure.com/image.jpg',
+ platforms: ['spotify'],
+ },
+ },
+ linksByPlatform: {
+ spotify: {
+ entityUniqueId: 'SPOTIFY_SONG::abc',
+ url: 'https://open.spotify.com/track/abc',
+ },
+ },
+ }),
+ } as unknown as Response);
+
+ const result = await resolveSongLink('https://open.spotify.com/track/abc');
+
+ expect(result.thumbnailUrl).toBeNull();
+ });
+});
+
+describe('Security: API resolve endpoint URL validation', () => {
+ // These tests exercise the server-side resolve handler imported directly
+ // The resolve endpoint tests already cover this, but these focus on attack vectors
+
+ it('should not send requests for non-HTTPS URLs', async () => {
+ const mockFetch = vi.mocked(fetch);
+
+ // Attempting to resolve an HTTP URL should throw before ever calling fetch
+ await expect(resolveSongLink('http://open.spotify.com/track/abc')).rejects.toThrow();
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it('should not send requests for non-music-platform URLs', async () => {
+ const mockFetch = vi.mocked(fetch);
+
+ await expect(resolveSongLink('https://evil.com/track/abc')).rejects.toThrow();
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/rate-limiter.ts b/src/lib/rate-limiter.ts
new file mode 100644
index 0000000..dc56326
--- /dev/null
+++ b/src/lib/rate-limiter.ts
@@ -0,0 +1,84 @@
+/**
+ * In-memory sliding window rate limiter.
+ *
+ * Tracks timestamps of requests per key (typically IP address).
+ * Expired timestamps are pruned on each check. A periodic cleanup
+ * removes stale entries to prevent unbounded memory growth.
+ */
+
+interface RateLimiterOptions {
+ /** Maximum requests allowed within the window. Default: 10 */
+ maxRequests?: number;
+ /** Window duration in milliseconds. Default: 60_000 (1 minute) */
+ windowMs?: number;
+}
+
+interface RateLimiter {
+ /** Returns true if the request is allowed, false if rate-limited */
+ check: (key: string) => boolean;
+ /** Returns the number of tracked keys (for testing/monitoring) */
+ size: () => number;
+}
+
+export function createRateLimiter(options: RateLimiterOptions = {}): RateLimiter {
+ const maxRequests = options.maxRequests ?? 10;
+ const windowMs = options.windowMs ?? 60_000;
+
+ /** Map of key -> array of request timestamps within the window */
+ const requests = new Map();
+
+ /** Counter to trigger periodic cleanup of stale entries */
+ let checkCount = 0;
+ const CLEANUP_INTERVAL = 100;
+
+ function cleanup(): void {
+ const now = Date.now();
+ const cutoff = now - windowMs;
+
+ for (const [key, timestamps] of requests) {
+ const valid = timestamps.filter((t) => t > cutoff);
+ if (valid.length === 0) {
+ requests.delete(key);
+ } else {
+ requests.set(key, valid);
+ }
+ }
+ }
+
+ function check(key: string): boolean {
+ const now = Date.now();
+ const cutoff = now - windowMs;
+
+ // Periodic cleanup to prevent memory leak from abandoned keys
+ checkCount++;
+ if (checkCount >= CLEANUP_INTERVAL) {
+ checkCount = 0;
+ cleanup();
+ }
+
+ const timestamps = requests.get(key);
+
+ if (!timestamps) {
+ requests.set(key, [now]);
+ return true;
+ }
+
+ // Prune expired timestamps for this key
+ const valid = timestamps.filter((t) => t > cutoff);
+
+ if (valid.length >= maxRequests) {
+ requests.set(key, valid);
+ return false;
+ }
+
+ valid.push(now);
+ requests.set(key, valid);
+ return true;
+ }
+
+ function size(): number {
+ return requests.size;
+ }
+
+ return { check, size };
+}
diff --git a/src/pages/api/__tests__/resolve.test.ts b/src/pages/api/__tests__/resolve.test.ts
new file mode 100644
index 0000000..365f79d
--- /dev/null
+++ b/src/pages/api/__tests__/resolve.test.ts
@@ -0,0 +1,282 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+// Mock the rate limiter module before importing the handler.
+// This lets us control rate limiting behavior in tests without dealing
+// with the module-level singleton state.
+const mockCheck = vi.fn(() => true);
+vi.mock('../../../lib/rate-limiter', () => ({
+ createRateLimiter: () => ({ check: mockCheck, size: () => 0 }),
+}));
+
+// Import after mock setup
+const { GET } = await import('../resolve');
+
+// --- Helpers ---
+
+/** Build a minimal Astro APIContext with search params and request headers */
+function makeContext(params?: Record, headers?: Record) {
+ const url = new URL('http://localhost:4321/api/resolve');
+ if (params) {
+ for (const [key, value] of Object.entries(params)) {
+ url.searchParams.set(key, value);
+ }
+ }
+ const request = new Request(url, {
+ headers: new Headers(headers),
+ });
+ return { url, request } as Parameters[0];
+}
+
+/** Parse the JSON body and status from a Response */
+async function parseResponse(response: Response) {
+ const body = await response.json();
+ return { status: response.status, body };
+}
+
+describe('GET /api/resolve', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ mockCheck.mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ mockCheck.mockReset();
+ mockCheck.mockReturnValue(true);
+ });
+
+ // --- Rate limiting ---
+
+ describe('rate limiting', () => {
+ it('should return 429 when rate limit is exceeded', async () => {
+ mockCheck.mockReturnValue(false);
+
+ const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc' }));
+ const { status, body } = await parseResponse(response);
+
+ expect(status).toBe(429);
+ expect(body.error).toContain('Too many requests');
+ expect(response.headers.get('Retry-After')).toBe('60');
+ });
+
+ it('should allow requests when under rate limit', async () => {
+ mockCheck.mockReturnValue(true);
+ const mockFetch = vi.mocked(fetch);
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
+
+ const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' }));
+
+ expect(response.status).toBe(200);
+ });
+
+ it('should extract client IP from X-Forwarded-For header', async () => {
+ mockCheck.mockReturnValue(true);
+ const mockFetch = vi.mocked(fetch);
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
+
+ await GET(
+ makeContext(
+ { url: 'https://open.spotify.com/track/abc123' },
+ { 'x-forwarded-for': '203.0.113.50, 70.41.3.18' },
+ ),
+ );
+
+ // The rate limiter check should be called with the first IP
+ expect(mockCheck).toHaveBeenCalledWith('203.0.113.50');
+ });
+
+ it('should use "unknown" when no IP headers are present', async () => {
+ mockCheck.mockReturnValue(true);
+ const mockFetch = vi.mocked(fetch);
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
+
+ await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' }));
+
+ expect(mockCheck).toHaveBeenCalledWith('unknown');
+ });
+
+ it('should check rate limit before processing the request', async () => {
+ mockCheck.mockReturnValue(false);
+ const mockFetch = vi.mocked(fetch);
+
+ await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' }));
+
+ // fetch should NOT have been called since rate limit was hit first
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+ });
+
+ // --- Missing / invalid URL parameter ---
+
+ it('should return 400 when url parameter is missing', async () => {
+ const response = await GET(makeContext());
+ const { status, body } = await parseResponse(response);
+
+ expect(status).toBe(400);
+ expect(body.error).toBe('Missing url parameter');
+ });
+
+ it('should return 400 when url parameter is empty string', async () => {
+ const response = await GET(makeContext({ url: '' }));
+ const { status } = await parseResponse(response);
+
+ // Empty string is treated as missing by searchParams.get
+ expect(status).toBe(400);
+ });
+
+ it('should return 400 when url is malformed', async () => {
+ const response = await GET(makeContext({ url: 'not-a-url' }));
+ const { status, body } = await parseResponse(response);
+
+ expect(status).toBe(400);
+ expect(body.error).toBe('Invalid URL');
+ });
+
+ // --- Unsupported platforms ---
+
+ it('should return 400 when url is from an unsupported platform', async () => {
+ const response = await GET(makeContext({ url: 'https://example.com/track/123' }));
+ const { status, body } = await parseResponse(response);
+
+ expect(status).toBe(400);
+ expect(body.error).toBe('URL not from a supported music platform');
+ });
+
+ it('should return 400 when url uses HTTP protocol instead of HTTPS', async () => {
+ const response = await GET(makeContext({ url: 'http://open.spotify.com/track/abc' }));
+ const { status, body } = await parseResponse(response);
+
+ expect(status).toBe(400);
+ expect(body.error).toBe('URL not from a supported music platform');
+ });
+
+ // --- Valid platform URLs forwarded to Odesli ---
+
+ const validPlatformUrls = [
+ { platform: 'Spotify', url: 'https://open.spotify.com/track/abc123' },
+ { platform: 'Spotify short link', url: 'https://spotify.link/abc123' },
+ { platform: 'YouTube Music', url: 'https://music.youtube.com/watch?v=abc123' },
+ { platform: 'YouTube', url: 'https://www.youtube.com/watch?v=abc123' },
+ { platform: 'YouTube short', url: 'https://youtu.be/abc123' },
+ { platform: 'Apple Music', url: 'https://music.apple.com/us/album/abc123' },
+ { platform: 'iTunes', url: 'https://itunes.apple.com/us/album/abc123' },
+ { platform: 'Tidal', url: 'https://tidal.com/browse/track/123' },
+ { platform: 'Tidal listen', url: 'https://listen.tidal.com/track/123' },
+ { platform: 'Deezer', url: 'https://www.deezer.com/track/123' },
+ { platform: 'Deezer page link', url: 'https://deezer.page.link/abc' },
+ { platform: 'SoundCloud', url: 'https://soundcloud.com/artist/track' },
+ { platform: 'song.link', url: 'https://song.link/s/abc123' },
+ { platform: 'album.link', url: 'https://album.link/s/abc123' },
+ { platform: 'odesli.co', url: 'https://odesli.co/s/abc123' },
+ ];
+
+ for (const { platform, url } of validPlatformUrls) {
+ it(`should forward ${platform} URL to Odesli API`, async () => {
+ const mockFetch = vi.mocked(fetch);
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
+
+ const response = await GET(makeContext({ url }));
+
+ expect(response.status).toBe(200);
+ expect(mockFetch).toHaveBeenCalledOnce();
+
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
+ expect(calledUrl).toContain('api.song.link/v1-alpha.1/links');
+ expect(calledUrl).toContain(encodeURIComponent(url));
+ });
+ }
+
+ // --- Odesli API response forwarding ---
+
+ it('should forward the Odesli API response body and status as-is', async () => {
+ const mockFetch = vi.mocked(fetch);
+ const odesliBody = { entityUniqueId: 'SPOTIFY::abc', pageUrl: 'https://song.link/s/abc' };
+ mockFetch.mockResolvedValue(new Response(JSON.stringify(odesliBody), { status: 200 }));
+
+ const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' }));
+ const { status, body } = await parseResponse(response);
+
+ expect(status).toBe(200);
+ expect(body.entityUniqueId).toBe('SPOTIFY::abc');
+ });
+
+ it('should forward Odesli 404 status when song is not found', async () => {
+ const mockFetch = vi.mocked(fetch);
+ mockFetch.mockResolvedValue(
+ new Response(JSON.stringify({ error: 'not found' }), { status: 404 }),
+ );
+
+ const response = await GET(makeContext({ url: 'https://open.spotify.com/track/notfound' }));
+
+ expect(response.status).toBe(404);
+ });
+
+ it('should forward Odesli 500 status when API has internal error', async () => {
+ const mockFetch = vi.mocked(fetch);
+ mockFetch.mockResolvedValue(
+ new Response(JSON.stringify({ error: 'internal' }), { status: 500 }),
+ );
+
+ const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' }));
+
+ expect(response.status).toBe(500);
+ });
+
+ // --- Response size guard ---
+
+ it('should return 502 when Odesli response exceeds size limit', async () => {
+ const mockFetch = vi.mocked(fetch);
+ // Create a response body larger than 512 KB
+ const largeBody = 'x'.repeat(512 * 1024 + 1);
+ mockFetch.mockResolvedValue(new Response(largeBody, { status: 200 }));
+
+ const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' }));
+ const { status, body } = await parseResponse(response);
+
+ expect(status).toBe(502);
+ expect(body.error).toBe('Response too large');
+ });
+
+ // --- Network errors ---
+
+ it('should return 502 when fetch to Odesli API fails with network error', async () => {
+ const mockFetch = vi.mocked(fetch);
+ mockFetch.mockRejectedValue(new TypeError('Failed to fetch'));
+
+ const response = await GET(makeContext({ url: 'https://open.spotify.com/track/abc123' }));
+ const { status, body } = await parseResponse(response);
+
+ expect(status).toBe(502);
+ expect(body.error).toBe('Failed to reach song resolution service');
+ });
+
+ // --- Security: protocol enforcement ---
+
+ it('should reject FTP protocol URLs', async () => {
+ const response = await GET(makeContext({ url: 'ftp://open.spotify.com/track/abc' }));
+ const { status, body } = await parseResponse(response);
+
+ expect(status).toBe(400);
+ expect(body.error).toBe('URL not from a supported music platform');
+ });
+
+ it('should reject javascript: protocol URLs', async () => {
+ const response = await GET(makeContext({ url: 'javascript:alert(1)' }));
+
+ expect(response.status).toBe(400);
+ });
+
+ it('should reject data: protocol URLs', async () => {
+ const response = await GET(makeContext({ url: 'data:text/html,' }));
+
+ expect(response.status).toBe(400);
+ });
+
+ // --- Content-Type header ---
+
+ it('should always return Content-Type application/json', async () => {
+ const response = await GET(makeContext());
+
+ expect(response.headers.get('Content-Type')).toBe('application/json');
+ });
+});
diff --git a/src/pages/api/resolve.ts b/src/pages/api/resolve.ts
index 031f660..6bebd3a 100644
--- a/src/pages/api/resolve.ts
+++ b/src/pages/api/resolve.ts
@@ -1,7 +1,11 @@
import type { APIRoute } from 'astro';
+import { createRateLimiter } from '../../lib/rate-limiter';
const ODESLI_API = 'https://api.song.link/v1-alpha.1/links';
+/** Max response body size from Odesli API (500 KB) to prevent memory abuse */
+const MAX_RESPONSE_BYTES = 512 * 1024;
+
/** Allowed music platform hostnames (mirrors the client-side whitelist in odesli.ts) */
const ALLOWED_HOSTS = new Set([
'open.spotify.com',
@@ -24,13 +28,41 @@ const ALLOWED_HOSTS = new Set([
'odesli.co',
]);
+/** In-memory rate limiter — 10 requests per minute per IP */
+const rateLimiter = createRateLimiter({ maxRequests: 10, windowMs: 60_000 });
+
+/**
+ * Extract client IP from the request. Netlify sets X-Forwarded-For;
+ * take only the first address (leftmost = original client).
+ */
+function getClientIp(request: Request): string {
+ const forwarded = request.headers.get('x-forwarded-for');
+ if (forwarded) {
+ const first = forwarded.split(',')[0].trim();
+ if (first) return first;
+ }
+ return 'unknown';
+}
+
/**
* Server-side proxy for the Odesli API.
* Avoids CORS issues since the browser calls our own origin.
*
* GET /api/resolve?url=
*/
-export const GET: APIRoute = async ({ url }) => {
+export const GET: APIRoute = async ({ url, request }) => {
+ // Rate limiting
+ const clientIp = getClientIp(request);
+ if (!rateLimiter.check(clientIp)) {
+ return new Response(JSON.stringify({ error: 'Too many requests. Try again in a minute.' }), {
+ status: 429,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Retry-After': '60',
+ },
+ });
+ }
+
const musicUrl = url.searchParams.get('url');
if (!musicUrl) {
@@ -62,6 +94,14 @@ export const GET: APIRoute = async ({ url }) => {
const body = await response.text();
+ // Guard against unexpectedly large responses from the upstream API
+ if (body.length > MAX_RESPONSE_BYTES) {
+ return new Response(JSON.stringify({ error: 'Response too large' }), {
+ status: 502,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
return new Response(body, {
status: response.status,
headers: { 'Content-Type': 'application/json' },