diff --git a/.github/workflows/phpunit-coverage.yml b/.github/workflows/phpunit-coverage.yml new file mode 100644 index 0000000000..354e930957 --- /dev/null +++ b/.github/workflows/phpunit-coverage.yml @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: PHPUnit Coverage + +on: + push: + branches: + - master + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: phpunit-coverage-${{ github.ref }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest-low + outputs: + php-version: ${{ steps.versions.outputs.php-max }} + server-version: ${{ steps.versions.outputs.branches-max }} + steps: + - name: Checkout app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Get version matrix + id: versions + uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.3.2.3.1.3.2 + + coverage: + runs-on: ubuntu-latest + + needs: [matrix] + + name: Coverage PHP ${{ needs.matrix.outputs.php-version }} Nextcloud ${{ needs.matrix.outputs.server-version }} + + steps: + - name: Set app env + if: ${{ env.APP_NAME == '' }} + run: | + # Split and keep last + echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV + + - name: Checkout server + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ needs.matrix.outputs.server-version }} + + - name: Checkout app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + path: apps/${{ env.APP_NAME }} + + - name: Set up php ${{ needs.matrix.outputs.php-version }} + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 + with: + php-version: ${{ needs.matrix.outputs.php-version }} + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite + coverage: pcov + ini-file: development + ini-values: disable_functions= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up dependencies + working-directory: apps/${{ env.APP_NAME }} + run: | + composer remove nextcloud/ocp --dev + composer i + + - name: Set up Nextcloud + run: | + mkdir data + ./occ maintenance:install --verbose --database=sqlite --database-name=nextcloud --database-host=127.0.0.1 --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin + ./occ app:enable --force ${{ env.APP_NAME }} + + - name: PHPUnit with coverage + working-directory: apps/${{ env.APP_NAME }} + run: composer run test:unit -- --coverage-clover coverage.xml --coverage-text --colors=never 2>&1 | tee /tmp/coverage.txt + + - name: Write coverage summary + run: | + echo '## Test Coverage' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + grep -A 999 'Code Coverage Report' /tmp/coverage.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: apps/${{ env.APP_NAME }}/coverage.xml + retention-days: 7 + + - name: Print logs + if: always() + run: | + cat data/nextcloud.log diff --git a/lib/Db/Poll.php b/lib/Db/Poll.php index 5a5418b8e9..448f75df72 100644 --- a/lib/Db/Poll.php +++ b/lib/Db/Poll.php @@ -158,7 +158,7 @@ class Poll extends EntityWithUser implements JsonSerializable { protected int $adminAccess = 0; protected int $allowComment = 0; protected int $hideBookedUp = 0; - protected int $useNo = 0; + protected int $useNo = 1; protected int $lastInteraction = 0; protected ?string $miscSettings = ''; protected string $votingVariant = ''; @@ -220,6 +220,39 @@ public function __clone() { $this->setAnonymous(0); } + /** + * Build a fresh Poll entity pre-populated from this poll's settings. + * The fresh entity's id is never set, so QBMapper::insert() relies on the + * DB autoincrement to assign a fresh id. + */ + public function createClone(string $newOwner): self { + $clone = new self(); + $clone->setType($this->getType()); + $clone->setVotingVariant($this->getVotingVariant()); + $clone->setTitle('Clone of ' . $this->getTitle()); + $clone->setDescription($this->getDescription()); + $clone->setOwner($newOwner); + $clone->setCreated(time()); + $clone->setLastInteraction(0); + $clone->setExpire($this->getExpire()); + $clone->setDeleted(0); + $clone->setAccess(self::ACCESS_PRIVATE); + $clone->setAnonymous(0); + $clone->setAllowComment($this->getAllowComment()); + $clone->setAllowMaybe($this->getAllowMaybe()); + $clone->setAllowProposals($this->getAllowProposals()); + $clone->setProposalsExpire($this->getProposalsExpire()); + $clone->setVoteLimit($this->getVoteLimit()); + $clone->setOptionLimit($this->getOptionLimit()); + $clone->setShowResults($this->getShowResults()); + $clone->setAdminAccess($this->getAdminAccess()); + $clone->setHideBookedUp($this->getHideBookedUp()); + $clone->setUseNo($this->getUseNo()); + $clone->setMiscSettings($this->getMiscSettings()); + $clone->setTimezoneName($this->getTimezoneName()); + return $clone; + } + /** * @return array * diff --git a/lib/Service/DiffService.php b/lib/Service/DiffService.php index c0cc448253..cbdfbff532 100644 --- a/lib/Service/DiffService.php +++ b/lib/Service/DiffService.php @@ -103,12 +103,12 @@ private function extractNewValues($diff, &$newValues): void { foreach ($diff as $key => $value) { if (is_array($value)) { // If 'new' exists, add it to the correct position in the structure - if (isset($value['new'])) { + if (array_key_exists('new', $value)) { $newValues[$key] = $value['new']; } // If the element is an array and the key isn't set yet, initialize it - if (!isset($newValues[$key])) { + if (!array_key_exists($key, $newValues)) { $newValues[$key] = []; } diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index 4f179fa0d8..1357acdd6d 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -411,9 +411,7 @@ public function clone(int $pollId): Poll { throw new ForbiddenException('Poll creation is disabled'); } - $this->poll = clone $origin; - - $this->poll->setOwner($this->userSession->getCurrentUserId()); + $this->poll = $origin->createClone($this->userSession->getCurrentUserId()); $this->poll = $this->pollMapper->insert($this->poll); $this->eventDispatcher->dispatchTyped(new PollCreatedEvent($this->poll)); diff --git a/package-lock.json b/package-lock.json index 55be4a0185..e389bcc8ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "marked-gfm-heading-id": "^4.1.3", "pinia": "^3.0.4", "qrcode": "^1.5.3", + "sortablejs": "^1.15.7", "vue": "^3.5.29", "vue-material-design-icons": "^5.3.0", "vue-router": "^4.6.4", @@ -52,6 +53,7 @@ "eslint-config-prettier": "^10.1.5", "eslint-plugin-prettier": "^5.5.5", "prettier": "^3.8.1", + "terser": "^5.46.0", "vite": "^7.3.1", "vite-plugin-node-polyfills": "^0.25.0", "xml2js": "^0.6.2" @@ -1240,6 +1242,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2469,9 +2482,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.0.tgz", + "integrity": "sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ==", "cpu": [ "arm" ], @@ -2483,9 +2496,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.0.tgz", + "integrity": "sha512-2O73dR4Dc9bp+wSYhviP6sDziurB5/HCym7xILKifWdE9UsOe2FtNcM+I4xZjKrfLJnq5UR8k9riB87gauiQtw==", "cpu": [ "arm64" ], @@ -2497,9 +2510,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.0.tgz", + "integrity": "sha512-vwSXQN8T4sKf1RHr1F0s98Pf8UPz7pS6P3LG9NSmuw0TVh7EmaE+5Ny7hJOZ0M2yuTctEsHHRTMi2wuHkdS6Hg==", "cpu": [ "arm64" ], @@ -2511,9 +2524,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.0.tgz", + "integrity": "sha512-cQp/WG8HE7BCGyFVuzUg0FNmupxC+EPZEwWu2FCGGw5WDT1o2/YlENbm5e9SMvfDFR6FRhVCBePLqj0o8MN7Vw==", "cpu": [ "x64" ], @@ -2525,9 +2538,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.0.tgz", + "integrity": "sha512-UR1uTJFU/p801DvvBbtDD7z9mQL8J80xB0bR7DqW7UGQHRm/OaKzp4is7sQSdbt2pjjSS72eAtRh43hNduTnnQ==", "cpu": [ "arm64" ], @@ -2539,9 +2552,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.0.tgz", + "integrity": "sha512-G/DKyS6PK0dD0+VEzH/6n/hWDNPDZSMBmqsElWnCRGrYOb2jC0VSupp7UAHHQ4+QILwkxSMaYIbQ72dktp8pKA==", "cpu": [ "x64" ], @@ -2553,9 +2566,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.0.tgz", + "integrity": "sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==", "cpu": [ "arm" ], @@ -2567,9 +2580,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.0.tgz", + "integrity": "sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==", "cpu": [ "arm" ], @@ -2581,9 +2594,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.0.tgz", + "integrity": "sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==", "cpu": [ "arm64" ], @@ -2595,9 +2608,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.0.tgz", + "integrity": "sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==", "cpu": [ "arm64" ], @@ -2608,24 +2621,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.0.tgz", + "integrity": "sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==", "cpu": [ "loong64" ], @@ -2637,23 +2636,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.0.tgz", + "integrity": "sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==", "cpu": [ "ppc64" ], @@ -2665,9 +2650,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.0.tgz", + "integrity": "sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==", "cpu": [ "riscv64" ], @@ -2679,9 +2664,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.0.tgz", + "integrity": "sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==", "cpu": [ "riscv64" ], @@ -2693,9 +2678,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.0.tgz", + "integrity": "sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==", "cpu": [ "s390x" ], @@ -2707,9 +2692,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.0.tgz", + "integrity": "sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==", "cpu": [ "x64" ], @@ -2721,9 +2706,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.0.tgz", + "integrity": "sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==", "cpu": [ "x64" ], @@ -2734,24 +2719,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.0.tgz", + "integrity": "sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==", "cpu": [ "arm64" ], @@ -2763,9 +2734,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.0.tgz", + "integrity": "sha512-q7cIIdFvWQoaCbLDUyUc8YfR3Jh2xx3unO8Dn6/TTogKjfwrax9SyfmGGK6cQhKtjePI7jRfd7iRYcxYs93esg==", "cpu": [ "arm64" ], @@ -2777,9 +2748,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.0.tgz", + "integrity": "sha512-XzNOVg/YnDOmFdDKcxxK410PrcbcqZkBmz+0FicpW5jtjKQxcW1BZJEQOF0NJa6JO7CZhett8GEtRN/wYLYJuw==", "cpu": [ "ia32" ], @@ -2790,24 +2761,10 @@ "win32" ] }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.0.tgz", + "integrity": "sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==", "cpu": [ "x64" ], @@ -4793,6 +4750,13 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", @@ -5138,6 +5102,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/comment-parser": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", @@ -11399,9 +11370,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.0.tgz", + "integrity": "sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==", "dev": true, "license": "MIT", "peer": true, @@ -11416,31 +11387,27 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.50.0", + "@rollup/rollup-android-arm64": "4.50.0", + "@rollup/rollup-darwin-arm64": "4.50.0", + "@rollup/rollup-darwin-x64": "4.50.0", + "@rollup/rollup-freebsd-arm64": "4.50.0", + "@rollup/rollup-freebsd-x64": "4.50.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.0", + "@rollup/rollup-linux-arm-musleabihf": "4.50.0", + "@rollup/rollup-linux-arm64-gnu": "4.50.0", + "@rollup/rollup-linux-arm64-musl": "4.50.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.50.0", + "@rollup/rollup-linux-ppc64-gnu": "4.50.0", + "@rollup/rollup-linux-riscv64-gnu": "4.50.0", + "@rollup/rollup-linux-riscv64-musl": "4.50.0", + "@rollup/rollup-linux-s390x-gnu": "4.50.0", + "@rollup/rollup-linux-x64-gnu": "4.50.0", + "@rollup/rollup-linux-x64-musl": "4.50.0", + "@rollup/rollup-openharmony-arm64": "4.50.0", + "@rollup/rollup-win32-arm64-msvc": "4.50.0", + "@rollup/rollup-win32-ia32-msvc": "4.50.0", + "@rollup/rollup-win32-x64-msvc": "4.50.0", "fsevents": "~2.3.2" } }, @@ -11931,6 +11898,13 @@ "node": ">=10" } }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT", + "peer": true + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11950,6 +11924,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -12956,6 +12941,26 @@ "dev": true, "license": "MIT" }, + "node_modules/terser": { + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index 63a733d025..269b70c06f 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "marked-gfm-heading-id": "^4.1.3", "pinia": "^3.0.4", "qrcode": "^1.5.3", + "sortablejs": "^1.15.7", "vue": "^3.5.29", "vue-material-design-icons": "^5.3.0", "vue-router": "^4.6.4", @@ -74,6 +75,7 @@ "eslint-config-prettier": "^10.1.5", "eslint-plugin-prettier": "^5.5.5", "prettier": "^3.8.1", + "terser": "^5.46.0", "vite": "^7.3.1", "vite-plugin-node-polyfills": "^0.25.0", "xml2js": "^0.6.2" diff --git a/tests/Unit/Db/CommentMapperTest.php b/tests/Unit/Db/CommentMapperTest.php index f3b2796df1..ec2d8ca8af 100644 --- a/tests/Unit/Db/CommentMapperTest.php +++ b/tests/Unit/Db/CommentMapperTest.php @@ -6,7 +6,6 @@ namespace OCA\Polls\Tests\Unit\Db; -use League\FactoryMuffin\Faker\Facade as Faker; use OCA\Polls\Tests\Unit\UnitTestCase; use OCA\Polls\Db\Comment; @@ -75,8 +74,7 @@ public function testFindByPoll() { */ public function testUpdate() { foreach ($this->comments as &$comment) { - $newComment = Faker::paragraph(); - $comment->setComment($newComment()); + $comment->setComment(bin2hex(random_bytes(64))); $this->assertInstanceOf(Comment::class, $this->commentMapper->update($comment)); } unset($comment); diff --git a/tests/Unit/Db/PollGroupMapperTest.php b/tests/Unit/Db/PollGroupMapperTest.php new file mode 100644 index 0000000000..f19e05fd26 --- /dev/null +++ b/tests/Unit/Db/PollGroupMapperTest.php @@ -0,0 +1,96 @@ +set('ncPollsUserId', 'TestUser'); + + $this->pollGroupMapper = Server::get(PollGroupMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $this->polls[] = $this->pollMapper->insert($poll); + + for ($count = 0; $count < 2; $count++) { + /** @var PollGroup $group */ + $group = $this->fm->instance('OCA\Polls\Db\PollGroup'); + $this->groups[] = $this->pollGroupMapper->add($group); + } + } + + public function testList(): void { + $list = $this->pollGroupMapper->list(); + $this->assertGreaterThan(0, count($list)); + $this->assertContainsOnlyInstancesOf(PollGroup::class, $list); + } + + public function testFind(): void { + foreach ($this->groups as $group) { + $this->assertInstanceOf(PollGroup::class, $this->pollGroupMapper->find($group->getId())); + } + } + + public function testAddPollToGroup(): void { + $this->expectNotToPerformAssertions(); + $this->pollGroupMapper->addPollToGroup($this->polls[0]->getId(), $this->groups[0]->getId()); + } + + public function testRemovePollFromGroup(): void { + $this->expectNotToPerformAssertions(); + $this->pollGroupMapper->addPollToGroup($this->polls[0]->getId(), $this->groups[0]->getId()); + $this->pollGroupMapper->removePollFromGroup($this->polls[0]->getId(), $this->groups[0]->getId()); + } + + public function testTidyPollGroups(): void { + // groups have no associated polls → tidyPollGroups should delete them all + $this->pollGroupMapper->tidyPollGroups(); + $this->groups = []; + $this->expectNotToPerformAssertions(); + } + + public function testUpdate(): void { + foreach ($this->groups as &$group) { + $group->setTitle('Updated Title'); + $this->assertInstanceOf(PollGroup::class, $this->pollGroupMapper->update($group)); + } + unset($group); + } + + public function tearDown(): void { + parent::tearDown(); + foreach ($this->groups as $group) { + try { + $this->pollGroupMapper->delete($group); + } catch (\Exception $e) { + // already deleted (e.g. by testTidyPollGroups) + } + } + foreach ($this->polls as $poll) { + $this->pollMapper->delete($poll); + } + } +} diff --git a/tests/Unit/Db/PollMapperTest.php b/tests/Unit/Db/PollMapperTest.php index f85ac5d772..7683dbb31c 100644 --- a/tests/Unit/Db/PollMapperTest.php +++ b/tests/Unit/Db/PollMapperTest.php @@ -8,7 +8,6 @@ namespace OCA\Polls\Tests\Unit\Db; -use League\FactoryMuffin\Faker\Facade as Faker; use OCA\Polls\Db\Poll; use OCA\Polls\Db\PollMapper; use OCA\Polls\Tests\Unit\UnitTestCase; @@ -42,10 +41,8 @@ protected function setUp(): void { */ public function testUpdate() { foreach ($this->polls as &$poll) { - $newTitle = Faker::sentence(10); - $newDescription = Faker::paragraph(); - $poll->setTitle($newTitle()); - $poll->setDescription($newDescription()); + $poll->setTitle(bin2hex(random_bytes(16))); + $poll->setDescription(bin2hex(random_bytes(64))); $this->assertInstanceOf(Poll::class, $this->pollMapper->update($poll)); } diff --git a/tests/Unit/Db/PreferencesMapperTest.php b/tests/Unit/Db/PreferencesMapperTest.php new file mode 100644 index 0000000000..a26fe5c1ad --- /dev/null +++ b/tests/Unit/Db/PreferencesMapperTest.php @@ -0,0 +1,63 @@ +preferencesMapper = Server::get(PreferencesMapper::class); + + for ($count = 0; $count < 2; $count++) { + /** @var Preferences $prefs */ + $prefs = $this->fm->instance('OCA\Polls\Db\Preferences'); + array_push($this->preferences, $this->preferencesMapper->insert($prefs)); + } + } + + public function testFind(): void { + foreach ($this->preferences as $prefs) { + $this->assertInstanceOf( + Preferences::class, + $this->preferencesMapper->find($prefs->getUserId()) + ); + } + } + + public function testUpdate(): void { + foreach ($this->preferences as &$prefs) { + $prefs->setPreferences('{"updated":true}'); + $this->assertInstanceOf(Preferences::class, $this->preferencesMapper->update($prefs)); + } + unset($prefs); + } + + public function testDeleteByUserId(): void { + foreach ($this->preferences as $prefs) { + $this->expectNotToPerformAssertions(); + $this->preferencesMapper->deleteByUserId($prefs->getUserId()); + } + $this->preferences = []; + } + + public function tearDown(): void { + parent::tearDown(); + foreach ($this->preferences as $prefs) { + $this->preferencesMapper->deleteByUserId($prefs->getUserId()); + } + } +} diff --git a/tests/Unit/Db/ShareMapperTest.php b/tests/Unit/Db/ShareMapperTest.php new file mode 100644 index 0000000000..12d4db7106 --- /dev/null +++ b/tests/Unit/Db/ShareMapperTest.php @@ -0,0 +1,119 @@ +set('ncPollsUserId', 'TestUser'); + + $this->shareMapper = Server::get(ShareMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + + $this->polls = [ + $this->fm->instance('OCA\Polls\Db\Poll'), + ]; + + foreach ($this->polls as &$poll) { + $poll = $this->pollMapper->insert($poll); + + for ($count = 0; $count < 2; $count++) { + /** @var Share $share */ + $share = $this->fm->instance('OCA\Polls\Db\Share'); + $share->setPollId($poll->getId()); + array_push($this->shares, $this->shareMapper->insert($share)); + } + } + unset($poll); + } + + public function testFindByPoll(): void { + foreach ($this->polls as $poll) { + $this->assertGreaterThan(0, count($this->shareMapper->findByPoll($poll->getId()))); + } + } + + public function testFindByToken(): void { + foreach ($this->shares as $share) { + $this->assertInstanceOf(Share::class, $this->shareMapper->findByToken($share->getToken())); + } + } + + public function testFindByPollAndUser(): void { + foreach ($this->shares as $share) { + $this->assertInstanceOf( + Share::class, + $this->shareMapper->findByPollAndUser($share->getPollId(), $share->getUserId()) + ); + } + } + + public function testFindByPollNotInvited(): void { + foreach ($this->polls as $poll) { + // shares were inserted with invitationSent = 0 + $this->assertGreaterThan(0, count($this->shareMapper->findByPollNotInvited($poll->getId()))); + } + } + + public function testFindByPollUnreminded(): void { + foreach ($this->polls as $poll) { + // reminderSent defaults to 0 + $this->assertGreaterThan(0, count($this->shareMapper->findByPollUnreminded($poll->getId()))); + } + } + + public function testUpdate(): void { + foreach ($this->shares as &$share) { + $share->setDisplayName('Updated Name'); + $this->assertInstanceOf(Share::class, $this->shareMapper->update($share)); + } + unset($share); + } + + public function testDelete(): void { + foreach ($this->shares as $share) { + $this->assertInstanceOf(Share::class, $this->shareMapper->delete($share)); + } + $this->shares = []; + } + + public function testDeleteByIdAndType(): void { + $this->expectNotToPerformAssertions(); + foreach ($this->shares as $share) { + $this->shareMapper->deleteByIdAndType($share->getUserId(), $share->getType()); + } + $this->shares = []; + } + + public function tearDown(): void { + parent::tearDown(); + foreach ($this->shares as $share) { + $this->shareMapper->delete($share); + } + foreach ($this->polls as $poll) { + $this->pollMapper->delete($poll); + } + } +} diff --git a/tests/Unit/Db/UserMapperTest.php b/tests/Unit/Db/UserMapperTest.php new file mode 100644 index 0000000000..9cba09b9e4 --- /dev/null +++ b/tests/Unit/Db/UserMapperTest.php @@ -0,0 +1,88 @@ +userMapper = Server::get(UserMapper::class); + } + + // --- getUserObject --- + // User/Admin use 'admin' (created by NC install in CI). + // Group uses 'admin' group (also created by NC install). + // Contact/ContactGroup/Circle require external apps — tested separately once available. + + public function testGetUserObjectReturnsUser(): void { + $user = $this->userMapper->getUserObject(User::TYPE, 'admin'); + $this->assertInstanceOf(User::class, $user); + } + + public function testGetUserObjectReturnsAdmin(): void { + $user = $this->userMapper->getUserObject(Admin::TYPE, 'admin'); + $this->assertInstanceOf(Admin::class, $user); + } + + public function testGetUserObjectReturnsGroup(): void { + $user = $this->userMapper->getUserObject(Group::TYPE, 'admin'); + $this->assertInstanceOf(Group::class, $user); + } + + public function testGetUserObjectReturnsGhost(): void { + $user = $this->userMapper->getUserObject(Ghost::TYPE, 'ghost1'); + $this->assertInstanceOf(Ghost::class, $user); + } + + public function testGetUserObjectReturnsEmail(): void { + $user = $this->userMapper->getUserObject(Email::TYPE, 'email_user', 'Display', 'test@example.com', 'en'); + $this->assertInstanceOf(Email::class, $user); + } + + public function testGetUserObjectReturnsExternalGenericUser(): void { + $user = $this->userMapper->getUserObject(UserBase::TYPE_EXTERNAL, 'ext1', 'External User'); + $this->assertInstanceOf(GenericUser::class, $user); + } + + public function testGetUserObjectReturnsPublicGenericUser(): void { + $user = $this->userMapper->getUserObject(UserBase::TYPE_PUBLIC, 'pub1', 'Public User'); + $this->assertInstanceOf(GenericUser::class, $user); + } + + public function testGetUserObjectThrowsForInvalidType(): void { + $this->expectException(InvalidShareTypeException::class); + $this->userMapper->getUserObject('invalid_type', 'user1'); + } + + // --- getParticipant: DB-touching paths --- + + public function testGetParticipantWithEmptyUserIdReturnsEmptyType(): void { + $user = $this->userMapper->getParticipant('', null); + $this->assertSame(UserBase::TYPE_EMPTY, $user->getType()); + } + + public function testGetParticipantWithNonExistentUserReturnsGhost(): void { + // User doesn't exist in NC, no share exists → falls back to Ghost + $user = $this->userMapper->getParticipant('nonexistent_user_xyzzy_' . bin2hex(random_bytes(4)), null); + $this->assertInstanceOf(Ghost::class, $user); + } +} diff --git a/tests/Unit/Db/WatchMapperTest.php b/tests/Unit/Db/WatchMapperTest.php new file mode 100644 index 0000000000..7cfe148e22 --- /dev/null +++ b/tests/Unit/Db/WatchMapperTest.php @@ -0,0 +1,100 @@ +watchMapper = Server::get(WatchMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + $this->userSession = Server::get(UserSession::class); + + $this->polls = [ + $this->fm->instance('OCA\Polls\Db\Poll'), + ]; + + foreach ($this->polls as &$poll) { + $poll = $this->pollMapper->insert($poll); + + // A watch with a different session_id: findUpdatesForPollId excludes the + // current session, so this one will appear in results. + /** @var Watch $watch */ + $watch = $this->fm->instance('OCA\Polls\Db\Watch'); + $watch->setPollId($poll->getId()); + $watch->setSessionId('test-session-different'); + array_push($this->watches, $this->watchMapper->insert($watch)); + + // A watch with the current session's hashed ID for findForPollIdAndTable. + /** @var Watch $ownWatch */ + $ownWatch = $this->fm->instance('OCA\Polls\Db\Watch'); + $ownWatch->setPollId($poll->getId()); + $ownWatch->setTable(Watch::OBJECT_OPTIONS); + $ownWatch->setSessionId($this->userSession->getClientIdHashed()); + array_push($this->watches, $this->watchMapper->insert($ownWatch)); + } + unset($poll); + } + + public function testInsert(): void { + foreach ($this->watches as $watch) { + $this->assertNotNull($watch->getId()); + } + } + + public function testFindUpdatesForPollId(): void { + foreach ($this->polls as $poll) { + $updates = $this->watchMapper->findUpdatesForPollId($poll->getId(), 0); + // The watch with 'test-session-different' must appear; + // the own-session watch is filtered out. + $this->assertGreaterThan(0, count($updates)); + } + } + + public function testFindForPollIdAndTable(): void { + foreach ($this->polls as $poll) { + $this->assertInstanceOf( + Watch::class, + $this->watchMapper->findForPollIdAndTable($poll->getId(), Watch::OBJECT_OPTIONS) + ); + } + } + + public function testDelete(): void { + foreach ($this->watches as $watch) { + $this->assertInstanceOf(Watch::class, $this->watchMapper->delete($watch)); + } + $this->watches = []; + } + + public function tearDown(): void { + parent::tearDown(); + foreach ($this->watches as $watch) { + $this->watchMapper->delete($watch); + } + foreach ($this->polls as $poll) { + $this->pollMapper->delete($poll); + } + } +} diff --git a/tests/Unit/Factories/CommentFactory.php b/tests/Unit/Factories/CommentFactory.php index 37621864f8..0a8c72dfb4 100644 --- a/tests/Unit/Factories/CommentFactory.php +++ b/tests/Unit/Factories/CommentFactory.php @@ -4,16 +4,15 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; - -/** - * General factory for the comment model. - */ $fm->define('OCA\Polls\Db\Comment')->setDefinitions([ - 'userId' => Faker::firstNameMale(), + 'userId' => function () { + return bin2hex(random_bytes(8)); + }, 'timestamp' => function () { $date = new DateTime('today'); return $date->getTimestamp(); }, - 'comment' => Faker::text(255), + 'comment' => function () { + return bin2hex(random_bytes(64)); + }, ]); diff --git a/tests/Unit/Factories/LogFactory.php b/tests/Unit/Factories/LogFactory.php index 9c6936c78f..9a9966755e 100644 --- a/tests/Unit/Factories/LogFactory.php +++ b/tests/Unit/Factories/LogFactory.php @@ -4,18 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; - -/** - * General factory for the comment model. - */ $fm->define('OCA\Polls\Db\Log')->setDefinitions([ 'created' => function () { $date = new DateTime('yesterday'); return $date->getTimestamp(); }, 'processed' => 0, - 'userId' => Faker::firstNameMale(), - 'displayName' => Faker::lastName(), - 'messageId' => 'addPoll' + 'userId' => function () { + return bin2hex(random_bytes(8)); + }, + 'displayName' => function () { + return bin2hex(random_bytes(8)); + }, + 'messageId' => 'addPoll', ]); diff --git a/tests/Unit/Factories/OptionFactory.php b/tests/Unit/Factories/OptionFactory.php index 938cc33286..56fac19a01 100644 --- a/tests/Unit/Factories/OptionFactory.php +++ b/tests/Unit/Factories/OptionFactory.php @@ -4,18 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; - -/** - * General factory for the text model. - */ $fm->define('OCA\Polls\Db\Option')->setDefinitions([ - 'owner' => Faker::firstNameMale(), + 'owner' => function () { + return bin2hex(random_bytes(8)); + }, 'released' => function () { $date = new DateTime('now'); return $date->getTimestamp(); }, - 'pollOptionText' => Faker::text(255), + 'pollOptionText' => function () { + return bin2hex(random_bytes(64)); + }, 'timestamp' => 0, 'isoTimestamp' => null, 'isoDuration' => null, diff --git a/tests/Unit/Factories/PollFactory.php b/tests/Unit/Factories/PollFactory.php index 7ce5f343ba..5cb5cde98e 100644 --- a/tests/Unit/Factories/PollFactory.php +++ b/tests/Unit/Factories/PollFactory.php @@ -4,17 +4,20 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; use OCA\Polls\Db\Poll; -/** - * General factory for the poll model. - */ + $fm->define('OCA\Polls\Db\Poll')->setDefinitions([ 'type' => 'textPoll', - 'title' => Faker::text(124), + 'title' => function () { + return bin2hex(random_bytes(16)); + }, 'votingVariant' => Poll::VARIANT_SIMPLE, - 'description' => Faker::text(255), - 'owner' => Faker::firstNameMale(), + 'description' => function () { + return bin2hex(random_bytes(64)); + }, + 'owner' => function () { + return bin2hex(random_bytes(8)); + }, 'created' => function () { $date = new DateTime('today'); return $date->getTimestamp(); @@ -42,6 +45,6 @@ 'showResults' => Poll::SHOW_RESULTS_ALWAYS, 'adminAccess' => 0, 'hideBookedUp' => 0, - 'useNo' => 0, + 'useNo' => 1, 'misc_settings' => '', ]); diff --git a/tests/Unit/Factories/PollGroupFactory.php b/tests/Unit/Factories/PollGroupFactory.php new file mode 100644 index 0000000000..7b7737a41e --- /dev/null +++ b/tests/Unit/Factories/PollGroupFactory.php @@ -0,0 +1,13 @@ +define('OCA\Polls\Db\PollGroup')->setDefinitions([ + 'title' => function () { + return bin2hex(random_bytes(16)); + }, + 'description' => function () { + return bin2hex(random_bytes(64)); + }, +]); diff --git a/tests/Unit/Factories/PreferencesFactory.php b/tests/Unit/Factories/PreferencesFactory.php index aa87ab282b..e2adfb03d7 100644 --- a/tests/Unit/Factories/PreferencesFactory.php +++ b/tests/Unit/Factories/PreferencesFactory.php @@ -8,10 +8,12 @@ * General factory for the poll model. */ $fm->define('OCA\Polls\Db\Preferences')->setDefinitions([ - 'type' => 'textPoll', + 'userId' => function () { + return 'test_user_' . bin2hex(random_bytes(4)); + }, 'timestamp' => function () { $date = new DateTime('today'); return $date->getTimestamp(); }, - 'preferences' => '{"someJSON":0}' + 'preferences' => '{"someJSON":0}', ]); diff --git a/tests/Unit/Factories/ShareFactory.php b/tests/Unit/Factories/ShareFactory.php index d5fc1e2fb5..da953d161d 100644 --- a/tests/Unit/Factories/ShareFactory.php +++ b/tests/Unit/Factories/ShareFactory.php @@ -3,18 +3,23 @@ * SPDX-FileCopyrightText: 2017 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; +use OCA\Polls\Db\Share; -/** - * General factory for the poll model. - */ $fm->define('OCA\Polls\Db\Share')->setDefinitions([ - 'userId' => Faker::firstNameMale(), - 'emailAddress' => Faker::safeEmail(), - 'invitationSent' => function () { - $date = new DateTime('today'); - return $date->getTimestamp(); + 'type' => Share::TYPE_USER, + 'token' => function () { + return bin2hex(random_bytes(16)); + }, + 'userId' => function () { + return bin2hex(random_bytes(8)); + }, + 'emailAddress' => function () { + return bin2hex(random_bytes(8)) . '@example.com'; + }, + 'displayName' => function () { + return bin2hex(random_bytes(8)); }, - 'displayName' => Faker::lastName(), - 'isNoUser' => 0, + 'invitationSent' => 0, + 'reminderSent' => 0, + 'deleted' => 0, ]); diff --git a/tests/Unit/Factories/SubscriptionFactory.php b/tests/Unit/Factories/SubscriptionFactory.php index 16e8f022b1..b4df6de5e1 100644 --- a/tests/Unit/Factories/SubscriptionFactory.php +++ b/tests/Unit/Factories/SubscriptionFactory.php @@ -3,11 +3,8 @@ * SPDX-FileCopyrightText: 2017 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; - -/** - * General factory for the Subscription model. - */ $fm->define('OCA\Polls\Db\Subscription')->setDefinitions([ - 'userId' => Faker::firstNameMale() + 'userId' => function () { + return bin2hex(random_bytes(8)); + }, ]); diff --git a/tests/Unit/Factories/VoteFactory.php b/tests/Unit/Factories/VoteFactory.php index b063e8761e..46dfa6284a 100644 --- a/tests/Unit/Factories/VoteFactory.php +++ b/tests/Unit/Factories/VoteFactory.php @@ -3,13 +3,12 @@ * SPDX-FileCopyrightText: 2017 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; - -/** - * General factory for the vote model. - */ $fm->define('OCA\Polls\Db\Vote')->setDefinitions([ - 'userId' => Faker::firstNameMale(), - 'voteOptionText' => Faker::text(255), + 'userId' => function () { + return bin2hex(random_bytes(8)); + }, + 'voteOptionText' => function () { + return bin2hex(random_bytes(64)); + }, 'voteAnswer' => 'yes', ]); diff --git a/tests/Unit/Factories/WatchFactory.php b/tests/Unit/Factories/WatchFactory.php new file mode 100644 index 0000000000..f89db5feff --- /dev/null +++ b/tests/Unit/Factories/WatchFactory.php @@ -0,0 +1,16 @@ +define('OCA\Polls\Db\Watch')->setDefinitions([ + 'table' => Watch::OBJECT_VOTES, + 'updated' => function () { + return time(); + }, + 'sessionId' => function () { + return bin2hex(random_bytes(16)); + }, +]); diff --git a/tests/Unit/Model/DateIntervalTest.php b/tests/Unit/Model/DateIntervalTest.php new file mode 100644 index 0000000000..3dfb0c1890 --- /dev/null +++ b/tests/Unit/Model/DateIntervalTest.php @@ -0,0 +1,192 @@ +assertSame(1, $interval->d); + $this->assertSame(0, $interval->h); + $this->assertSame(0, $interval->s); + } + + public function testConstructorWithISO8601HourString(): void { + $interval = new DateInterval('PT1H'); + $this->assertSame(1, $interval->h); + $this->assertSame(0, $interval->d); + } + + public function testConstructorWithISO8601ComplexString(): void { + $interval = new DateInterval('P2DT3H'); + $this->assertSame(2, $interval->d); + $this->assertSame(3, $interval->h); + } + + // --- Constructor: int input (multiples of 86400 treated as days) --- + + public function testConstructorWithOneDayInSeconds(): void { + $interval = new DateInterval(86400); + $this->assertSame(1, $interval->d); + $this->assertSame(0, $interval->h); + $this->assertSame(0, $interval->i); + $this->assertSame(0, $interval->s); + } + + public function testConstructorWithThreeDaysInSeconds(): void { + $interval = new DateInterval(3 * 86400); + $this->assertSame(3, $interval->d); + } + + // --- Constructor: int input (not a multiple of 86400 treated as seconds) --- + + public function testConstructorWithSecondsNotDivisibleByDay(): void { + $interval = new DateInterval(3600); + $this->assertSame(3600, $interval->s); + $this->assertSame(0, $interval->d); + $this->assertSame(0, $interval->h); + } + + public function testConstructorWithSingleSecond(): void { + $interval = new DateInterval(1); + $this->assertSame(1, $interval->s); + } + + // --- Constructor: zero and null yield zero interval --- + + public function testConstructorWithZeroIntYieldsZeroInterval(): void { + $interval = new DateInterval(0); + $this->assertTrue($interval->isZeroDuration()); + } + + public function testConstructorWithNullYieldsZeroInterval(): void { + $interval = new DateInterval(null); + $this->assertTrue($interval->isZeroDuration()); + } + + // --- Constructor: copy from existing DateInterval --- + + public function testConstructorWithDateIntervalCopiesValues(): void { + $original = new DateInterval('P2DT3H'); + $copy = new DateInterval($original); + $this->assertSame($original->d, $copy->d); + $this->assertSame($original->h, $copy->h); + } + + // --- isZeroDuration --- + + public function testIsZeroDurationTrueForNullInput(): void { + $interval = new DateInterval(null); + $this->assertTrue($interval->isZeroDuration()); + } + + public function testIsZeroDurationTrueForZeroInt(): void { + $interval = new DateInterval(0); + $this->assertTrue($interval->isZeroDuration()); + } + + public function testIsZeroDurationFalseForOneDay(): void { + $interval = new DateInterval('P1D'); + $this->assertFalse($interval->isZeroDuration()); + } + + public function testIsZeroDurationFalseForOneHour(): void { + $interval = new DateInterval('PT1H'); + $this->assertFalse($interval->isZeroDuration()); + } + + // --- getISO --- + + public function testGetISOReturnsNullForZeroInterval(): void { + $interval = new DateInterval(null); + $this->assertNull($interval->getISO()); + } + + public function testGetISOReturnsStringForNonZeroInterval(): void { + $interval = new DateInterval('P1D'); + $iso = $interval->getISO(); + $this->assertNotNull($iso); + $this->assertStringStartsWith('P', $iso); + } + + public function testGetISOReturnsStringForTimeInterval(): void { + $interval = new DateInterval('PT2H'); + $this->assertNotNull($interval->getISO()); + } + + // --- isDayInterval --- + + public function testIsDayIntervalTrueForP1D(): void { + $interval = new DateInterval('P1D'); + $this->assertTrue($interval->isDayInterval()); + } + + public function testIsDayIntervalTrueForMultipleDays(): void { + $interval = new DateInterval('P5D'); + $this->assertTrue($interval->isDayInterval()); + } + + public function testIsDayIntervalTrueForIntOneDayInSeconds(): void { + $interval = new DateInterval(86400); + $this->assertTrue($interval->isDayInterval()); + } + + public function testIsDayIntervalFalseForHoursOnly(): void { + $interval = new DateInterval('PT1H'); + $this->assertFalse($interval->isDayInterval()); + } + + public function testIsDayIntervalFalseForMixedDayAndHour(): void { + $interval = new DateInterval('P1DT1H'); + $this->assertFalse($interval->isDayInterval()); + } + + public function testIsDayIntervalFalseForZeroInterval(): void { + $interval = new DateInterval(null); + $this->assertFalse($interval->isDayInterval()); + } + + // --- getSeconds --- + + public function testGetSecondsForOneDay(): void { + $interval = new DateInterval('P1D'); + $base = new DateTimeImmutable('2024-06-15T00:00:00+00:00'); + $this->assertSame(86400, $interval->getSeconds($base)); + } + + public function testGetSecondsForOneHour(): void { + $interval = new DateInterval('PT1H'); + $base = new DateTimeImmutable('2024-06-15T00:00:00+00:00'); + $this->assertSame(3600, $interval->getSeconds($base)); + } + + public function testGetSecondsForThreeDays(): void { + $interval = new DateInterval('P3D'); + $base = new DateTimeImmutable('2024-06-15T00:00:00+00:00'); + $this->assertSame(3 * 86400, $interval->getSeconds($base)); + } + + public function testGetSecondsForZeroInterval(): void { + $interval = new DateInterval(null); + $base = new DateTimeImmutable('2024-06-15T00:00:00+00:00'); + $this->assertSame(0, $interval->getSeconds($base)); + } + + public function testGetSecondsFromIntConstructorMatchesISO(): void { + $fromInt = new DateInterval(86400); + $fromISO = new DateInterval('P1D'); + $base = new DateTimeImmutable('2024-06-15T00:00:00+00:00'); + $this->assertSame($fromISO->getSeconds($base), $fromInt->getSeconds($base)); + } +} diff --git a/tests/Unit/Model/DateTimeImmutableTest.php b/tests/Unit/Model/DateTimeImmutableTest.php new file mode 100644 index 0000000000..14cf9dfae4 --- /dev/null +++ b/tests/Unit/Model/DateTimeImmutableTest.php @@ -0,0 +1,113 @@ +assertSame(0, $dt->getTimestamp()); + } + + public function testConstructorWithZeroIntYieldsEpoch(): void { + $dt = new DateTimeImmutable(0); + $this->assertSame(0, $dt->getTimestamp()); + } + + public function testConstructorWithNegativeIntYieldsEpoch(): void { + $dt = new DateTimeImmutable(-1); + $this->assertSame(0, $dt->getTimestamp()); + } + + public function testConstructorWithEmptyStringYieldsEpoch(): void { + $dt = new DateTimeImmutable(''); + $this->assertSame(0, $dt->getTimestamp()); + } + + public function testConstructorWithPositiveIntUsesUnixTimestamp(): void { + $timestamp = 1700000000; + $dt = new DateTimeImmutable($timestamp); + $this->assertSame($timestamp, $dt->getTimestamp()); + } + + public function testConstructorWithISOStringParsesCorrectly(): void { + $dt = new DateTimeImmutable('2024-03-15T14:30:00+00:00'); + $this->assertSame('2024-03-15', $dt->format('Y-m-d')); + $this->assertSame('14:30:00', $dt->format('H:i:s')); + } + + public function testConstructorWithDateTimeImmutableCopiesTimestamp(): void { + $original = new DateTimeImmutable('2024-06-01T08:00:00+00:00'); + $copy = new DateTimeImmutable($original); + $this->assertSame($original->getTimestamp(), $copy->getTimestamp()); + } + + // --- isStartOfDay --- + + public function testIsStartOfDayTrueAtMidnight(): void { + $dt = new DateTimeImmutable('2024-01-01T00:00:00+00:00'); + $this->assertTrue($dt->isStartOfDay()); + } + + public function testIsStartOfDayFalseAtNoon(): void { + $dt = new DateTimeImmutable('2024-01-01T12:00:00+00:00'); + $this->assertFalse($dt->isStartOfDay()); + } + + public function testIsStartOfDayFalseOneSecondPastMidnight(): void { + $dt = new DateTimeImmutable('2024-01-01T00:00:01+00:00'); + $this->assertFalse($dt->isStartOfDay()); + } + + // --- getISO --- + + public function testGetISOReturnsNullForEpoch(): void { + $dt = new DateTimeImmutable(null); + $this->assertNull($dt->getISO()); + } + + public function testGetISOReturnsNullForZeroTimestamp(): void { + $dt = new DateTimeImmutable(0); + $this->assertNull($dt->getISO()); + } + + public function testGetISOReturnsStringForValidDate(): void { + $dt = new DateTimeImmutable('2024-03-15T14:30:00+00:00'); + $iso = $dt->getISO(); + $this->assertNotNull($iso); + $this->assertStringContainsString('2024-03-15', $iso); + $this->assertStringContainsString('14:30:00', $iso); + } + + public function testGetISOReturnsStringForPositiveTimestamp(): void { + $dt = new DateTimeImmutable(1700000000); + $this->assertNotNull($dt->getISO()); + } + + // --- getISODate --- + + public function testGetISODateReturnsNullForEpoch(): void { + $dt = new DateTimeImmutable(null); + $this->assertNull($dt->getISODate()); + } + + public function testGetISODateReturnsDateStringForValidDate(): void { + $dt = new DateTimeImmutable('2024-03-15T14:30:00+00:00'); + $this->assertSame('2024-03-15', $dt->getISODate()); + } + + public function testGetISODateReturnsOnlyDatePortion(): void { + $dt = new DateTimeImmutable('2024-12-31T23:59:59+00:00'); + $this->assertSame('2024-12-31', $dt->getISODate()); + } +} diff --git a/tests/Unit/Model/DateTimeIntervalTextTest.php b/tests/Unit/Model/DateTimeIntervalTextTest.php new file mode 100644 index 0000000000..65fd9244df --- /dev/null +++ b/tests/Unit/Model/DateTimeIntervalTextTest.php @@ -0,0 +1,186 @@ +l10n = $this->createMock(IL10N::class); + $this->l10n->method('l') + ->willReturnCallback(static function (string $type, $data): string { + if (!$data instanceof \DateTime) { + return ''; + } + return match ($type) { + 'date' => $data->format('Y-m-d'), + 'time' => $data->format('H:i'), + default => $data->format('Y-m-d H:i'), + }; + }); + } + + private function makeSubject(): DateTimeIntervalText { + return new DateTimeIntervalText($this->l10n); + } + + // --- Getters --- + + public function testGetDateTimeReturnsSetStart(): void { + $subject = $this->makeSubject(); + $start = new DateTimeImmutable('2024-05-10T10:00:00+00:00'); + $subject->setStartDateTime($start); + $this->assertSame($start->getTimestamp(), $subject->getDateTime()->getTimestamp()); + } + + public function testGetIntervalReturnsSetInterval(): void { + $subject = $this->makeSubject(); + $interval = new DateInterval('PT2H'); + $subject->setInterval($interval); + $this->assertSame($interval->h, $subject->getInterval()->h); + } + + public function testGetEndDateTimeAddsIntervalToStart(): void { + $subject = $this->makeSubject(); + $start = new DateTimeImmutable('2024-05-10T10:00:00+00:00'); + $interval = new DateInterval('PT2H'); + $subject->setStartDateTime($start); + $subject->setInterval($interval); + $end = $subject->getEndDateTime(); + $this->assertSame($start->getTimestamp() + 7200, $end->getTimestamp()); + } + + // --- getLocalizedDateTimeString: branch 1 --- + // Zero duration, NOT start of day → format='datetime' + + public function testZeroDurationNotStartOfDayReturnsSingleDatetime(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T10:30:00+00:00')); + $subject->setInterval(new DateInterval(null)); + + $result = $subject->getLocalizedDateTimeString(); + + $this->assertSame('2024-01-15 10:30', $result); + } + + // --- getLocalizedDateTimeString: branch 2 --- + // Zero duration, IS start of day → format='date' + + public function testZeroDurationStartOfDayReturnsSingleDate(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T00:00:00+00:00')); + $subject->setInterval(new DateInterval(null)); + + $result = $subject->getLocalizedDateTimeString(); + + $this->assertSame('2024-01-15', $result); + } + + // --- getLocalizedDateTimeString: branch 3 --- + // Day interval, IS start of day, same day after subtracting P1D → single date + + public function testSingleDayFullDayEventReturnsSingleDate(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T00:00:00+00:00')); + $subject->setInterval(new DateInterval('P1D')); + + $result = $subject->getLocalizedDateTimeString(); + + $this->assertSame('2024-01-15', $result); + } + + // --- getLocalizedDateTimeString: branch 4 --- + // Day interval, IS start of day, spanning multiple days → "start – end" + + public function testMultiDayFullDayEventReturnsDateRange(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T00:00:00+00:00')); + $subject->setInterval(new DateInterval('P3D')); + + $result = $subject->getLocalizedDateTimeString(); + + // end = 2024-01-18, minus P1D = 2024-01-17 (inclusive end) + $this->assertSame('2024-01-15 – 2024-01-17', $result); + } + + public function testTwoDayFullDayEventReturnsDateRange(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T00:00:00+00:00')); + $subject->setInterval(new DateInterval('P2D')); + + $result = $subject->getLocalizedDateTimeString(); + + // end = 2024-01-17, minus P1D = 2024-01-16 + $this->assertSame('2024-01-15 – 2024-01-16', $result); + } + + // --- getLocalizedDateTimeString: branch 5 --- + // Time range on the same calendar day → "datetime – time" + + public function testSameDayTimeRangeReturnsDatetimeThenTimeOnly(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T10:00:00+00:00')); + $subject->setInterval(new DateInterval('PT2H')); + + $result = $subject->getLocalizedDateTimeString(); + + $this->assertSame('2024-01-15 10:00 – 12:00', $result); + } + + public function testSameDayTimeRangeEndingAtMidnightIsNotSameDay(): void { + $subject = $this->makeSubject(); + // 22:00 + 2h = 00:00 next day → cross-day + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T22:00:00+00:00')); + $subject->setInterval(new DateInterval('PT2H')); + + $result = $subject->getLocalizedDateTimeString(); + + $this->assertSame('2024-01-15 22:00 – 2024-01-16 00:00', $result); + } + + // --- getLocalizedDateTimeString: branch 6 --- + // Time range spanning midnight → "datetime – datetime" + + public function testCrossDayTimeRangeReturnsTwoDatetimes(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T22:00:00+00:00')); + $subject->setInterval(new DateInterval('PT4H')); + + $result = $subject->getLocalizedDateTimeString(); + + // start: 2024-01-15 22:00, end: 2024-01-16 02:00 + $this->assertSame('2024-01-15 22:00 – 2024-01-16 02:00', $result); + } + + // --- setStartDateTime / setInterval return $this for chaining --- + + public function testFluentInterfaceForSetStartDateTime(): void { + $subject = $this->makeSubject(); + $returned = $subject->setStartDateTime(new DateTimeImmutable('2024-01-01T00:00:00+00:00')); + $this->assertSame($subject, $returned); + } + + public function testFluentInterfaceForSetInterval(): void { + $subject = $this->makeSubject(); + $returned = $subject->setInterval(new DateInterval('P1D')); + $this->assertSame($subject, $returned); + } +} diff --git a/tests/Unit/Model/SequenceTest.php b/tests/Unit/Model/SequenceTest.php new file mode 100644 index 0000000000..5cbf25178a --- /dev/null +++ b/tests/Unit/Model/SequenceTest.php @@ -0,0 +1,156 @@ +assertSame($unit, $seq->getUnit()); + } + + public function testGetStepWidthReturnsConstructedValue(): void { + $seq = new Sequence(new SequenceUnit(SequenceUnit::REPETITION_DAY), 3, 7); + $this->assertSame(3, $seq->getStepWidth()); + } + + public function testGetRepetitionsReturnsConstructedValue(): void { + $seq = new Sequence(new SequenceUnit(SequenceUnit::REPETITION_DAY), 1, 10); + $this->assertSame(10, $seq->getRepetitions()); + } + + // --- jsonSerialize --- + + public function testJsonSerializeContainsRequiredKeys(): void { + $seq = new Sequence(new SequenceUnit(SequenceUnit::REPETITION_WEEK), 1, 4); + $json = $seq->jsonSerialize(); + + $this->assertArrayHasKey('unit', $json); + $this->assertArrayHasKey('stepWidth', $json); + $this->assertArrayHasKey('repetitions', $json); + } + + public function testJsonSerializeValuesMatchConstructorInput(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_MONTH); + $seq = new Sequence($unit, 2, 6); + $json = $seq->jsonSerialize(); + + $this->assertSame(2, $json['stepWidth']); + $this->assertSame(6, $json['repetitions']); + } + + public function testJsonSerializeUnitIsSerialized(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_WEEK); + $seq = new Sequence($unit, 1, 3); + $json = json_decode((string)json_encode($seq), true); + + $this->assertIsArray($json['unit']); + $this->assertSame('week', $json['unit']['id']); + } + + // --- fromArray --- + + public function testFromArrayWithNullReturnsDefaultSequence(): void { + $seq = Sequence::fromArray(null); + $this->assertSame(SequenceUnit::REPETITION_DAY, $seq->getUnit()->getId()); + $this->assertSame(1, $seq->getStepWidth()); + $this->assertSame(0, $seq->getRepetitions()); + } + + public function testFromArrayWithValidDataReturnsCorrectSequence(): void { + $data = [ + 'unit' => ['id' => SequenceUnit::REPETITION_WEEK], + 'stepWidth' => 2, + 'repetitions' => 4, + ]; + $seq = Sequence::fromArray($data); + + $this->assertSame(SequenceUnit::REPETITION_WEEK, $seq->getUnit()->getId()); + $this->assertSame(2, $seq->getStepWidth()); + $this->assertSame(4, $seq->getRepetitions()); + } + + public function testFromArrayFallsBackToDefaultUnitWhenMissing(): void { + $seq = Sequence::fromArray(['stepWidth' => 3, 'repetitions' => 5]); + $this->assertSame(SequenceUnit::REPETITION_DAY, $seq->getUnit()->getId()); + $this->assertSame(3, $seq->getStepWidth()); + $this->assertSame(5, $seq->getRepetitions()); + } + + public function testFromArrayFallsBackToDefaultStepWidthWhenMissing(): void { + $seq = Sequence::fromArray(['unit' => ['id' => 'month'], 'repetitions' => 2]); + $this->assertSame(1, $seq->getStepWidth()); + } + + public function testFromArrayFallsBackToDefaultRepetitionsWhenMissing(): void { + $seq = Sequence::fromArray(['unit' => ['id' => 'day'], 'stepWidth' => 1]); + $this->assertSame(0, $seq->getRepetitions()); + } + + // --- getOccurence --- + + public function testGetOccurenceAtIndexZeroReturnsSameDate(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_DAY); + $seq = new Sequence($unit, 1, 7); + $base = new DateTimeImmutable('2024-01-01T00:00:00+00:00'); + $seq->setBaseDateTime($base); + + $occurrence = $seq->getOccurence(0); + $this->assertSame('2024-01-01', $occurrence->getISODate()); + } + + public function testGetOccurenceAtIndexOneReturnsPlusOneStep(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_DAY); + $seq = new Sequence($unit, 1, 7); + $base = new DateTimeImmutable('2024-01-01T00:00:00+00:00'); + $seq->setBaseDateTime($base); + + $occurrence = $seq->getOccurence(1); + $this->assertSame('2024-01-02', $occurrence->getISODate()); + } + + public function testGetOccurenceWithStepWidthThree(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_DAY); + $seq = new Sequence($unit, 3, 7); + $base = new DateTimeImmutable('2024-01-01T00:00:00+00:00'); + $seq->setBaseDateTime($base); + + // index=2: 2 * 3 = 6 days later + $occurrence = $seq->getOccurence(2); + $this->assertSame('2024-01-07', $occurrence->getISODate()); + } + + public function testGetOccurenceReturnsDateTimeImmutableInstance(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_DAY); + $seq = new Sequence($unit, 1, 3); + $base = new DateTimeImmutable('2024-06-01T00:00:00+00:00'); + $seq->setBaseDateTime($base); + + $occurrence = $seq->getOccurence(1); + $this->assertInstanceOf(DateTimeImmutable::class, $occurrence); + } + + public function testGetOccurenceWithMonthUnit(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_MONTH); + $seq = new Sequence($unit, 1, 12); + $base = new DateTimeImmutable('2024-01-01T00:00:00+00:00'); + $seq->setBaseDateTime($base); + + // index=3: 3 months later + $occurrence = $seq->getOccurence(3); + $this->assertSame('2024-04-01', $occurrence->getISODate()); + } +} diff --git a/tests/Unit/Model/SequenceUnitTest.php b/tests/Unit/Model/SequenceUnitTest.php new file mode 100644 index 0000000000..107d4e794d --- /dev/null +++ b/tests/Unit/Model/SequenceUnitTest.php @@ -0,0 +1,109 @@ +assertSame(SequenceUnit::REPETITION_DAY, $unit->getId()); + } + + public function testGetIdReturnsWeekConstant(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_WEEK); + $this->assertSame(SequenceUnit::REPETITION_WEEK, $unit->getId()); + } + + // --- jsonSerialize: structure --- + + /** + * @dataProvider unitProvider + */ + public function testJsonSerializeContainsRequiredKeys(string $unitId): void { + $unit = new SequenceUnit($unitId); + $json = $unit->jsonSerialize(); + + $this->assertArrayHasKey('id', $json); + $this->assertArrayHasKey('luxonUnit', $json); + $this->assertArrayHasKey('name', $json); + } + + /** + * @dataProvider unitProvider + */ + public function testJsonSerializeIdMatchesConstructorInput(string $unitId): void { + $unit = new SequenceUnit($unitId); + $this->assertSame($unitId, $unit->jsonSerialize()['id']); + } + + // --- jsonSerialize: concrete expected values --- + + public function testJsonSerializeForYear(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_YEAR); + $json = $unit->jsonSerialize(); + $this->assertSame('year', $json['id']); + $this->assertSame('years', $json['luxonUnit']); + $this->assertSame('Year', $json['name']); + } + + public function testJsonSerializeForMonth(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_MONTH); + $json = $unit->jsonSerialize(); + $this->assertSame('month', $json['id']); + $this->assertSame('months', $json['luxonUnit']); + $this->assertSame('Month', $json['name']); + } + + public function testJsonSerializeForWeek(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_WEEK); + $json = $unit->jsonSerialize(); + $this->assertSame('week', $json['id']); + $this->assertSame('weeks', $json['luxonUnit']); + $this->assertSame('Week', $json['name']); + } + + public function testJsonSerializeForDay(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_DAY); + $json = $unit->jsonSerialize(); + $this->assertSame('day', $json['id']); + $this->assertSame('days', $json['luxonUnit']); + $this->assertSame('Day', $json['name']); + } + + public function testJsonSerializeForHour(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_HOUR); + $json = $unit->jsonSerialize(); + $this->assertSame('hour', $json['id']); + $this->assertSame('hours', $json['luxonUnit']); + $this->assertSame('Hour', $json['name']); + } + + public function testJsonSerializeForMinute(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_MINUTE); + $json = $unit->jsonSerialize(); + $this->assertSame('minute', $json['id']); + $this->assertSame('minutes', $json['luxonUnit']); + $this->assertSame('Minute', $json['name']); + } + + public static function unitProvider(): array { + return [ + 'year' => [SequenceUnit::REPETITION_YEAR], + 'month' => [SequenceUnit::REPETITION_MONTH], + 'week' => [SequenceUnit::REPETITION_WEEK], + 'day' => [SequenceUnit::REPETITION_DAY], + 'hour' => [SequenceUnit::REPETITION_HOUR], + 'minute' => [SequenceUnit::REPETITION_MINUTE], + ]; + } +} diff --git a/tests/Unit/Service/CommentServiceTest.php b/tests/Unit/Service/CommentServiceTest.php new file mode 100644 index 0000000000..ac855c2898 --- /dev/null +++ b/tests/Unit/Service/CommentServiceTest.php @@ -0,0 +1,124 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + $this->commentService = Server::get(CommentService::class); + $this->commentMapper = Server::get(CommentMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $this->poll = $this->pollMapper->insert($poll); + } + + protected function tearDown(): void { + parent::tearDown(); + // Comments cascade-delete with the poll + try { + $this->pollMapper->delete($this->poll); + } catch (\Exception) { + } + } + + // --- add --- + + public function testAddCreatesComment(): void { + $comment = $this->commentService->add('Hello World', $this->poll->getId()); + $this->assertInstanceOf(Comment::class, $comment); + $this->assertSame('Hello World', $comment->getComment()); + $this->assertSame('admin', $comment->getUserId()); + $this->assertSame($this->poll->getId(), $comment->getPollId()); + $this->assertSame(Comment::CONFIDENTIAL_NO, $comment->getConfidential()); + $this->assertNull($comment->getRecipient()); + } + + public function testAddConfidentialComment(): void { + $comment = $this->commentService->add('Secret', $this->poll->getId(), true); + $this->assertSame(Comment::CONFIDENTIAL_YES, $comment->getConfidential()); + // recipient is set to the poll owner for confidential comments + $this->assertSame('admin', $comment->getRecipient()); + } + + // --- list --- + + public function testListReturnsAddedComments(): void { + $this->commentService->add('First', $this->poll->getId()); + $this->commentService->add('Second', $this->poll->getId()); + $comments = $this->commentService->list($this->poll->getId()); + $this->assertCount(2, $comments); + } + + public function testListGroupsRapidCommentsFromSameUser(): void { + $first = $this->commentService->add('First', $this->poll->getId()); + $second = $this->commentService->add('Second', $this->poll->getId()); + $comments = $this->commentService->list($this->poll->getId()); + // Both posted within 5 minutes by the same user → second gets parent set + $this->assertSame(0, $comments[0]->getParent()); + $this->assertSame($first->getId(), $comments[1]->getParent()); + } + + public function testListDoesNotReturnDeletedComments(): void { + $comment = $this->commentService->add('To delete', $this->poll->getId()); + $this->commentService->delete($comment->getId()); + $comments = $this->commentService->list($this->poll->getId()); + $this->assertEmpty($comments); + } + + // --- delete / restore --- + + public function testDeleteSetsDeletedTimestamp(): void { + $comment = $this->commentService->add('To delete', $this->poll->getId()); + $deleted = $this->commentService->delete($comment->getId()); + $this->assertGreaterThan(0, $deleted->getDeleted()); + } + + public function testRestoreClearsDeletedTimestamp(): void { + $comment = $this->commentService->add('To restore', $this->poll->getId()); + $this->commentService->delete($comment->getId()); + $restored = $this->commentService->restore($comment->getId()); + $this->assertSame(0, $restored->getDeleted()); + } + + public function testOwnerCanDeleteForeignComment(): void { + // Insert a comment directly as a different user + $foreign = new Comment(); + $foreign->setPollId($this->poll->getId()); + $foreign->setUserId('other_user'); + $foreign->setComment('Foreign comment'); + $foreign->setTimestamp(time()); + $foreign->setConfidential(Comment::CONFIDENTIAL_NO); + $foreign = $this->commentMapper->insert($foreign); + + // Admin (poll owner) has PERMISSION_COMMENT_DELETE → can delete foreign comment + $deleted = $this->commentService->delete($foreign->getId()); + $this->assertGreaterThan(0, $deleted->getDeleted()); + } +} diff --git a/tests/Unit/Service/DiffServiceTest.php b/tests/Unit/Service/DiffServiceTest.php new file mode 100644 index 0000000000..3f8eb57f80 --- /dev/null +++ b/tests/Unit/Service/DiffServiceTest.php @@ -0,0 +1,227 @@ +createMock(Poll::class); + $poll->method('jsonSerialize')->willReturn($data); + return $poll; + } + + // --- Initial state before setComparisonObject --- + + public function testFullDiffIsEmptyBeforeComparison(): void { + $service = new DiffService($this->makePoll(['title' => 'Test'])); + $this->assertSame([], $service->getFullDiff()); + } + + public function testNewValuesDiffIsEmptyBeforeComparison(): void { + $service = new DiffService($this->makePoll(['title' => 'Test'])); + $this->assertSame([], $service->getNewValuesDiff()); + } + + // --- Identical objects produce empty diffs --- + + public function testFullDiffIsEmptyForIdenticalObjects(): void { + $data = ['title' => 'My Poll', 'type' => 'datePoll', 'expire' => 0]; + $service = new DiffService($this->makePoll($data)); + $service->setComparisonObject($this->makePoll($data)); + + $this->assertSame([], $service->getFullDiff()); + } + + public function testNewValuesDiffIsEmptyForIdenticalObjects(): void { + $data = ['title' => 'My Poll', 'type' => 'textPoll']; + $service = new DiffService($this->makePoll($data)); + $service->setComparisonObject($this->makePoll($data)); + + $this->assertSame([], $service->getNewValuesDiff()); + } + + // --- Single scalar field changed --- + + public function testFullDiffDetectsSingleFieldChange(): void { + $service = new DiffService($this->makePoll(['title' => 'Original'])); + $service->setComparisonObject($this->makePoll(['title' => 'Updated'])); + + $diff = $service->getFullDiff(); + + $this->assertArrayHasKey('title', $diff); + $this->assertSame('Original', $diff['title']['old']); + $this->assertSame('Updated', $diff['title']['new']); + } + + public function testNewValuesDiffReturnsSingleChangedField(): void { + $service = new DiffService($this->makePoll(['title' => 'Original'])); + $service->setComparisonObject($this->makePoll(['title' => 'Updated'])); + + $this->assertSame(['title' => 'Updated'], $service->getNewValuesDiff()); + } + + // --- Multiple fields changed --- + + public function testFullDiffDetectsMultipleChangedFields(): void { + $service = new DiffService($this->makePoll([ + 'title' => 'Old Title', + 'type' => 'datePoll', + 'expire' => 0, + ])); + $service->setComparisonObject($this->makePoll([ + 'title' => 'New Title', + 'type' => 'textPoll', + 'expire' => 0, + ])); + + $diff = $service->getFullDiff(); + + $this->assertArrayHasKey('title', $diff); + $this->assertArrayHasKey('type', $diff); + $this->assertArrayNotHasKey('expire', $diff); + $this->assertSame('New Title', $diff['title']['new']); + $this->assertSame('textPoll', $diff['type']['new']); + } + + public function testNewValuesDiffReturnsOnlyChangedFields(): void { + $service = new DiffService($this->makePoll([ + 'title' => 'Old Title', + 'type' => 'datePoll', + 'expire' => 0, + ])); + $service->setComparisonObject($this->makePoll([ + 'title' => 'New Title', + 'type' => 'textPoll', + 'expire' => 0, + ])); + + $newValues = $service->getNewValuesDiff(); + + $this->assertArrayHasKey('title', $newValues); + $this->assertArrayHasKey('type', $newValues); + $this->assertArrayNotHasKey('expire', $newValues); + $this->assertSame('New Title', $newValues['title']); + $this->assertSame('textPoll', $newValues['type']); + } + + // --- Key added in comparison (not in base) --- + + public function testFullDiffDetectsNewKeyInComparison(): void { + $service = new DiffService($this->makePoll(['title' => 'Test'])); + $service->setComparisonObject($this->makePoll(['title' => 'Test', 'extra' => 'added'])); + + $diff = $service->getFullDiff(); + + $this->assertArrayHasKey('extra', $diff); + $this->assertNull($diff['extra']['old']); + $this->assertSame('added', $diff['extra']['new']); + } + + // --- Key removed in comparison (present in base, absent in comparison) --- + + public function testFullDiffDetectsRemovedKeyInComparison(): void { + $service = new DiffService($this->makePoll(['title' => 'Test', 'extra' => 'was here'])); + $service->setComparisonObject($this->makePoll(['title' => 'Test'])); + + $diff = $service->getFullDiff(); + + $this->assertArrayHasKey('extra', $diff); + $this->assertSame('was here', $diff['extra']['old']); + $this->assertNull($diff['extra']['new']); + } + + public function testNewValuesDiffForRemovedKeyContainsNull(): void { + $service = new DiffService($this->makePoll(['title' => 'Test', 'extra' => 'was here'])); + $service->setComparisonObject($this->makePoll(['title' => 'Test'])); + + $newValues = $service->getNewValuesDiff(); + + $this->assertArrayHasKey('extra', $newValues); + $this->assertNull($newValues['extra']); + } + + // --- Nested array changes --- + + public function testFullDiffDetectsNestedFieldChange(): void { + $service = new DiffService($this->makePoll([ + 'configuration' => ['access' => 'private', 'allowComment' => false], + ])); + $service->setComparisonObject($this->makePoll([ + 'configuration' => ['access' => 'open', 'allowComment' => false], + ])); + + $diff = $service->getFullDiff(); + + $this->assertArrayHasKey('configuration', $diff); + $this->assertArrayHasKey('access', $diff['configuration']); + $this->assertSame('private', $diff['configuration']['access']['old']); + $this->assertSame('open', $diff['configuration']['access']['new']); + $this->assertArrayNotHasKey('allowComment', $diff['configuration']); + } + + public function testNewValuesDiffPreservesNestedStructure(): void { + $service = new DiffService($this->makePoll([ + 'configuration' => ['access' => 'private', 'expire' => 0], + ])); + $service->setComparisonObject($this->makePoll([ + 'configuration' => ['access' => 'open', 'expire' => 0], + ])); + + $newValues = $service->getNewValuesDiff(); + + $this->assertArrayHasKey('configuration', $newValues); + $this->assertArrayHasKey('access', $newValues['configuration']); + $this->assertSame('open', $newValues['configuration']['access']); + $this->assertArrayNotHasKey('expire', $newValues['configuration']); + } + + // --- Recalculation when setComparisonObject is called again --- + + public function testDiffIsRecalculatedOnSubsequentSetComparisonObjectCall(): void { + $service = new DiffService($this->makePoll(['title' => 'Base'])); + + $service->setComparisonObject($this->makePoll(['title' => 'First'])); + $this->assertSame('First', $service->getFullDiff()['title']['new']); + + $service->setComparisonObject($this->makePoll(['title' => 'Second'])); + $this->assertSame('Second', $service->getFullDiff()['title']['new']); + } + + // --- Type changes --- + + public function testFullDiffDetectsTypeChange(): void { + $service = new DiffService($this->makePoll(['expire' => 0])); + $service->setComparisonObject($this->makePoll(['expire' => 1700000000])); + + $diff = $service->getFullDiff(); + + $this->assertSame(0, $diff['expire']['old']); + $this->assertSame(1700000000, $diff['expire']['new']); + } + + public function testFullDiffDetectsBooleanChange(): void { + $service = new DiffService($this->makePoll(['allowComment' => false])); + $service->setComparisonObject($this->makePoll(['allowComment' => true])); + + $diff = $service->getFullDiff(); + + $this->assertSame(false, $diff['allowComment']['old']); + $this->assertSame(true, $diff['allowComment']['new']); + } +} diff --git a/tests/Unit/Service/OptionServiceTest.php b/tests/Unit/Service/OptionServiceTest.php new file mode 100644 index 0000000000..4a073cd237 --- /dev/null +++ b/tests/Unit/Service/OptionServiceTest.php @@ -0,0 +1,212 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + $this->optionService = Server::get(OptionService::class); + $this->pollMapper = Server::get(PollMapper::class); + + // Text poll owned by admin, private, open + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $poll->setType(Poll::TYPE_TEXT); + $poll->setAccess(Poll::ACCESS_PRIVATE); + $poll->setExpire(0); + $this->textPoll = $this->pollMapper->insert($poll); + + // Date poll owned by admin, private, open + $datePoll = $this->fm->instance('OCA\Polls\Db\Poll'); + $datePoll->setOwner('admin'); + $datePoll->setType(Poll::TYPE_DATE); + $datePoll->setAccess(Poll::ACCESS_PRIVATE); + $datePoll->setExpire(0); + $this->datePoll = $this->pollMapper->insert($datePoll); + + // Pre-existing text option for update/delete/confirm/reorder tests + $this->textOption = $this->optionService->add( + $this->textPoll->getId(), + (new SimpleOption())->setText('Initial option') + ); + } + + protected function tearDown(): void { + parent::tearDown(); + // Options are deleted when their poll is deleted + try { + $this->pollMapper->delete($this->textPoll); + } catch (\Exception) { + } + try { + $this->pollMapper->delete($this->datePoll); + } catch (\Exception) { + } + } + + // --- add --- + + public function testAddTextOptionToTextPoll(): void { + $option = $this->optionService->add( + $this->textPoll->getId(), + (new SimpleOption())->setText('New Option') + ); + $this->assertInstanceOf(Option::class, $option); + $this->assertSame('New Option', $option->getPollOptionText()); + $this->assertSame($this->textPoll->getId(), $option->getPollId()); + } + + public function testAddDateOptionToDatePoll(): void { + $timestamp = strtotime('2027-06-15 12:00:00 UTC'); + $option = $this->optionService->add( + $this->datePoll->getId(), + (new SimpleOption())->setDateTime($timestamp) + ); + $this->assertInstanceOf(Option::class, $option); + $this->assertSame($this->datePoll->getId(), $option->getPollId()); + $this->assertSame($timestamp, $option->getTimestamp()); + } + + // --- addBulk --- + + public function testAddBulkAddsMultipleOptions(): void { + $bulkText = implode(PHP_EOL, ['Bulk A', 'Bulk B', 'Bulk C']); + $options = $this->optionService->addBulk($this->textPoll->getId(), $bulkText); + // setUp created 1 option; addBulk adds 3 more → at least 4 + $this->assertGreaterThanOrEqual(4, count($options)); + } + + public function testAddBulkDeduplicates(): void { + $countBefore = count($this->optionService->list($this->textPoll->getId())); + $bulkText = implode(PHP_EOL, ['Unique One', 'Unique One']); + $this->optionService->addBulk($this->textPoll->getId(), $bulkText); + $countAfter = count($this->optionService->list($this->textPoll->getId())); + // array_unique in addBulk means only 1 new option added despite 2 lines + $this->assertSame($countBefore + 1, $countAfter); + } + + // --- list --- + + public function testListReturnsOptionsForPoll(): void { + $options = $this->optionService->list($this->textPoll->getId()); + $this->assertNotEmpty($options); + foreach ($options as $option) { + $this->assertSame($this->textPoll->getId(), $option->getPollId()); + } + } + + public function testListReturnsEmptyArrayForPollWithNoOptions(): void { + $options = $this->optionService->list($this->datePoll->getId()); + $this->assertIsArray($options); + $this->assertEmpty($options); + } + + // --- update --- + + public function testUpdateTextOptionChangesText(): void { + $updated = $this->optionService->update($this->textOption->getId(), 'Updated Text'); + $this->assertSame('Updated Text', $updated->getPollOptionText()); + } + + public function testUpdateTextOptionThrowsOnEmptyText(): void { + $this->expectException(InsufficientAttributesException::class); + $this->optionService->update($this->textOption->getId(), ''); + } + + // --- delete / restore --- + + public function testDeleteSetsDeletedTimestamp(): void { + $deleted = $this->optionService->delete($this->textOption->getId()); + $this->assertGreaterThan(0, $deleted->getDeleted()); + } + + public function testRestoreClearsDeletedTimestamp(): void { + $this->optionService->delete($this->textOption->getId()); + $restored = $this->optionService->delete($this->textOption->getId(), true); + $this->assertSame(0, $restored->getDeleted()); + } + + // --- confirm --- + + public function testConfirmOptionOnExpiredPoll(): void { + // Poll must be expired for confirm permission + $this->textPoll->setExpire(time() - 3600); + $this->pollMapper->update($this->textPoll); + + // Bust OptionService poll cache: load datePoll so the next call for textPoll + // causes a cache miss and re-reads the updated expire from DB + $this->optionService->list($this->datePoll->getId()); + + $confirmed = $this->optionService->confirm($this->textOption->getId()); + $this->assertGreaterThan(0, $confirmed->getConfirmed()); + + // Restore: re-open the poll for tearDown + $this->textPoll->setExpire(0); + $this->pollMapper->update($this->textPoll); + } + + public function testConfirmTogglesConfirmation(): void { + $this->textPoll->setExpire(time() - 3600); + $this->pollMapper->update($this->textPoll); + + // Bust OptionService poll cache so updated expire is read from DB + $this->optionService->list($this->datePoll->getId()); + + $this->optionService->confirm($this->textOption->getId()); // confirm + $unconfirmed = $this->optionService->confirm($this->textOption->getId()); // toggle back + $this->assertSame(0, $unconfirmed->getConfirmed()); + + $this->textPoll->setExpire(0); + $this->pollMapper->update($this->textPoll); + } + + // --- reorder --- + + public function testReorderChangesOptionOrder(): void { + $second = $this->optionService->add( + $this->textPoll->getId(), + (new SimpleOption())->setText('Second Option') + ); + + // Swap order: second first, initial second + $reordered = $this->optionService->reorder($this->textPoll->getId(), [ + ['id' => $second->getId()], + ['id' => $this->textOption->getId()], + ]); + + $this->assertCount(2, $reordered); + $orderById = []; + foreach ($reordered as $opt) { + $orderById[$opt->getId()] = $opt->getOrder(); + } + $this->assertSame(1, $orderById[$second->getId()]); + $this->assertSame(2, $orderById[$this->textOption->getId()]); + } +} diff --git a/tests/Unit/Service/PollGroupServiceTest.php b/tests/Unit/Service/PollGroupServiceTest.php new file mode 100644 index 0000000000..1b22349fdb --- /dev/null +++ b/tests/Unit/Service/PollGroupServiceTest.php @@ -0,0 +1,197 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + $this->pollGroupService = Server::get(PollGroupService::class); + $this->pollGroupMapper = Server::get(PollGroupMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $this->poll = $this->pollMapper->insert($poll); + } + + protected function tearDown(): void { + parent::tearDown(); + // Poll groups without polls are cleaned up by tidyPollGroups. + // The poll itself (and any linked group relations) must be deleted manually. + try { + $this->pollMapper->delete($this->poll); + } catch (\Exception) { + } + } + + // --- list --- + + public function testListPollGroupsReturnsArray(): void { + $groups = $this->pollGroupService->listPollGroups(); + $this->assertIsArray($groups); + } + + // --- addPollToPollGroup (new group by name) --- + + public function testAddPollToNewGroupCreatesGroup(): void { + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'TestGroup', + ); + $this->assertInstanceOf(PollGroup::class, $group); + $this->assertSame('TestGroup', $group->getName()); + $this->assertContains($this->poll->getId(), $group->getPollIds()); + } + + public function testAddPollToExistingGroup(): void { + // Create a group first via the service + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'ExistingGroup', + ); + + // Create a second poll and add it to the same group + $poll2 = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll2->setOwner('admin'); + $poll2 = $this->pollMapper->insert($poll2); + + try { + $updated = $this->pollGroupService->addPollToPollGroup( + $poll2->getId(), + pollGroupId: $group->getId(), + ); + $this->assertContains($poll2->getId(), $updated->getPollIds()); + } finally { + $this->pollMapper->delete($poll2); + } + } + + public function testAddPollWithoutGroupIdOrNameThrows(): void { + $this->expectException(\OCA\Polls\Exceptions\InsufficientAttributesException::class); + $this->pollGroupService->addPollToPollGroup($this->poll->getId()); + } + + // --- updatePollGroup --- + + public function testUpdatePollGroupChangesName(): void { + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'OriginalName', + ); + + $updated = $this->pollGroupService->updatePollGroup( + $group->getId(), + 'UpdatedName', + 'ext', + 'desc', + ); + + $this->assertSame('UpdatedName', $updated->getName()); + } + + public function testUpdatePollGroupThrowsForNonOwner(): void { + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'OwnedGroup', + ); + + // Switch session to a different user + $this->session->set('ncPollsUserId', 'other_user'); + + $this->expectException(ForbiddenException::class); + $this->pollGroupService->updatePollGroup( + $group->getId(), + 'HackedName', + '', + null, + ); + } + + // --- removePollFromPollGroup --- + + public function testRemovePollFromGroupLeavingOtherPollsReturnsGroup(): void { + // Add two polls to the same group + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'MultiPollGroup', + ); + + $poll2 = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll2->setOwner('admin'); + $poll2 = $this->pollMapper->insert($poll2); + + try { + $this->pollGroupService->addPollToPollGroup($poll2->getId(), pollGroupId: $group->getId()); + + // Remove only the first poll; group should survive + $this->session->set('ncPollsUserId', 'admin'); + $remaining = $this->pollGroupService->removePollFromPollGroup( + $this->poll->getId(), + $group->getId(), + ); + $this->assertInstanceOf(PollGroup::class, $remaining); + $this->assertNotContains($this->poll->getId(), $remaining->getPollIds()); + } finally { + $this->pollMapper->delete($poll2); + } + } + + public function testRemoveLastPollDeletesGroupAndReturnsNull(): void { + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'SinglePollGroup', + ); + + // Removing the only poll triggers tidyPollGroups → group is deleted → null returned + $result = $this->pollGroupService->removePollFromPollGroup( + $this->poll->getId(), + $group->getId(), + ); + $this->assertNull($result); + } + + public function testRemovePollNotInGroupThrows(): void { + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'GroupForNotFound', + ); + + $poll2 = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll2->setOwner('admin'); + $poll2 = $this->pollMapper->insert($poll2); + + try { + $this->expectException(NotFoundException::class); + $this->pollGroupService->removePollFromPollGroup($poll2->getId(), $group->getId()); + } finally { + $this->pollMapper->delete($poll2); + } + } +} diff --git a/tests/Unit/Service/PollServiceTest.php b/tests/Unit/Service/PollServiceTest.php new file mode 100644 index 0000000000..72af2f11c8 --- /dev/null +++ b/tests/Unit/Service/PollServiceTest.php @@ -0,0 +1,191 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + $this->pollService = Server::get(PollService::class); + $this->pollMapper = Server::get(PollMapper::class); + + // Create a base poll for read/update/archive/close tests + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $this->poll = $this->pollMapper->insert($poll); + } + + protected function tearDown(): void { + parent::tearDown(); + try { + $this->pollMapper->delete($this->poll); + } catch (\Exception $e) { + // already deleted by test + } + } + + // --- add --- + + public function testAddCreatesTextPoll(): void { + $poll = $this->pollService->add(Poll::TYPE_TEXT, 'My Text Poll'); + $this->assertInstanceOf(Poll::class, $poll); + $this->assertSame(Poll::TYPE_TEXT, $poll->getType()); + $this->assertSame('My Text Poll', $poll->getTitle()); + $this->assertSame('admin', $poll->getOwner()); + $this->pollMapper->delete($poll); + } + + public function testAddCreatesDatePoll(): void { + $poll = $this->pollService->add(Poll::TYPE_DATE, 'My Date Poll'); + $this->assertInstanceOf(Poll::class, $poll); + $this->assertSame(Poll::TYPE_DATE, $poll->getType()); + $this->pollMapper->delete($poll); + } + + public function testAddThrowsOnInvalidType(): void { + $this->expectException(InvalidPollTypeException::class); + $this->pollService->add('invalidType', 'Title'); + } + + public function testAddThrowsOnEmptyTitle(): void { + $this->expectException(EmptyTitleException::class); + $this->pollService->add(Poll::TYPE_TEXT, ''); + } + + // --- get --- + + public function testGetReturnsExistingPoll(): void { + $result = $this->pollService->get($this->poll->getId()); + $this->assertInstanceOf(Poll::class, $result); + $this->assertSame($this->poll->getId(), $result->getId()); + } + + public function testGetThrowsOnNonExistentPoll(): void { + $this->expectException(NotFoundException::class); + $this->pollService->get(PHP_INT_MAX); + } + + // --- update --- + + public function testUpdateChangesTitle(): void { + $result = $this->pollService->update($this->poll->getId(), ['title' => 'Updated Title']); + $this->assertSame('Updated Title', $result['poll']->getTitle()); + } + + public function testUpdateReturnsDiff(): void { + $result = $this->pollService->update($this->poll->getId(), ['title' => 'Another Title']); + $this->assertArrayHasKey('poll', $result); + $this->assertArrayHasKey('diff', $result); + $this->assertArrayHasKey('changes', $result); + } + + public function testUpdateThrowsOnEmptyTitle(): void { + $this->expectException(EmptyTitleException::class); + $this->pollService->update($this->poll->getId(), ['title' => '']); + } + + public function testUpdateThrowsOnInvalidShowResults(): void { + $this->expectException(InvalidShowResultsException::class); + $this->pollService->update($this->poll->getId(), ['showResults' => 'invalidValue']); + } + + public function testUpdateChangesShowResults(): void { + $result = $this->pollService->update($this->poll->getId(), ['showResults' => Poll::SHOW_RESULTS_CLOSED]); + $this->assertSame(Poll::SHOW_RESULTS_CLOSED, $result['poll']->getShowResults()); + } + + // --- toggleArchive --- + + public function testToggleArchiveArchivesPoll(): void { + $result = $this->pollService->toggleArchive($this->poll->getId()); + $this->assertGreaterThan(0, $result->getDeleted()); + } + + public function testToggleArchiveRestoresPoll(): void { + // Archive first + $this->pollService->toggleArchive($this->poll->getId()); + // Then restore + $result = $this->pollService->toggleArchive($this->poll->getId()); + $this->assertSame(0, $result->getDeleted()); + } + + // --- close / reopen --- + + public function testCloseSetsExpiryInPast(): void { + $result = $this->pollService->close($this->poll->getId()); + $this->assertLessThan(time(), $result->getExpire()); + $this->assertGreaterThan(0, $result->getExpire()); + } + + public function testReopenClearsExpiry(): void { + $this->pollService->close($this->poll->getId()); + $result = $this->pollService->reopen($this->poll->getId()); + $this->assertSame(0, $result->getExpire()); + } + + // --- clone --- + + public function testCloneCreatesNewPoll(): void { + $clone = $this->pollService->clone($this->poll->getId()); + $this->assertInstanceOf(Poll::class, $clone); + $this->assertNotSame($this->poll->getId(), $clone->getId()); + $this->assertSame('Clone of ' . $this->poll->getTitle(), $clone->getTitle()); + $this->assertSame('admin', $clone->getOwner()); + $this->pollMapper->delete($clone); + } + + // --- delete --- + + public function testDeleteRemovesPoll(): void { + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $poll = $this->pollMapper->insert($poll); + + $result = $this->pollService->delete($poll->getId()); + $this->assertInstanceOf(Poll::class, $result); + + $this->expectException(NotFoundException::class); + $this->pollService->get($poll->getId()); + } + + public function testDeleteThrowsOnNonExistentPoll(): void { + $this->expectException(AlreadyDeletedException::class); + $this->pollService->delete(PHP_INT_MAX); + } + + // --- getValidEnum --- + + public function testGetValidEnumReturnsExpectedKeys(): void { + $enum = $this->pollService->getValidEnum(); + $this->assertArrayHasKey('pollType', $enum); + $this->assertArrayHasKey('access', $enum); + $this->assertArrayHasKey('showResults', $enum); + $this->assertContains(Poll::TYPE_TEXT, $enum['pollType']); + $this->assertContains(Poll::TYPE_DATE, $enum['pollType']); + } +} diff --git a/tests/Unit/Service/PreferencesServiceTest.php b/tests/Unit/Service/PreferencesServiceTest.php new file mode 100644 index 0000000000..496ff40a94 --- /dev/null +++ b/tests/Unit/Service/PreferencesServiceTest.php @@ -0,0 +1,74 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + // PreferencesService is a singleton; call load() explicitly so it picks + // up the session user set above instead of whatever was set before. + $this->preferencesService = Server::get(PreferencesService::class); + $this->preferencesService->load(); + } + + // --- get --- + + public function testGetReturnsPreferences(): void { + $prefs = $this->preferencesService->get(); + $this->assertInstanceOf(Preferences::class, $prefs); + $this->assertSame('admin', $prefs->getUserId()); + } + + public function testGetReturnsDefaultSettings(): void { + $settings = $this->preferencesService->get()->getUserSettings(); + $this->assertArrayHasKey('useAlternativeStyling', $settings); + $this->assertArrayHasKey('defaultViewTextPoll', $settings); + } + + // --- write --- + + public function testWriteUpdatesSettings(): void { + $newSettings = array_merge( + Preferences::DEFAULT_SETTINGS, + ['useAlternativeStyling' => true], + ); + $prefs = $this->preferencesService->write($newSettings); + $this->assertInstanceOf(Preferences::class, $prefs); + $this->assertTrue($prefs->getUserSettings()['useAlternativeStyling']); + } + + public function testWriteDoesNotAffectOtherKeys(): void { + $newSettings = array_merge( + Preferences::DEFAULT_SETTINGS, + ['relevantOffset' => 60], + ); + $prefs = $this->preferencesService->write($newSettings); + $this->assertSame(60, $prefs->getUserSettings()['relevantOffset']); + // Other defaults remain intact + $this->assertArrayHasKey('defaultViewDatePoll', $prefs->getUserSettings()); + } + + public function testWriteUpdatesTimestamp(): void { + $before = time(); + $prefs = $this->preferencesService->write(Preferences::DEFAULT_SETTINGS); + $this->assertGreaterThanOrEqual($before, $prefs->getTimestamp()); + } +} diff --git a/tests/Unit/Service/ShareServiceTest.php b/tests/Unit/Service/ShareServiceTest.php new file mode 100644 index 0000000000..57fbd770e5 --- /dev/null +++ b/tests/Unit/Service/ShareServiceTest.php @@ -0,0 +1,189 @@ +originalSystemSettings = Server::get(SystemSettings::class); + $settingsMock = $this->createMock(SystemSettings::class); + $settingsMock->method('getShareCreateAllowed')->willReturn(true); + $settingsMock->method('getExternalShareCreationAllowed')->willReturn(true); + \OC::$server->registerService(SystemSettings::class, fn () => $settingsMock); + + $this->session = Server::get(ISession::class); + $this->userSession = Server::get(UserSession::class); + $this->shareService = Server::get(ShareService::class); + $this->shareMapper = Server::get(ShareMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $this->poll = $this->pollMapper->insert($poll); + + // Pre-create a TYPE_USER share for admin. + // Also used to establish a proper logged-in session so that + // UserSession::getIsLoggedIn() returns true (required for PERMISSION_SHARE_ADD). + $share = $this->fm->instance('OCA\Polls\Db\Share'); + $share->setPollId($this->poll->getId()); + $share->setType(Share::TYPE_USER); + $share->setUserId('admin'); + $this->userShare = $this->shareMapper->insert($share); + + $this->login(); + } + + protected function tearDown(): void { + parent::tearDown(); + // Shares cascade-delete with the poll + try { + $this->pollMapper->delete($this->poll); + } catch (\Exception) { + } + // Restore the real SystemSettings so other test classes are unaffected + $original = $this->originalSystemSettings; + \OC::$server->registerService(SystemSettings::class, fn () => $original); + } + + private function login(): void { + $this->userSession->cleanSession(); + // Set the core Nextcloud user session so IUserSession::isLoggedIn() returns true + \OC_User::setUserId('admin'); + $this->session->set(UserSession::SESSION_KEY_SHARE_TOKEN, $this->userShare->getToken()); + $this->session->set(UserSession::SESSION_KEY_USER_ID, 'admin'); + } + + // --- add --- + + public function testAddPublicShareCreatesShare(): void { + $share = $this->shareService->add($this->poll->getId(), Share::TYPE_PUBLIC); + $this->assertInstanceOf(Share::class, $share); + $this->assertSame(Share::TYPE_PUBLIC, $share->getType()); + $this->assertSame($this->poll->getId(), $share->getPollId()); + $this->assertNotEmpty($share->getToken()); + } + + public function testAddEmailShareCreatesShare(): void { + $share = $this->shareService->add( + $this->poll->getId(), + Share::TYPE_EMAIL, + 'invited@polls.example.com', + 'Invited Person', + 'invited@polls.example.com', + ); + $this->assertInstanceOf(Share::class, $share); + $this->assertSame(Share::TYPE_EMAIL, $share->getType()); + $this->assertSame($this->poll->getId(), $share->getPollId()); + } + + public function testAddDuplicatePublicShareCreatesNewShare(): void { + $first = $this->shareService->add($this->poll->getId(), Share::TYPE_PUBLIC); + $second = $this->shareService->add($this->poll->getId(), Share::TYPE_PUBLIC); + $this->assertNotSame($first->getId(), $second->getId()); + } + + // --- get --- + + public function testGetShareByToken(): void { + $share = $this->shareService->get($this->userShare->getToken()); + $this->assertInstanceOf(Share::class, $share); + $this->assertSame($this->userShare->getId(), $share->getId()); + } + + // --- list --- + + public function testListReturnsPollShares(): void { + $shares = $this->shareService->list($this->poll->getId()); + $this->assertNotEmpty($shares); + foreach ($shares as $share) { + $this->assertSame($this->poll->getId(), $share->getPollId()); + } + } + + // --- delete / restore --- + + public function testDeleteByTokenSetsDeletedTimestamp(): void { + $deleted = $this->shareService->deleteByToken($this->userShare->getToken()); + $this->assertGreaterThan(0, $deleted->getDeleted()); + } + + public function testRestoreByTokenClearsDeletedTimestamp(): void { + $this->shareService->deleteByToken($this->userShare->getToken()); + $restored = $this->shareService->deleteByToken($this->userShare->getToken(), restore: true); + $this->assertSame(0, $restored->getDeleted()); + } + + // --- lock / unlock --- + + public function testLockByTokenSetsLockedTimestamp(): void { + $locked = $this->shareService->lockByToken($this->userShare->getToken()); + $this->assertGreaterThan(0, $locked->getLocked()); + } + + public function testUnlockByTokenClearsLockedTimestamp(): void { + $this->shareService->lockByToken($this->userShare->getToken()); + $unlocked = $this->shareService->lockByToken($this->userShare->getToken(), unlock: true); + $this->assertSame(0, $unlocked->getLocked()); + } + + // --- setType --- + + public function testSetTypeChangesUserShareToAdmin(): void { + $updated = $this->shareService->setType($this->userShare->getToken(), Share::TYPE_ADMIN); + $this->assertSame(Share::TYPE_ADMIN, $updated->getType()); + } + + public function testSetTypeChangesAdminShareToUser(): void { + // Promote to admin first, then demote back + $this->shareService->setType($this->userShare->getToken(), Share::TYPE_ADMIN); + $updated = $this->shareService->setType($this->userShare->getToken(), Share::TYPE_USER); + $this->assertSame(Share::TYPE_USER, $updated->getType()); + } + + public function testSetTypeRejectsInvalidTransition(): void { + // Only user↔admin transitions are supported; public→admin must not change + $publicShare = $this->shareService->add($this->poll->getId(), Share::TYPE_PUBLIC); + $result = $this->shareService->setType($publicShare->getToken(), Share::TYPE_ADMIN); + // setType silently returns the unchanged share when transition is not allowed + $this->assertSame(Share::TYPE_PUBLIC, $result->getType()); + } + + // --- setEmailAddress --- + + public function testSetEmailAddressThrowsForNonExternalShare(): void { + $this->expectException(InvalidShareTypeException::class); + $this->shareService->setEmailAddress($this->userShare, 'new@example.com'); + } +} diff --git a/tests/Unit/Service/SubscriptionServiceTest.php b/tests/Unit/Service/SubscriptionServiceTest.php new file mode 100644 index 0000000000..994102f360 --- /dev/null +++ b/tests/Unit/Service/SubscriptionServiceTest.php @@ -0,0 +1,109 @@ +getHasEmail(). + // Ensure admin has an email and that the core NC session is active so + // UserSession::getIsLoggedIn() returns true and getCurrentUser() resolves + // to the real admin user rather than Ghost. + $adminUser = Server::get(IUserManager::class)->get('admin'); + $this->adminOriginalEmail = $adminUser?->getEMailAddress() ?? ''; + if ($adminUser !== null && !$adminUser->getEMailAddress()) { + $adminUser->setEMailAddress('admin@test.local'); + } + \OC_User::setUserId('admin'); + + // Reset the polls UserSession cache so getCurrentUser() is re-resolved + // from the core session set above, then restore the polls session key. + $userSession = Server::get(UserSession::class); + $userSession->cleanSession(); + + $this->session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + $this->subscriptionService = Server::get(SubscriptionService::class); + $this->pollMapper = Server::get(PollMapper::class); + + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $this->poll = $this->pollMapper->insert($poll); + } + + protected function tearDown(): void { + parent::tearDown(); + Server::get(IUserManager::class)->get('admin')?->setEMailAddress($this->adminOriginalEmail); + // Subscriptions cascade-delete with the poll + try { + $this->pollMapper->delete($this->poll); + } catch (\Exception) { + } + } + + // --- get --- + + public function testGetReturnsFalseWhenNotSubscribed(): void { + $this->assertFalse($this->subscriptionService->get($this->poll->getId())); + } + + public function testGetReturnsTrueWhenSubscribed(): void { + $this->subscriptionService->set(true, $this->poll->getId()); + $this->assertTrue($this->subscriptionService->get($this->poll->getId())); + } + + // --- set --- + + public function testSetSubscribeReturnsTrue(): void { + $result = $this->subscriptionService->set(true, $this->poll->getId()); + $this->assertTrue($result); + } + + public function testSetUnsubscribeWhenNotSubscribedReturnsFalse(): void { + $result = $this->subscriptionService->set(false, $this->poll->getId()); + $this->assertFalse($result); + } + + public function testSetUnsubscribeAfterSubscribeReturnsFalse(): void { + $this->subscriptionService->set(true, $this->poll->getId()); + $result = $this->subscriptionService->set(false, $this->poll->getId()); + $this->assertFalse($result); + } + + public function testSubscribeTwiceIsIdempotent(): void { + $this->subscriptionService->set(true, $this->poll->getId()); + // Duplicate insert hits unique constraint → returns true (already subscribed) + $result = $this->subscriptionService->set(true, $this->poll->getId()); + $this->assertTrue($result); + } + + public function testGetReturnsFalseAfterUnsubscribe(): void { + $this->subscriptionService->set(true, $this->poll->getId()); + $this->subscriptionService->set(false, $this->poll->getId()); + $this->assertFalse($this->subscriptionService->get($this->poll->getId())); + } +} diff --git a/tests/Unit/Service/VoteServiceTest.php b/tests/Unit/Service/VoteServiceTest.php new file mode 100644 index 0000000000..4cb69059d8 --- /dev/null +++ b/tests/Unit/Service/VoteServiceTest.php @@ -0,0 +1,214 @@ +session = Server::get(ISession::class); + $this->pollMapper = Server::get(PollMapper::class); + $this->shareMapper = Server::get(ShareMapper::class); + $this->voteMapper = Server::get(VoteMapper::class); + $this->voteService = Server::get(VoteService::class); + $this->optionService = Server::get(OptionService::class); + $this->userSession = Server::get(UserSession::class); + + // Poll owned by admin, private access, voting open (useNo=0 is default) + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $poll->setAccess(Poll::ACCESS_PRIVATE); + $poll->setExpire(0); + $poll->setUseNo(0); + $this->poll = $this->pollMapper->insert($poll); + + // TYPE_USER share for admin — gives getCurrentUser() a User object (TYPE_USER) + $ownerShare = $this->fm->instance('OCA\Polls\Db\Share'); + $ownerShare->setPollId($this->poll->getId()); + $ownerShare->setType(Share::TYPE_USER); + $ownerShare->setUserId('admin'); + $this->ownerShare = $this->shareMapper->insert($ownerShare); + + // TYPE_EXTERNAL share for a simulated external participant + $externalShare = $this->fm->instance('OCA\Polls\Db\Share'); + $externalShare->setPollId($this->poll->getId()); + $externalShare->setType(Share::TYPE_EXTERNAL); + $externalShare->setUserId(self::EXTERNAL_USER_ID); + $this->externalShare = $this->shareMapper->insert($externalShare); + + // Login before creating option so OptionService can check permissions + $this->loginAsOwner(); + + // Option created via service so pollOptionHash is computed and stored in DB + // (factory-created options bypass setPoll() and lack the hash, causing VoteMapper + // join failures) + $this->option = $this->optionService->add( + $this->poll->getId(), + (new SimpleOption())->setText('Test option') + ); + } + + protected function tearDown(): void { + parent::tearDown(); + $this->voteMapper->deleteByPollAndUserId($this->poll->getId(), 'admin'); + $this->voteMapper->deleteByPollAndUserId($this->poll->getId(), self::EXTERNAL_USER_ID); + $this->shareMapper->delete($this->ownerShare); + $this->shareMapper->delete($this->externalShare); + $this->pollMapper->delete($this->poll); + } + + // --- session helpers --- + + /** + * Login as poll owner via TYPE_USER share. + * UserSession resolves getCurrentUser() → User('admin') with TYPE_USER. + */ + private function loginAsOwner(): void { + $this->userSession->cleanSession(); + $this->session->set(UserSession::SESSION_KEY_SHARE_TOKEN, $this->ownerShare->getToken()); + $this->session->set(UserSession::SESSION_KEY_USER_ID, 'admin'); + } + + /** + * Login as external participant via TYPE_EXTERNAL share. + * UserSession resolves getCurrentUser() → GenericUser with TYPE_EXTERNAL. + */ + private function loginAsExternalUser(): void { + $this->userSession->cleanSession(); + $this->session->set(UserSession::SESSION_KEY_SHARE_TOKEN, $this->externalShare->getToken()); + $this->session->set(UserSession::SESSION_KEY_USER_ID, self::EXTERNAL_USER_ID); + } + + // --- set vote --- + + public function testOwnerCanSetVoteYes(): void { + $vote = $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $this->assertInstanceOf(Vote::class, $vote); + $this->assertSame(Vote::VOTE_YES, $vote->getVoteAnswer()); + $this->assertSame('admin', $vote->getUserId()); + $this->assertSame($this->poll->getId(), $vote->getPollId()); + } + + public function testOwnerCanSetVoteMaybe(): void { + $vote = $this->voteService->set($this->option->getId(), Vote::VOTE_EVENTUALLY); + $this->assertSame(Vote::VOTE_EVENTUALLY, $vote->getVoteAnswer()); + } + + public function testOwnerCanUpdateVoteFromYesToMaybe(): void { + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $updated = $this->voteService->set($this->option->getId(), Vote::VOTE_EVENTUALLY); + $this->assertSame(Vote::VOTE_EVENTUALLY, $updated->getVoteAnswer()); + } + + public function testSetSameAnswerIsIdempotent(): void { + $first = $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $second = $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + // Returns existing vote without change + $this->assertSame($first->getId(), $second->getId()); + $this->assertSame(Vote::VOTE_YES, $second->getVoteAnswer()); + } + + public function testSetVoteNoDeletesExistingVoteWhenUseNoIsFalse(): void { + // useNo=0 (default): setting 'no' on an existing vote deletes it + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $result = $this->voteService->set($this->option->getId(), Vote::VOTE_NO); + // Service returns the vote entity with empty answer after deletion + $this->assertSame('', $result->getVoteAnswer()); + } + + public function testSetVoteNoStoredWhenUseNoIsTrue(): void { + // Enable 'no' votes on the poll + $this->poll->setUseNo(1); + $this->pollMapper->update($this->poll); + + $vote = $this->voteService->set($this->option->getId(), Vote::VOTE_NO); + $this->assertSame(Vote::VOTE_NO, $vote->getVoteAnswer()); + + // Restore default + $this->poll->setUseNo(0); + $this->pollMapper->update($this->poll); + } + + // --- external user --- + + public function testExternalUserCanVote(): void { + $this->loginAsExternalUser(); + $vote = $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $this->assertInstanceOf(Vote::class, $vote); + $this->assertSame(Vote::VOTE_YES, $vote->getVoteAnswer()); + $this->assertSame(self::EXTERNAL_USER_ID, $vote->getUserId()); + } + + // --- list --- + + public function testOwnerCanListAllVotes(): void { + // Create votes for both owner and external user + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $this->loginAsExternalUser(); + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + + // Switch back to owner and list + $this->loginAsOwner(); + $votes = $this->voteService->list($this->poll->getId()); + $this->assertCount(2, $votes); + } + + // --- deleteUserFromPoll --- + + public function testOwnerCanDeleteOwnVotesFromPoll(): void { + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $userId = $this->voteService->deleteUserFromPoll($this->poll->getId()); + $this->assertSame('admin', $userId); + + $remainingVotes = $this->voteMapper->findByPollAndUser($this->poll->getId(), 'admin'); + $this->assertEmpty($remainingVotes); + } + + public function testOwnerCanDeleteExternalUserVotesFromPoll(): void { + $this->loginAsExternalUser(); + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + + // Owner deletes the external user's votes + $this->loginAsOwner(); + $userId = $this->voteService->deleteUserFromPoll($this->poll->getId(), self::EXTERNAL_USER_ID); + $this->assertSame(self::EXTERNAL_USER_ID, $userId); + + $remainingVotes = $this->voteMapper->findByPollAndUser($this->poll->getId(), self::EXTERNAL_USER_ID); + $this->assertEmpty($remainingVotes); + } +} diff --git a/vite.config.js b/vite.config.js index 42bc16c08c..46c86d0141 100644 --- a/vite.config.js +++ b/vite.config.js @@ -18,9 +18,17 @@ const customConfig = { }, }, }, - define: { - '__APP_VERSION__': JSON.stringify(process.env.npm_package_version), - }, + define: { + '__APP_VERSION__': JSON.stringify(process.env.npm_package_version), + }, + build: { + // rollup-plugin-esbuild-minify runs in renderChunk (before Rollup resolves + // !~{NNN}~ placeholder hashes) and renames variables to short names that + // collide across chunk sections, causing vite:esbuild-transpile to fail with + // "'_s' is not declared in this file". Use terser instead: it runs in + // generateBundle after all placeholders are resolved. + minify: 'terser', + }, } export default createAppConfig( { @@ -34,5 +42,6 @@ export default createAppConfig( inlineCSS: { relativeCSSInjection: true }, config: customConfig, assetsPrefix: `polls-${process.env.npm_package_version}-`, + minify: false, // disable rollup-plugin-esbuild-minify; terser is used instead (see above) }, )