diff --git a/package-lock.json b/package-lock.json index 93e9328..3f30c6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-s3": "^3.400.0", "@aws-sdk/lib-storage": "^3.400.0", "@nuxt/devtools": "latest", + "@nuxt/test-utils": "^3.7.3", "@nuxtjs/google-fonts": "^3.0.2", "@prisma/client": "^5.2.0", "@types/formidable": "^3.4.2", @@ -25,7 +26,8 @@ "primevue": "^3.32.2", "prisma": "^5.2.0", "sass": "^1.66.1", - "twitter-api-v2": "^1.15.1" + "twitter-api-v2": "^1.15.1", + "vitest": "^0.33.0" } }, "node_modules/@ampproject/remapping": { @@ -2389,6 +2391,18 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -2992,6 +3006,93 @@ "nuxt-telemetry": "bin/nuxt-telemetry.mjs" } }, + "node_modules/@nuxt/test-utils": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@nuxt/test-utils/-/test-utils-3.7.3.tgz", + "integrity": "sha512-KHe5NLnPh10gT62tFZtQZ7h40yrlsjxfVYrfpD8WchzdeC4lHtZPMaYFTB8K43il/L6U3m2i+kC7zIFMGEpzWQ==", + "dev": true, + "dependencies": { + "@nuxt/kit": "3.7.3", + "@nuxt/schema": "3.7.3", + "consola": "^3.2.3", + "defu": "^6.1.2", + "execa": "^7.2.0", + "get-port-please": "^3.1.1", + "ofetch": "^1.3.3", + "pathe": "^1.1.1", + "ufo": "^1.3.0" + }, + "engines": { + "node": "^14.18.0 || >=16.10.0" + }, + "peerDependencies": { + "@jest/globals": "^29.5.0", + "playwright-core": "^1.34.3", + "vitest": "^0.30.0 || ^0.31.0 || ^0.32.0 || ^0.33.0", + "vue": "^3.3.4" + }, + "peerDependenciesMeta": { + "@jest/globals": { + "optional": true + }, + "playwright-core": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@nuxt/test-utils/node_modules/@nuxt/kit": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.7.3.tgz", + "integrity": "sha512-bhP02i6CNti15Z4ix3LpR3fd1ANtTcpfS3CDSaCja24hDt3UxIasyp52mqD9LRC+OxrUVHJziB18EwUtS6RLDQ==", + "dev": true, + "dependencies": { + "@nuxt/schema": "3.7.3", + "c12": "^1.4.2", + "consola": "^3.2.3", + "defu": "^6.1.2", + "globby": "^13.2.2", + "hash-sum": "^2.0.0", + "ignore": "^5.2.4", + "jiti": "^1.20.0", + "knitwork": "^1.0.0", + "mlly": "^1.4.2", + "pathe": "^1.1.1", + "pkg-types": "^1.0.3", + "scule": "^1.0.0", + "semver": "^7.5.4", + "ufo": "^1.3.0", + "unctx": "^2.3.1", + "unimport": "^3.3.0", + "untyped": "^1.4.0" + }, + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/@nuxt/test-utils/node_modules/@nuxt/schema": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-3.7.3.tgz", + "integrity": "sha512-Uqe3Z9RnAROzv5owQo//PztD9d4csKK6ulwQO1hIAinCh34X7z2zrv9lhm14hlRYU1n7ISEi4S7UeHgL/r8d8A==", + "dev": true, + "dependencies": { + "@nuxt/ui-templates": "^1.3.1", + "defu": "^6.1.2", + "hookable": "^5.5.3", + "pathe": "^1.1.1", + "pkg-types": "^1.0.3", + "postcss-import-resolver": "^2.0.0", + "std-env": "^3.4.3", + "ufo": "^1.3.0", + "unimport": "^3.3.0", + "untyped": "^1.4.0" + }, + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/@nuxt/ui-templates": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@nuxt/ui-templates/-/ui-templates-1.3.1.tgz", @@ -3760,6 +3861,12 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, "node_modules/@smithy/abort-controller": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.0.5.tgz", @@ -4454,6 +4561,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@types/chai": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.6.tgz", + "integrity": "sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==", + "dev": true + }, + "node_modules/@types/chai-subset": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz", + "integrity": "sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", @@ -4682,6 +4804,74 @@ "vue": "^3.0.0" } }, + "node_modules/@vitest/expect": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.33.0.tgz", + "integrity": "sha512-sVNf+Gla3mhTCxNJx+wJLDPp/WcstOe0Ksqz4Vec51MmgMth/ia0MGFEkIZmVGeTL5HtjYR4Wl/ZxBxBXZJTzQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "0.33.0", + "@vitest/utils": "0.33.0", + "chai": "^4.3.7" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.33.0.tgz", + "integrity": "sha512-UPfACnmCB6HKRHTlcgCoBh6ppl6fDn+J/xR8dTufWiKt/74Y9bHci5CKB8tESSV82zKYtkBJo9whU3mNvfaisg==", + "dev": true, + "dependencies": { + "@vitest/utils": "0.33.0", + "p-limit": "^4.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.33.0.tgz", + "integrity": "sha512-tJjrl//qAHbyHajpFvr8Wsk8DIOODEebTu7pgBrP07iOepR5jYkLFiqLq2Ltxv+r0uptUb4izv1J8XBOwKkVYA==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.33.0.tgz", + "integrity": "sha512-Kv+yZ4hnH1WdiAkPUQTpRxW8kGtH8VRTnus7ZTGovFYM1ZezJpvGtb9nPIjPnptHbsyIAxYZsEpVPYgtpjGnrg==", + "dev": true, + "dependencies": { + "tinyspy": "^2.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.33.0.tgz", + "integrity": "sha512-pF1w22ic965sv+EN6uoePkAOTkAPWM03Ri/jXNyMIKBb/XHLDPfhLvf/Fa9g0YECevAIz56oVYXhodLvLQ/awA==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.4.3", + "loupe": "^2.3.6", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue-macros/common": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-1.7.2.tgz", @@ -4906,6 +5096,15 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", @@ -5213,6 +5412,15 @@ "util": "^0.12.0" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/ast-kit": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-0.10.0.tgz", @@ -5855,6 +6063,24 @@ "upper-case-first": "^2.0.2" } }, + "node_modules/chai": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.8.tgz", + "integrity": "sha512-vX4YvVVtxlfSZ2VecZgFUTU5qPCYsobVI2O9FmwEXBhDigYGQA6jRXCycIs1yJnnWbZ6/+a2zNIF5DfVCcJBFQ==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^4.1.2", + "get-func-name": "^2.0.0", + "loupe": "^2.3.1", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", @@ -5887,6 +6113,15 @@ "tslib": "^2.0.3" } }, + "node_modules/check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -6729,6 +6964,18 @@ "node": ">=0.10.0" } }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -6886,6 +7133,15 @@ "node": ">=0.3.1" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -7669,6 +7925,15 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", @@ -7685,9 +7950,9 @@ } }, "node_modules/get-port-please": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.0.2.tgz", - "integrity": "sha512-c14cAITf0E+uqdxGALvyYHwOL7UsnWcv3oDtgDAZksiVSGN87xlWVUWGZcmWQU3cICdaOxT+6LdQzUfK2ei1SA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.1.1.tgz", + "integrity": "sha512-3UBAyM3u4ZBVYDsxOQfJDxEa6XTbpBDrOjp4mf7ExFRt5BKs/QywQQiJsh2B+hxcZLSapWqCRvElUe8DnKcFHA==", "dev": true }, "node_modules/get-stream": { @@ -8684,9 +8949,9 @@ } }, "node_modules/jiti": { - "version": "1.19.3", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.19.3.tgz", - "integrity": "sha512-5eEbBDQT/jF1xg6l36P+mWGGoH9Spuy0PCdSr2dtWRDGC6ph/w9ZCL4lmESW8f8F7MwT3XKescfP0wnZWAKL9w==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.20.0.tgz", + "integrity": "sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==", "dev": true, "bin": { "jiti": "bin/jiti.js" @@ -8986,6 +9251,15 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "dev": true }, + "node_modules/loupe": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", + "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.0" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -9606,9 +9880,9 @@ } }, "node_modules/mlly": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.1.tgz", - "integrity": "sha512-SCDs78Q2o09jiZiE2WziwVBEqXQ02XkGdUy45cbJf+BpYRIjArXRJ1Wbowxkb+NaM9DWvS3UC9GiO/6eqvQ/pg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.2.tgz", + "integrity": "sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==", "dev": true, "dependencies": { "acorn": "^8.10.0", @@ -10568,6 +10842,21 @@ "openapi-typescript": "bin/cli.js" } }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -10755,6 +11044,15 @@ "integrity": "sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==", "dev": true }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -11435,6 +11733,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/primeicons": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-6.0.1.tgz", @@ -11598,6 +11922,12 @@ "flat": "^5.0.2" } }, + "node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -12296,6 +12626,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -12511,6 +12847,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -12929,6 +13271,30 @@ "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", "dev": true }, + "node_modules/tinybench": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.5.1.tgz", + "integrity": "sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==", + "dev": true + }, + "node_modules/tinypool": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.6.0.tgz", + "integrity": "sha512-FdswUUo5SxRizcBc6b1GSuLpLjisa8N8qMyYoP3rl+bym+QauhtJP5bvZY1ytt8krKGmMLYIRl36HBZfeAoqhQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.1.1.tgz", + "integrity": "sha512-XPJL2uSzcOyBMky6OFrusqWlzfFrXtE0hPuMgW8A2HmaqrPo4ZQHRN/V0QXN3FSjKxpsbRrFc5LI7KOwBsT1/w==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/titleize": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", @@ -13075,6 +13441,15 @@ "integrity": "sha512-KNxoJL+sldWMI3AooPGcNkbP8awQai93d9xxsTurVPuUo/qnOUR3iO0XZTGC5sezdejHHqNyTwBAgGGw948MDg==", "dev": true }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -13186,17 +13561,17 @@ } }, "node_modules/unimport": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/unimport/-/unimport-3.2.0.tgz", - "integrity": "sha512-9buxPxkNwxwxAlH/RfOFHxtQTUrlmBGi9Ai9HezY2yYbkoOhgJTYPI6+WqxI1EZphoM9cw1SHoCFRkXSb8/fjQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-3.3.0.tgz", + "integrity": "sha512-3jhq3ZG5hFZzrWGDCpx83kjPzefP/EeuKkIO1T0MA4Zwj+dO/Og1mFvZ4aZ5WSDm0FVbbdVIRH1zKBG7c4wOpg==", "dev": true, "dependencies": { - "@rollup/pluginutils": "^5.0.3", + "@rollup/pluginutils": "^5.0.4", "escape-string-regexp": "^5.0.0", "fast-glob": "^3.3.1", "local-pkg": "^0.4.3", "magic-string": "^0.30.3", - "mlly": "^1.4.0", + "mlly": "^1.4.1", "pathe": "^1.1.1", "pkg-types": "^1.0.3", "scule": "^1.0.0", @@ -14208,6 +14583,83 @@ "@esbuild/win32-x64": "0.18.20" } }, + "node_modules/vitest": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.33.0.tgz", + "integrity": "sha512-1CxaugJ50xskkQ0e969R/hW47za4YXDUfWJDxip1hwbnhUjYolpfUn2AMOulqG/Dtd9WYAtkHmM/m3yKVrEejQ==", + "dev": true, + "dependencies": { + "@types/chai": "^4.3.5", + "@types/chai-subset": "^1.3.3", + "@types/node": "*", + "@vitest/expect": "0.33.0", + "@vitest/runner": "0.33.0", + "@vitest/snapshot": "0.33.0", + "@vitest/spy": "0.33.0", + "@vitest/utils": "0.33.0", + "acorn": "^8.9.0", + "acorn-walk": "^8.2.0", + "cac": "^6.7.14", + "chai": "^4.3.7", + "debug": "^4.3.4", + "local-pkg": "^0.4.3", + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.3.3", + "strip-literal": "^1.0.1", + "tinybench": "^2.5.0", + "tinypool": "^0.6.0", + "vite": "^3.0.0 || ^4.0.0", + "vite-node": "0.33.0", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@vitest/browser": "*", + "@vitest/ui": "*", + "happy-dom": "*", + "jsdom": "*", + "playwright": "*", + "safaridriver": "*", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, "node_modules/vscode-jsonrpc": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-6.0.0.tgz", @@ -14441,6 +14893,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", + "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -14754,6 +15222,18 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zhead": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/zhead/-/zhead-2.0.10.tgz", diff --git a/package.json b/package.json index aec5d83..18e1423 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "scripts": { "build": "nuxt build", "dev": "nuxt dev", + "test": "vitest", "generate": "nuxt generate", "preview": "nuxt preview", "postinstall": "nuxt prepare" @@ -13,6 +14,7 @@ "@aws-sdk/client-s3": "^3.400.0", "@aws-sdk/lib-storage": "^3.400.0", "@nuxt/devtools": "latest", + "@nuxt/test-utils": "^3.7.3", "@nuxtjs/google-fonts": "^3.0.2", "@prisma/client": "^5.2.0", "@types/formidable": "^3.4.2", @@ -27,6 +29,7 @@ "primevue": "^3.32.2", "prisma": "^5.2.0", "sass": "^1.66.1", - "twitter-api-v2": "^1.15.1" + "twitter-api-v2": "^1.15.1", + "vitest": "^0.33.0" } } diff --git a/services/bluesky-url-facets-extractor.spec.ts b/services/bluesky-url-facets-extractor.spec.ts new file mode 100644 index 0000000..f5ba580 --- /dev/null +++ b/services/bluesky-url-facets-extractor.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import buildUrlFacets from "./bluesky-url-facets-extractor"; + +describe("Bluesky build url facets from text", () => { + it("should return empty array if no url in text", () => { + const text = "This is a text without url"; + const urls = buildUrlFacets(text); + expect(urls).toEqual([]); + }); + + it("should find urls in text", () => { + const text = + "\u2728 example mentioning @atproto.com to share the URL \ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68 https://en.wikipedia.org/wiki/CBOR."; + const urls = buildUrlFacets(text); + expect(urls).toEqual([ + { + index: { + byteStart: 74, + byteEnd: 108, + }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: "https://en.wikipedia.org/wiki/CBOR", + }, + ], + }, + ]); + }); +}); diff --git a/services/bluesky-url-facets-extractor.ts b/services/bluesky-url-facets-extractor.ts new file mode 100644 index 0000000..91472c7 --- /dev/null +++ b/services/bluesky-url-facets-extractor.ts @@ -0,0 +1,31 @@ +const buildUrlFacets = (text: string) => { + const urlRegex = + /http(s)?:\/\/.[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+~#?&//=]*)/g; + const urlFacets = []; + + const encoder = new TextEncoder(); + + let match; + while ((match = urlRegex.exec(text)) !== null) { + const startIndex = encoder.encode(text.substring(0, match.index)).length; + const url = match[0]; + const endIndex = startIndex + encoder.encode(url).length; + + urlFacets.push({ + index: { + byteStart: startIndex, + byteEnd: endIndex, + }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: url, + }, + ], + }); + } + + return urlFacets; +}; + +export default buildUrlFacets; diff --git a/services/publication-service.ts b/services/publication-service.ts index 678ae73..a5bb9f7 100644 --- a/services/publication-service.ts +++ b/services/publication-service.ts @@ -1,256 +1,283 @@ import { Thread, Message } from "~/models/models"; -import { createRestAPIClient, mastodon } from 'masto' +import { createRestAPIClient, mastodon } from "masto"; import AtProtocole from "@atproto/api"; -import { ReplyRef } from '@atproto/api/dist/client/types/app/bsky/feed/post'; -import { SendTweetV2Params, TweetV2PostTweetResult, TwitterApi } from 'twitter-api-v2'; +import { ReplyRef } from "@atproto/api/dist/client/types/app/bsky/feed/post"; +import { + SendTweetV2Params, + TweetV2PostTweetResult, + TwitterApi, +} from "twitter-api-v2"; import { prisma } from "~/prisma/db"; +import buildUrlFacets from "./bluesky-url-facets-extractor"; function getEnvString(key: string): string { - return process.env[key] || 'undefined' + return process.env[key] || "undefined"; } // Social network API clients -let blueskyClient: AtProtocole.BskyAgent +let blueskyClient: AtProtocole.BskyAgent; -let mastodonClient: mastodon.rest.Client +let mastodonClient: mastodon.rest.Client; let twitterClient: TwitterApi; async function connectClients(): Promise { - if (!blueskyClient) { - console.log('Connect to Bluesky…') - blueskyClient = new AtProtocole.BskyAgent({ service: getEnvString('BLUESKY_URL') }) - await blueskyClient.login({ - identifier: getEnvString('BLUESKY_IDENTIFIER'), - password: getEnvString('BLUESKY_PASSWORD'), - }); - console.log('Connection to Bluesky acquired.') - } - - if (!mastodonClient) { - console.log('Connect to Mastodon…') - mastodonClient = createRestAPIClient({ - url: getEnvString('MASTODON_URL'), - accessToken: getEnvString('MASTODON_ACCESS_TOKEN') - }) - console.log('Connection to Mastodon acquired.') - } - if (!twitterClient) { - console.log('Connect to Twitter…') - twitterClient = new TwitterApi({ - appKey: getEnvString('TWITTER_CONSUMER_KEY'), - appSecret: getEnvString('TWITTER_CONSUMER_SECRET'), - accessToken: getEnvString('TWITTER_ACCESS_TOKEN'), - accessSecret: getEnvString('TWITTER_ACCESS_SECRET') - }); - console.log('Connection to Twitter acquired.') - } + if (!blueskyClient) { + console.log("Connect to Bluesky…"); + blueskyClient = new AtProtocole.BskyAgent({ + service: getEnvString("BLUESKY_URL"), + }); + await blueskyClient.login({ + identifier: getEnvString("BLUESKY_IDENTIFIER"), + password: getEnvString("BLUESKY_PASSWORD"), + }); + console.log("Connection to Bluesky acquired."); + } + + if (!mastodonClient) { + console.log("Connect to Mastodon…"); + mastodonClient = createRestAPIClient({ + url: getEnvString("MASTODON_URL"), + accessToken: getEnvString("MASTODON_ACCESS_TOKEN"), + }); + console.log("Connection to Mastodon acquired."); + } + if (!twitterClient) { + console.log("Connect to Twitter…"); + twitterClient = new TwitterApi({ + appKey: getEnvString("TWITTER_CONSUMER_KEY"), + appSecret: getEnvString("TWITTER_CONSUMER_SECRET"), + accessToken: getEnvString("TWITTER_ACCESS_TOKEN"), + accessSecret: getEnvString("TWITTER_ACCESS_SECRET"), + }); + console.log("Connection to Twitter acquired."); + } } // Publication on Mastodon -async function postMessageOnMastodon(message: Message, inReplyToId: string | null): Promise { - const mediaIds: string[] = [] - if (message.attachments) { - for (const attachment of message.attachments) { - const remoteFile = await fetch(attachment.location); - const media = await mastodonClient.v2.media.create({ - file: await remoteFile.blob(), - description: attachment.alt, - }); - mediaIds.push(media.id) - } +async function postMessageOnMastodon( + message: Message, + inReplyToId: string | null +): Promise { + const mediaIds: string[] = []; + if (message.attachments) { + for (const attachment of message.attachments) { + const remoteFile = await fetch(attachment.location); + const media = await mastodonClient.v2.media.create({ + file: await remoteFile.blob(), + description: attachment.alt, + }); + mediaIds.push(media.id); } - - return mastodonClient.v1.statuses.create({ - status: message.text, - visibility: 'public', - mediaIds, - inReplyToId - }); + } + + return mastodonClient.v1.statuses.create({ + status: message.text, + visibility: "public", + mediaIds, + inReplyToId, + }); } async function postMessagesOnMastodon(messages: Message[]): Promise { - let inReplyToId: string | null = null - - for (const message of messages) { - console.log('Publish message on Mastodon…') - const status: mastodon.v1.Status = await postMessageOnMastodon(message, inReplyToId); - inReplyToId = status.id - console.log('Message published on Mastodon.') - } + let inReplyToId: string | null = null; + + for (const message of messages) { + console.log("Publish message on Mastodon…"); + const status: mastodon.v1.Status = await postMessageOnMastodon( + message, + inReplyToId + ); + inReplyToId = status.id; + console.log("Message published on Mastodon."); + } } // Publication on Bluesky interface RecordRef { - uri: string; - cid: string; + uri: string; + cid: string; } -async function postMessageOnBluesky(message: Message, reply: ReplyRef | null): Promise { - try { - - const record: any = {} - record.text = message.text - - if (message.attachments && message.attachments.length > 0) { - let embed: any - embed = { - $type: 'app.bsky.embed.images', - images: [] - } - for (const file of message.attachments) { - const mediaFile = await fetch(file.location); - const mediaData = await mediaFile.arrayBuffer(); - const mediaResponse = await blueskyClient.uploadBlob(Buffer.from(mediaData), { encoding: file.mimetype }); - embed.images.push({ image: mediaResponse.data.blob, alt: file.alt }) - } - record.embed = embed - } - - if (reply) { - record.reply = reply - } - - return blueskyClient.post(record) - } catch (error) { - console.error(error) - throw error +async function postMessageOnBluesky( + message: Message, + reply: ReplyRef | null +): Promise { + try { + const record: any = {}; + record.text = message.text; + record.facets = buildUrlFacets(message.text); + + if (message.attachments && message.attachments.length > 0) { + let embed: any; + embed = { + $type: "app.bsky.embed.images", + images: [], + }; + for (const file of message.attachments) { + const mediaFile = await fetch(file.location); + const mediaData = await mediaFile.arrayBuffer(); + const mediaResponse = await blueskyClient.uploadBlob( + Buffer.from(mediaData), + { encoding: file.mimetype } + ); + embed.images.push({ image: mediaResponse.data.blob, alt: file.alt }); + } + record.embed = embed; } + + if (reply) { + record.reply = reply; + } + + return blueskyClient.post(record); + } catch (error) { + console.error(error); + throw error; + } } async function postMessagesOnBluesky(messages: Message[]): Promise { - try { - let reply: ReplyRef | null = null - - for (const message of messages) { - console.log('Publish message on Bluesky') - const recordRef: RecordRef = await postMessageOnBluesky(message, reply); - reply = { - parent: { - cid: recordRef.cid, - uri: recordRef.uri - }, - root: { - cid: recordRef.cid, - uri: recordRef.uri - } - } - console.log('Message published on Bluesky.') - } - } catch (error) { - console.error(error) - throw error + try { + let reply: ReplyRef | null = null; + + for (const message of messages) { + console.log("Publish message on Bluesky"); + const recordRef: RecordRef = await postMessageOnBluesky(message, reply); + reply = { + parent: { + cid: recordRef.cid, + uri: recordRef.uri, + }, + root: { + cid: recordRef.cid, + uri: recordRef.uri, + }, + }; + console.log("Message published on Bluesky."); } + } catch (error) { + console.error(error); + throw error; + } } // Publication on Twitter -async function postMessageOnTwitter(message: Message, reply: TweetV2PostTweetResult | null): Promise { - try { - const tweet: SendTweetV2Params = {} - if (message.text) { - tweet.text = message.text - } - if (message.attachments && message.attachments.length > 0) { - const mediaIds = [] - for (const file of message.attachments) { - const mediaResponse = await fetch(file.location); - const mediaData = await mediaResponse.arrayBuffer(); - const mediaId = await twitterClient.v1.uploadMedia(Buffer.from(mediaData), { mimeType: file.mimetype }) - mediaIds.push(mediaId) - } - tweet.media = { media_ids: mediaIds } - } - if (reply && reply.data) { - tweet.reply = { in_reply_to_tweet_id: reply.data.id } - } - return twitterClient.v2.tweet(tweet) - } catch (error) { - console.error(error) - throw error +async function postMessageOnTwitter( + message: Message, + reply: TweetV2PostTweetResult | null +): Promise { + try { + const tweet: SendTweetV2Params = {}; + if (message.text) { + tweet.text = message.text; + } + if (message.attachments && message.attachments.length > 0) { + const mediaIds = []; + for (const file of message.attachments) { + const mediaResponse = await fetch(file.location); + const mediaData = await mediaResponse.arrayBuffer(); + const mediaId = await twitterClient.v1.uploadMedia( + Buffer.from(mediaData), + { mimeType: file.mimetype } + ); + mediaIds.push(mediaId); + } + tweet.media = { media_ids: mediaIds }; } + if (reply && reply.data) { + tweet.reply = { in_reply_to_tweet_id: reply.data.id }; + } + return twitterClient.v2.tweet(tweet); + } catch (error) { + console.error(error); + throw error; + } } async function postMessagesOnTwitter(messages: Message[]): Promise { - let reply: TweetV2PostTweetResult | null = null + let reply: TweetV2PostTweetResult | null = null; - for (const message of messages) { - console.log('Publish message on Twitter') - reply = await postMessageOnTwitter(message, reply); - console.log('Message published on twitter.') - } + for (const message of messages) { + console.log("Publish message on Twitter"); + reply = await postMessageOnTwitter(message, reply); + console.log("Message published on twitter."); + } } async function postMessages(messages: Message[]): Promise { - const platforms: string[] = [] - - if (process.env.BLUESKY_ENABLED as string === 'true') { - console.log('Publish messages on Bluesky') - await postMessagesOnBluesky(messages) - platforms.push('Bluesky') - console.log('Messages published on Bluesky.') - } - - if (process.env.MASTODON_ENABLED as string === 'true') { - console.log('Publish messages on Mastodon…') - await postMessagesOnMastodon(messages) - platforms.push('Mastodon') - console.log('Messages published on Mastodon.') - } - - if (process.env.TWITTER_ENABLED as string === 'true') { - console.log('Publish messages on Twitter…') - await postMessagesOnTwitter(messages) - platforms.push('Twitter') - console.log('Messages published on Twitter.') - } - return platforms + const platforms: string[] = []; + + if ((process.env.BLUESKY_ENABLED as string) === "true") { + console.log("Publish messages on Bluesky"); + await postMessagesOnBluesky(messages); + platforms.push("Bluesky"); + console.log("Messages published on Bluesky."); + } + + if ((process.env.MASTODON_ENABLED as string) === "true") { + console.log("Publish messages on Mastodon…"); + await postMessagesOnMastodon(messages); + platforms.push("Mastodon"); + console.log("Messages published on Mastodon."); + } + + if ((process.env.TWITTER_ENABLED as string) === "true") { + console.log("Publish messages on Twitter…"); + await postMessagesOnTwitter(messages); + platforms.push("Twitter"); + console.log("Messages published on Twitter."); + } + return platforms; } export async function publish(threadId: number): Promise { - const threadData = await prisma.thread.findFirst({ - where: { - id: threadId - }, - include: { - versions: true - } - }) - - if (!threadData) { - throw new Error(`Could not publish thread with ID ${threadId} because it does not exist.`) - } - - const [latestVersion] = threadData.versions.slice(-1) - const latestVersionData: any = latestVersion.data - - console.log('Publish thread…') - await connectClients() - const platforms: string[] = await postMessages(latestVersionData.messages) - - await prisma.thread.update({ - where: { - id: threadId - }, - data: { - publishedAt: new Date(), - } - }) - - let report = 'Thread published' - if (platforms.length === 1) { - report += ' on ' + platforms[0] - } - if (platforms.length > 1) { - const last = platforms.pop(); - report += ' on ' + platforms.join(', ') + ' and ' + last; - } - console.log(`${report} 🎉 !`) - return { - id: threadData.id, - messages: latestVersionData.messages - } + const threadData = await prisma.thread.findFirst({ + where: { + id: threadId, + }, + include: { + versions: true, + }, + }); + + if (!threadData) { + throw new Error( + `Could not publish thread with ID ${threadId} because it does not exist.` + ); + } + + const [latestVersion] = threadData.versions.slice(-1); + const latestVersionData: any = latestVersion.data; + + console.log("Publish thread…"); + await connectClients(); + const platforms: string[] = await postMessages(latestVersionData.messages); + + await prisma.thread.update({ + where: { + id: threadId, + }, + data: { + publishedAt: new Date(), + }, + }); + + let report = "Thread published"; + if (platforms.length === 1) { + report += " on " + platforms[0]; + } + if (platforms.length > 1) { + const last = platforms.pop(); + report += " on " + platforms.join(", ") + " and " + last; + } + console.log(`${report} 🎉 !`); + return { + id: threadData.id, + messages: latestVersionData.messages, + }; }