diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml
index 2435a082..6d12ae42 100644
--- a/.github/workflows/backend.yml
+++ b/.github/workflows/backend.yml
@@ -4,9 +4,9 @@ on: [workflow_dispatch, push, pull_request]
jobs:
run:
- uses: flarum/framework/.github/workflows/REUSABLE_backend.yml@main
+ uses: flarum/framework/.github/workflows/REUSABLE_backend.yml@1.x
with:
- enable_backend_testing: false
+ enable_backend_testing: true
enable_phpstan: true
backend_directory: .
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index 1b67573c..6c834f63 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -4,7 +4,7 @@ on: [workflow_dispatch, push, pull_request]
jobs:
run:
- uses: flarum/framework/.github/workflows/REUSABLE_frontend.yml@main
+ uses: flarum/framework/.github/workflows/REUSABLE_frontend.yml@1.x
with:
enable_bundlewatch: false
enable_prettier: true
diff --git a/.gitignore b/.gitignore
index d34e123e..528d5861 100755
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ node_modules
vendor
composer.lock
js/dist
+.phpunit.result.cache
diff --git a/README.md b/README.md
index 4ac2daa1..8b6c680e 100755
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ A [Flarum](http://flarum.org) extension. A Flarum extension that adds polls to y
### Installation
```sh
-composer require fof/polls
+composer require fof/polls:"*"
```
#### Migrating from ReFlar Polls
@@ -48,5 +48,6 @@ You can only run the command when the extension is enabled in the admin panel.
- [Packagist](https://packagist.org/packages/fof/polls)
- [GitHub](https://github.com/packages/FriendsOfFlarum/polls)
+- [Discuss](https://discuss.flarum.org/d/20586)
An extension by [FriendsOfFlarum](https://github.com/FriendsOfFlarum).
\ No newline at end of file
diff --git a/composer.json b/composer.json
index 88f48a2d..6578f369 100755
--- a/composer.json
+++ b/composer.json
@@ -52,18 +52,36 @@
},
"flarum-cli": {
"modules": {
- "githubActions": true
+ "githubActions": true,
+ "backendTesting": true
}
}
},
"require-dev": {
- "flarum/phpstan": "*"
+ "flarum/phpstan": "*",
+ "flarum/testing": "^1.0.0"
},
"scripts": {
"analyse:phpstan": "phpstan analyse",
- "clear-cache:phpstan": "phpstan clear-result-cache"
+ "clear-cache:phpstan": "phpstan clear-result-cache",
+ "test": [
+ "@test:unit",
+ "@test:integration"
+ ],
+ "test:unit": "phpunit -c tests/phpunit.unit.xml",
+ "test:integration": "phpunit -c tests/phpunit.integration.xml",
+ "test:setup": "@php tests/integration/setup.php"
},
"scripts-descriptions": {
- "analyse:phpstan": "Run static analysis"
+ "analyse:phpstan": "Run static analysis",
+ "test": "Runs all tests.",
+ "test:unit": "Runs all unit tests.",
+ "test:integration": "Runs all integration tests.",
+ "test:setup": "Sets up a database for use with integration tests. Execute this only once."
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "FoF\\Polls\\Tests\\": "tests/"
+ }
}
}
diff --git a/extend.php b/extend.php
index 7c8dfdac..c6da828b 100755
--- a/extend.php
+++ b/extend.php
@@ -25,18 +25,19 @@
return [
(new Extend\Frontend('forum'))
- ->js(__DIR__.'/js/dist/forum.js')
- ->css(__DIR__.'/resources/less/forum.less')
- ->route('/polls', 'polls'),
+ ->js(__DIR__ . '/js/dist/forum.js')
+ ->css(__DIR__ . '/resources/less/forum.less')
+ ->route('/polls', 'fof_polls_directory', Content\PollsDirectory::class),
(new Extend\Frontend('admin'))
- ->js(__DIR__.'/js/dist/admin.js')
- ->css(__DIR__.'/resources/less/admin.less'),
+ ->js(__DIR__ . '/js/dist/admin.js')
+ ->css(__DIR__ . '/resources/less/admin.less'),
- new Extend\Locales(__DIR__.'/resources/locale'),
+ new Extend\Locales(__DIR__ . '/resources/locale'),
(new Extend\Routes('api'))
->post('/fof/polls', 'fof.polls.create', Controllers\CreatePollController::class)
+ ->get('/fof/polls', 'fof.polls.index', Controllers\ListGlobalPollsController::class)
->get('/fof/polls/{id}', 'fof.polls.show', Controllers\ShowPollController::class)
->patch('/fof/polls/{id}', 'fof.polls.edit', Controllers\EditPollController::class)
->delete('/fof/polls/{id}', 'fof.polls.delete', Controllers\DeletePollController::class)
@@ -50,34 +51,17 @@
(new Extend\Event())
->listen(PostSaving::class, Listeners\SavePollsToDatabase::class)
- ->listen(SettingsSaved::class, function (SettingsSaved $event) {
- foreach ($event->settings as $key => $value) {
- if ($key === 'fof-polls.optionsColorBlend') {
- resolve('fof-user-bio.formatter')->flush();
-
- return;
- }
- }
- }),
+ ->listen(SettingsSaved::class, Listeners\ClearFormatterCache::class),
(new Extend\ApiSerializer(DiscussionSerializer::class))
- ->attribute('hasPoll', function (DiscussionSerializer $serializer, Discussion $discussion): bool {
- return $discussion->polls()->exists();
- })
- ->attribute('canStartPoll', function (DiscussionSerializer $serializer, Discussion $discussion): bool {
- return $serializer->getActor()->can('polls.start', $discussion);
- }),
+ ->attributes(Api\AddDiscussionAttributes::class),
(new Extend\ApiSerializer(PostSerializer::class))
->hasMany('polls', PollSerializer::class)
- ->attribute('canStartPoll', function (PostSerializer $serializer, Post $post): bool {
- return $serializer->getActor()->can('startPoll', $post);
- }),
+ ->attributes(Api\AddPostAttributes::class),
(new Extend\ApiSerializer(ForumSerializer::class))
- ->attribute('canStartPolls', function (ForumSerializer $serializer): bool {
- return $serializer->getActor()->can('discussion.polls.start');
- }),
+ ->attributes(Api\AddForumAttributes::class),
(new Extend\ApiController(Controller\ListDiscussionsController::class))
->addOptionalInclude(['firstPost.polls']),
@@ -112,6 +96,7 @@
(new Extend\Settings())
->default('fof-polls.maxOptions', 10)
->default('fof-polls.optionsColorBlend', true)
+ ->default('fof-polls.directory-default-sort', 'default')
->serializeToForum('allowPollOptionImage', 'fof-polls.allowOptionImage', 'boolval')
->serializeToForum('pollMaxOptions', 'fof-polls.maxOptions', 'intval')
->registerLessConfigVar('fof-polls-options-color-blend', 'fof-polls.optionsColorBlend', function ($value) {
@@ -120,4 +105,7 @@
(new Extend\ModelVisibility(Poll::class))
->scope(Access\ScopePollVisibility::class),
+
+ (new Extend\View())
+ ->namespace('fof-polls', __DIR__ . '/resources/views'),
];
diff --git a/js/admin.js b/js/admin.ts
similarity index 100%
rename from js/admin.js
rename to js/admin.ts
diff --git a/js/forum.js b/js/forum.ts
similarity index 100%
rename from js/forum.js
rename to js/forum.ts
diff --git a/js/src/admin/index.ts b/js/src/admin/index.ts
index e45965af..6e8f58f9 100755
--- a/js/src/admin/index.ts
+++ b/js/src/admin/index.ts
@@ -37,6 +37,14 @@ app.initializers.add('fof/polls', () => {
},
'start'
)
+ .registerPermission(
+ {
+ icon: 'fas fa-signal',
+ label: app.translator.trans('fof-polls.admin.permissions.start_global'),
+ permission: 'startGlobalPoll',
+ },
+ 'start'
+ )
.registerPermission(
{
icon: 'fas fa-pencil-alt',
diff --git a/js/src/forum/addNavItem.ts b/js/src/forum/addNavItem.ts
new file mode 100644
index 00000000..c9bebe74
--- /dev/null
+++ b/js/src/forum/addNavItem.ts
@@ -0,0 +1,20 @@
+import app from 'flarum/forum/app';
+import { extend } from 'flarum/common/extend';
+import IndexPage from 'flarum/forum/components/IndexPage';
+import LinkButton from 'flarum/common/components/LinkButton';
+
+export default function addNavItem() {
+ extend(IndexPage.prototype, 'navItems', (items) => {
+ items.add(
+ 'fof-polls-directory',
+ LinkButton.component(
+ {
+ href: app.route('fof_polls_directory'),
+ icon: 'fas fa-poll',
+ },
+ app.translator.trans('fof-polls.forum.page.nav')
+ ),
+ 35
+ );
+ });
+}
diff --git a/js/src/forum/components/PollsDirectory.tsx b/js/src/forum/components/PollsDirectory.tsx
new file mode 100644
index 00000000..295fe982
--- /dev/null
+++ b/js/src/forum/components/PollsDirectory.tsx
@@ -0,0 +1,148 @@
+import app from 'flarum/forum/app';
+import Page from 'flarum/common/components/Page';
+import extractText from 'flarum/common/utils/extractText';
+import IndexPage from 'flarum/forum/components/IndexPage';
+import ItemList from 'flarum/common/utils/ItemList';
+import listItems from 'flarum/common/helpers/listItems';
+import SelectDropdown from 'flarum/common/components/SelectDropdown';
+import LinkButton from 'flarum/common/components/LinkButton';
+import Select from 'flarum/common/components/Select';
+import Button from 'flarum/common/components/Button';
+import Mithril from 'mithril';
+
+export default class PollsDirectory extends Page {
+ oncreate(vnode: Mithril.Vnode) {
+ super.oncreate(vnode);
+
+ app.setTitle(extractText(app.translator.trans('fof-polls.forum.page.nav')));
+ }
+
+ view() {
+ return (
+
+ {IndexPage.prototype.hero()}
+
+
+
+
+
+
{listItems(this.viewItems().toArray())}
+
{listItems(this.actionItems().toArray())}
+
+ {/*
*/}
+
+
+
+
+ );
+ }
+
+ /**
+ * Our own sidebar. Re-uses Index.sidebarItems as the base
+ * Elements added here will only show up on the user directory page
+ */
+ sidebarItems(): ItemList {
+ const items = IndexPage.prototype.sidebarItems();
+
+ items.setContent(
+ 'nav',
+ SelectDropdown.component(
+ {
+ buttonClassName: 'Button',
+ className: 'App-titleControl',
+ },
+ this.navItems().toArray()
+ )
+ );
+
+ return items;
+ }
+
+ /**
+ * Our own sidebar navigation. Re-uses Index.navItems as the base
+ * Elements added here will only show up on the user directory page
+ */
+ navItems(): ItemList {
+ const items = IndexPage.prototype.navItems();
+ const params = this.stickyParams();
+
+ items.setContent(
+ 'fof-polls-directory',
+ LinkButton.component(
+ {
+ href: app.route('fof_polls_directory', params),
+ icon: 'fas fa-poll',
+ },
+ app.translator.trans('fof-polls.forum.page.nav')
+ )
+ );
+
+ return items;
+ }
+
+ stickyParams() {
+ return {
+ sort: m.route.param('sort'),
+ q: m.route.param('q'),
+ };
+ }
+
+ changeParams(sort: string) {
+ const params = this.params();
+
+ if (sort === app.forum.attribute('pollsDirectoryDefaultSort')) {
+ delete params.sort;
+ } else {
+ params.sort = sort;
+ }
+
+ this.state.refreshParams(params);
+
+ const routeParams = { ...params };
+ delete routeParams.qBuilder;
+
+ m.route.set(app.route('fof_polls_directory', routeParams));
+ }
+
+ viewItems() {
+ const items = new ItemList();
+ const sortMap = this.state.sortMap();
+
+ const sortOptions = {};
+ for (const i in sortMap) {
+ sortOptions[i] = app.translator.trans('fof-polls.lib.sort.' + i);
+ }
+
+ items.add(
+ 'sort',
+ Select.component({
+ options: sortOptions,
+ value: this.state.getParams().sort || app.forum.attribute('pollsDirectoryDefaultSort'),
+ onchange: this.changeParams.bind(this),
+ }),
+ 100
+ );
+
+ return items;
+ }
+
+ actionItems(): ItemList {
+ const items = new ItemList();
+
+ items.add(
+ 'refresh',
+ Button.component({
+ title: app.translator.trans('fof-polls.forum.page.refresh_tooltip'),
+ icon: 'fas fa-sync',
+ className: 'Button Button--icon',
+ onclick: () => {
+ this.state.refresh();
+ },
+ })
+ );
+
+ return items;
+ }
+}
diff --git a/js/src/forum/components/index.js b/js/src/forum/components/index.ts
similarity index 100%
rename from js/src/forum/components/index.js
rename to js/src/forum/components/index.ts
diff --git a/js/src/forum/extend.js b/js/src/forum/extend.js
deleted file mode 100644
index b9d1ffc2..00000000
--- a/js/src/forum/extend.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import Extend from 'flarum/common/extenders';
-import Post from 'flarum/common/models/Post';
-import Forum from 'flarum/common/models/Forum';
-import Discussion from 'flarum/common/models/Discussion';
-import Poll from './models/Poll';
-import PollsPage from './components/PollsPage';
-import PollOption from './models/PollOption';
-import PollVote from './models/PollVote';
-
-export default [
- new Extend.Store().add('polls', Poll).add('poll_options', PollOption).add('poll_votes', PollVote),
-
- new Extend.Model(Post).hasMany('polls').attribute('canStartPoll'),
-
- new Extend.Model(Forum).attribute('canStartPolls'),
-
- new Extend.Model(Discussion).attribute('hasPoll').attribute('canStartPoll'),
-
- // new Extend.Routes().add('polls', '/polls', ),
- // new Extend.Routes().add('polls', '/polls'),
-];
diff --git a/js/src/forum/extend.ts b/js/src/forum/extend.ts
new file mode 100644
index 00000000..2f99a386
--- /dev/null
+++ b/js/src/forum/extend.ts
@@ -0,0 +1,29 @@
+import Extend from 'flarum/common/extenders';
+import Post from 'flarum/common/models/Post';
+import Forum from 'flarum/common/models/Forum';
+import Discussion from 'flarum/common/models/Discussion';
+import Poll from './models/Poll';
+import PollOption from './models/PollOption';
+import PollVote from './models/PollVote';
+import PollsDirectory from './components/PollsDirectory';
+
+export default [
+ new Extend.Routes() //
+ .add('fof_polls_directory', '/polls', PollsDirectory),
+
+ new Extend.Store() //
+ .add('polls', Poll)
+ .add('poll_options', PollOption)
+ .add('poll_votes', PollVote),
+
+ new Extend.Model(Post) //
+ .hasMany('polls')
+ .attribute('canStartPoll'),
+
+ new Extend.Model(Forum) //
+ .attribute('canStartPolls'),
+
+ new Extend.Model(Discussion) //
+ .attribute('hasPoll')
+ .attribute('canStartPoll'),
+];
diff --git a/js/src/forum/index.js b/js/src/forum/index.ts
similarity index 87%
rename from js/src/forum/index.js
rename to js/src/forum/index.ts
index 8b0e9f56..e18f1d38 100755
--- a/js/src/forum/index.js
+++ b/js/src/forum/index.ts
@@ -5,6 +5,7 @@ import addComposerItems from './addComposerItems';
import addPollsLink from './addPollsLink';
import addPollsToPost from './addPollsToPost';
import addPostControls from './addPostControls';
+import addNavItem from './addNavItem';
export * from './components';
export * from './models';
@@ -17,12 +18,7 @@ app.initializers.add('fof/polls', () => {
addPollsLink();
addPollsToPost();
addPostControls();
-
- // TMP
- app.routes.polls = {
- path: '/polls',
- component: PollsPage,
- };
+ addNavItem();
});
export { default as extend } from './extend';
diff --git a/js/src/forum/models/Poll.js b/js/src/forum/models/Poll.js
deleted file mode 100755
index 0f4da4c5..00000000
--- a/js/src/forum/models/Poll.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import Model from 'flarum/common/Model';
-
-export default class Poll extends Model {
- question = Model.attribute('question');
- hasEnded = Model.attribute('hasEnded');
- endDate = Model.attribute('endDate');
-
- publicPoll = Model.attribute('publicPoll');
- hideVotes = Model.attribute('hideVotes');
- allowChangeVote = Model.attribute('allowChangeVote');
- allowMultipleVotes = Model.attribute('allowMultipleVotes');
- maxVotes = Model.attribute('maxVotes');
-
- voteCount = Model.attribute('voteCount');
-
- canVote = Model.attribute('canVote');
- canEdit = Model.attribute('canEdit');
- canDelete = Model.attribute('canDelete');
- canSeeVoters = Model.attribute('canSeeVoters');
- canChangeVote = Model.attribute('canChangeVote');
-
- options = Model.hasMany('options');
- votes = Model.hasMany('votes');
- myVotes = Model.hasMany('myVotes');
-
- apiEndpoint() {
- return `/fof/polls${this.exists ? `/${this.data.id}` : ''}`;
- }
-}
diff --git a/js/src/forum/models/Poll.ts b/js/src/forum/models/Poll.ts
new file mode 100755
index 00000000..583c4d64
--- /dev/null
+++ b/js/src/forum/models/Poll.ts
@@ -0,0 +1,82 @@
+import Model from 'flarum/common/Model';
+import PollOption from './PollOption';
+import PollVote from './PollVote';
+
+export default class Poll extends Model {
+ question() {
+ return Model.attribute('question').call(this);
+ }
+
+ hasEnded() {
+ return Model.attribute('hasEnded').call(this);
+ }
+
+ endDate() {
+ return Model.attribute('endDate', Model.transformDate).call(this);
+ }
+
+ publicPoll() {
+ return Model.attribute('publicPoll').call(this);
+ }
+
+ hideVotes() {
+ return Model.attribute('hideVotes').call(this);
+ }
+
+ allowChangeVote() {
+ return Model.attribute('allowChangeVote').call(this);
+ }
+
+ allowMultipleVotes() {
+ return Model.attribute('allowMultipleVotes').call(this);
+ }
+
+ maxVotes() {
+ return Model.attribute('maxVotes').call(this);
+ }
+
+ voteCount() {
+ return Model.attribute('voteCount').call(this);
+ }
+
+ canVote() {
+ return Model.attribute('canVote').call(this);
+ }
+
+ canEdit() {
+ return Model.attribute('canEdit').call(this);
+ }
+
+ canDelete() {
+ return Model.attribute('canDelete').call(this);
+ }
+
+ canSeeVoters() {
+ return Model.attribute('canSeeVoters').call(this);
+ }
+
+ canChangeVote() {
+ return Model.attribute('canChangeVote').call(this);
+ }
+
+ options() {
+ return Model.hasMany('options').call(this);
+ }
+
+ votes() {
+ return Model.hasMany('votes').call(this);
+ }
+
+ myVotes() {
+ return Model.hasMany('myVotes').call(this);
+ }
+
+ isGlobal() {
+ return Model.attribute('isGlobal').call(this);
+ }
+
+ apiEndpoint() {
+ /** @ts-ignore */
+ return `/fof/polls${this.exists ? `/${this.data.id}` : ''}`;
+ }
+}
diff --git a/js/src/forum/models/PollOption.js b/js/src/forum/models/PollOption.js
deleted file mode 100755
index c0f60ad6..00000000
--- a/js/src/forum/models/PollOption.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Model from 'flarum/common/Model';
-
-export default class PollOption extends Model {
- answer = Model.attribute('answer');
- imageUrl = Model.attribute('imageUrl');
- voteCount = Model.attribute('voteCount');
-
- poll = Model.hasOne('polls');
- votes = Model.hasMany('votes');
-
- apiEndpoint() {
- return `/fof/polls/answers${this.exists ? `/${this.data.id}` : ''}`;
- }
-}
diff --git a/js/src/forum/models/PollOption.ts b/js/src/forum/models/PollOption.ts
new file mode 100755
index 00000000..d83bf08e
--- /dev/null
+++ b/js/src/forum/models/PollOption.ts
@@ -0,0 +1,30 @@
+import Model from 'flarum/common/Model';
+import Poll from './Poll';
+import PollVote from './PollVote';
+
+export default class PollOption extends Model {
+ answer() {
+ return Model.attribute('answer').call(this);
+ }
+
+ imageUrl() {
+ return Model.attribute('imageUrl').call(this);
+ }
+
+ voteCount() {
+ return Model.attribute('voteCount').call(this);
+ }
+
+ poll() {
+ return Model.hasOne('polls').call(this);
+ }
+
+ votes() {
+ return Model.hasMany('votes').call(this);
+ }
+
+ apiEndpoint() {
+ /** @ts-ignore */
+ return `/fof/polls/answers${this.exists ? `/${this.data.id}` : ''}`;
+ }
+}
diff --git a/js/src/forum/models/PollVote.js b/js/src/forum/models/PollVote.js
deleted file mode 100755
index ac8e4a58..00000000
--- a/js/src/forum/models/PollVote.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Model from 'flarum/common/Model';
-
-export default class PollVote extends Model {
- poll = Model.hasOne('poll');
- option = Model.hasOne('option');
- user = Model.hasOne('user');
-
- pollId = Model.attribute('pollId');
- optionId = Model.attribute('optionId');
-
- apiEndpoint() {
- return `/fof/polls/${this.pollId()}/vote`;
- }
-}
diff --git a/js/src/forum/models/PollVote.ts b/js/src/forum/models/PollVote.ts
new file mode 100755
index 00000000..1eaa1ee1
--- /dev/null
+++ b/js/src/forum/models/PollVote.ts
@@ -0,0 +1,30 @@
+import Model from 'flarum/common/Model';
+import Poll from './Poll';
+import PollOption from './PollOption';
+import User from 'flarum/common/models/User';
+
+export default class PollVote extends Model {
+ poll() {
+ return Model.hasOne('poll').call(this);
+ }
+
+ option() {
+ return Model.hasOne('option').call(this);
+ }
+
+ user() {
+ return Model.hasOne('user').call(this);
+ }
+
+ pollId() {
+ return Model.attribute('pollId').call(this);
+ }
+
+ optionId() {
+ return Model.attribute('optionId').call(this);
+ }
+
+ apiEndpoint() {
+ return `/fof/polls/${this.pollId()}/vote`;
+ }
+}
diff --git a/js/src/forum/models/index.js b/js/src/forum/models/index.ts
similarity index 100%
rename from js/src/forum/models/index.js
rename to js/src/forum/models/index.ts
diff --git a/js/src/forum/states/PollDirectoryState.ts b/js/src/forum/states/PollDirectoryState.ts
new file mode 100644
index 00000000..28446910
--- /dev/null
+++ b/js/src/forum/states/PollDirectoryState.ts
@@ -0,0 +1,135 @@
+import app from 'flarum/forum/app';
+
+/**
+ * Based on Flarum's DiscussionListState
+ */
+import SortMap from '../../common/utils/SortMap';
+
+export default class UserDirectoryState {
+ constructor(params = {}, app = window.app) {
+ this.params = params;
+
+ this.app = app;
+
+ this.users = [];
+
+ this.moreResults = false;
+
+ this.loading = false;
+
+ this.qBuilder = {};
+ }
+
+ requestParams() {
+ const params = { include: [], filter: {} };
+
+ const sortKey = this.params.sort || app.forum.attribute('userDirectoryDefaultSort');
+
+ // sort might be set to null if no sort params has been passed
+ params.sort = this.sortMap()[sortKey];
+
+ if (this.params.q) {
+ params.filter.q = this.params.q;
+ }
+
+ return params;
+ }
+
+ sortMap() {
+ return {
+ default: '',
+ ...new SortMap().sortMap(),
+ };
+ }
+
+ getParams() {
+ return this.params;
+ }
+
+ clear() {
+ this.users = [];
+ m.redraw();
+ }
+
+ refreshParams(newParams) {
+ if (!this.hasUsers() || Object.keys(newParams).some((key) => this.getParams()[key] !== newParams[key])) {
+ const q = '';
+ this.params = newParams;
+
+ if (newParams.qBuilder) {
+ Object.assign(this.qBuilder, newParams.qBuilder || {});
+ this.params.q = Object.values(this.qBuilder).join(' ').trim();
+ }
+
+ if (!this.params.q && q) {
+ this.params.q = q;
+ }
+
+ this.refresh();
+ }
+ }
+
+ refresh() {
+ this.loading = true;
+
+ this.clear();
+
+ return this.loadResults().then(
+ (results) => {
+ this.users = [];
+ this.parseResults(results);
+ },
+ () => {
+ this.loading = false;
+ m.redraw();
+ }
+ );
+ }
+
+ loadResults(offset) {
+ const preloadedUsers = this.app.preloadedApiDocument();
+
+ if (preloadedUsers) {
+ return Promise.resolve(preloadedUsers);
+ }
+
+ const params = this.requestParams();
+ params.page = { offset };
+ params.include = params.include.join(',');
+
+ return this.app.store.find('users', params);
+ }
+
+ loadMore() {
+ this.loading = true;
+
+ this.loadResults(this.users.length).then(this.parseResults.bind(this));
+ }
+
+ parseResults(results) {
+ this.users.push(...results);
+
+ this.loading = false;
+ this.moreResults = !!results.payload.links && !!results.payload.links.next;
+
+ m.redraw();
+
+ return results;
+ }
+
+ hasUsers() {
+ return this.users.length > 0;
+ }
+
+ isLoading() {
+ return this.loading;
+ }
+
+ isSearchResults() {
+ return !!this.params.q;
+ }
+
+ empty() {
+ return !this.hasUsers() && !this.isLoading();
+ }
+}
diff --git a/migrations/2024_01_31_update_polls_set_post_id_nullable.php b/migrations/2024_01_31_update_polls_set_post_id_nullable.php
new file mode 100644
index 00000000..8d2410bc
--- /dev/null
+++ b/migrations/2024_01_31_update_polls_set_post_id_nullable.php
@@ -0,0 +1,26 @@
+ function (Builder $schema) {
+ $schema->table('polls', function (Blueprint $table) {
+ $table->integer('post_id')->unsigned()->nullable()->change();
+ });
+ },
+ 'down' => function (Builder $schema) {
+ $schema->table('polls', function (Blueprint $table) {
+ $table->integer('post_id')->unsigned()->nullable(false)->change();
+ });
+ },
+];
diff --git a/resources/locale/en.yml b/resources/locale/en.yml
index 45bdb3cf..8d1181ff 100755
--- a/resources/locale/en.yml
+++ b/resources/locale/en.yml
@@ -8,6 +8,7 @@ fof-polls:
permissions:
view_results_without_voting: View results without voting
start: Start a poll
+ start_global: Start a global poll
self_edit: Edit created polls (requires post edit permission)
self_post_edit: Edit *all* polls on own posts (requires post edit permission)
vote: Vote on polls
@@ -57,6 +58,9 @@ fof-polls:
delete_confirm: Are you sure you want to delete this poll?
edit: Edit Poll
+ page:
+ nav: Polls
+
tooltip:
badge: Poll
votes: "{count, plural, one {# vote} other {# votes}}"
diff --git a/resources/views/directory/index.blade.php b/resources/views/directory/index.blade.php
new file mode 100644
index 00000000..f40a0dfe
--- /dev/null
+++ b/resources/views/directory/index.blade.php
@@ -0,0 +1,16 @@
+
+
diff --git a/src/Access/PollPolicy.php b/src/Access/PollPolicy.php
index 942f416a..b58e9dfc 100644
--- a/src/Access/PollPolicy.php
+++ b/src/Access/PollPolicy.php
@@ -23,7 +23,7 @@ public function seeVoteCount(User $actor, Poll $poll)
return $this->deny();
}
- if ($poll->myVotes($actor)->count() || $actor->can('polls.viewResultsWithoutVoting', $poll->post->discussion)) {
+ if ($poll->myVotes($actor)->count() || $actor->can('polls.viewResultsWithoutVoting', $poll->post !== null ? $poll->post->discussion : null)) {
return $this->allow();
}
}
@@ -48,7 +48,7 @@ public function view(User $actor, Poll $poll)
public function vote(User $actor, Poll $poll)
{
- if ($actor->can('polls.vote', $poll->post->discussion) && !$poll->hasEnded()) {
+ if ($actor->can('polls.vote', $poll->post !== null ? $poll->post->discussion : null) && !$poll->hasEnded()) {
return $this->allow();
}
}
@@ -62,7 +62,7 @@ public function changeVote(User $actor, Poll $poll)
public function edit(User $actor, Poll $poll)
{
- if ($actor->can('polls.moderate', $poll->post->discussion)) {
+ if ($actor->can('polls.moderate', $poll->post !== null ? $poll->post->discussion : null)) {
return $this->allow();
}
diff --git a/src/Access/ScopePollVisibility.php b/src/Access/ScopePollVisibility.php
index ea9f2442..a73303f8 100644
--- a/src/Access/ScopePollVisibility.php
+++ b/src/Access/ScopePollVisibility.php
@@ -22,7 +22,8 @@ public function __invoke(User $actor, Builder $query)
$query->whereExists(function ($query) use ($actor) {
$query->selectRaw('1')
->from('posts')
- ->whereColumn('posts.id', 'polls.post_id');
+ ->whereColumn('posts.id', 'polls.post_id')
+ ->orWhere('polls.post_id', null);
Post::query()->setQuery($query)->whereVisibleTo($actor);
});
}
diff --git a/src/Api/AddDiscussionAttributes.php b/src/Api/AddDiscussionAttributes.php
new file mode 100644
index 00000000..543b11bb
--- /dev/null
+++ b/src/Api/AddDiscussionAttributes.php
@@ -0,0 +1,26 @@
+polls()->exists();
+ $attributes['canStartPoll'] = $serializer->getActor()->can('polls.start', $discussion);
+
+ return $attributes;
+ }
+}
diff --git a/src/Api/AddForumAttributes.php b/src/Api/AddForumAttributes.php
new file mode 100644
index 00000000..3160b127
--- /dev/null
+++ b/src/Api/AddForumAttributes.php
@@ -0,0 +1,24 @@
+getActor()->can('discussion.polls.start');
+
+ return $attributes;
+ }
+}
diff --git a/src/Api/AddPostAttributes.php b/src/Api/AddPostAttributes.php
new file mode 100644
index 00000000..bb206dfd
--- /dev/null
+++ b/src/Api/AddPostAttributes.php
@@ -0,0 +1,22 @@
+getActor()->can('startPoll', $post);
+
+ return $attributes;
+ }
+}
diff --git a/src/Api/Controllers/CreatePollController.php b/src/Api/Controllers/CreatePollController.php
index d313b435..6f2aab7e 100644
--- a/src/Api/Controllers/CreatePollController.php
+++ b/src/Api/Controllers/CreatePollController.php
@@ -46,11 +46,18 @@ public function __construct(PostRepository $posts, Dispatcher $bus)
protected function data(ServerRequestInterface $request, Document $document)
{
$postId = Arr::get($request->getParsedBody(), 'data.relationships.post.data.id');
+ $actor = RequestUtil::getActor($request);
+
+ $post = null;
+
+ if ($postId !== null) {
+ $post = $this->posts->findOrFail($postId, $actor);
+ }
return $this->bus->dispatch(
new CreatePoll(
- RequestUtil::getActor($request),
- $this->posts->findOrFail($postId),
+ $actor,
+ $post,
Arr::get($request->getParsedBody(), 'data.attributes')
)
);
diff --git a/src/Api/Controllers/ListGlobalPollsController.php b/src/Api/Controllers/ListGlobalPollsController.php
new file mode 100644
index 00000000..c68d983a
--- /dev/null
+++ b/src/Api/Controllers/ListGlobalPollsController.php
@@ -0,0 +1,85 @@
+polls = $polls;
+ $this->url = $url;
+ }
+
+ public function data(ServerRequestInterface $request, Document $document): Collection
+ {
+ $actor = RequestUtil::getActor($request);
+
+ // Not yet needed, but here if/when we do.
+ // $filters = $this->extractFilter($request);
+ $sort = $this->extractSort($request);
+ $sortIsDefault = $this->sortIsDefault($request);
+
+ $limit = $this->extractLimit($request);
+ $offset = $this->extractOffset($request);
+ $include = $this->extractInclude($request);
+
+ $results = $this->polls->queryVisibleTo($actor)
+ ->select('polls.*')
+ ->whereNull('post_id')
+ ->orderBy($sortIsDefault ? 'id' : $sort, 'desc')
+ ->skip($offset)
+ ->take($limit);
+
+ $totalItems = $results->count();
+ $results = $results->get();
+
+ $document->addPaginationLinks(
+ $this->url->to('api')->route('fof.polls.index'),
+ $request->getQueryParams(),
+ $offset,
+ $limit,
+ $totalItems - ($offset + $limit) > 0 ? null : 0
+ );
+
+ $this->loadRelations($results, $include, $request);
+
+ return $results;
+ }
+}
diff --git a/src/Api/Serializers/PollSerializer.php b/src/Api/Serializers/PollSerializer.php
index 9e99b72e..52a7bcdd 100755
--- a/src/Api/Serializers/PollSerializer.php
+++ b/src/Api/Serializers/PollSerializer.php
@@ -45,6 +45,7 @@ protected function getDefaultAttributes($poll)
'canDelete' => $this->actor->can('delete', $poll),
'canSeeVoters' => $this->actor->can('seeVoters', $poll),
'canChangeVote' => $this->actor->can('changeVote', $poll),
+ 'isGlobal' => $poll->isGlobal(),
];
if ($this->actor->can('seeVoteCount', $poll)) {
diff --git a/src/Commands/CreatePoll.php b/src/Commands/CreatePoll.php
index b17d28c8..822e2e44 100644
--- a/src/Commands/CreatePoll.php
+++ b/src/Commands/CreatePoll.php
@@ -22,7 +22,7 @@ class CreatePoll
public $actor;
/**
- * @var Post
+ * @var ?Post
*/
public $post;
@@ -42,7 +42,7 @@ class CreatePoll
* @param array $data
* @param callable|null $savePollOn
*/
- public function __construct(User $actor, Post $post, array $data, callable $savePollOn = null)
+ public function __construct(User $actor, ?Post $post, array $data, callable $savePollOn = null)
{
$this->actor = $actor;
$this->post = $post;
diff --git a/src/Commands/CreatePollHandler.php b/src/Commands/CreatePollHandler.php
index 56c15f67..3d3ecf8e 100644
--- a/src/Commands/CreatePollHandler.php
+++ b/src/Commands/CreatePollHandler.php
@@ -61,7 +61,11 @@ public function __construct(PostRepository $posts, PollValidator $validator, Pol
public function handle(CreatePoll $command)
{
- $command->actor->assertCan('startPoll', $command->post);
+ if ($command->post) {
+ $command->actor->assertCan('startPoll', $command->post);
+ } else {
+ $command->actor->assertCan('startGlobalPoll');
+ }
$attributes = $command->data;
@@ -98,7 +102,7 @@ public function handle(CreatePoll $command)
$poll = Poll::build(
Arr::get($attributes, 'question'),
- $command->post->id,
+ $command->post ? $command->post->id : null,
$command->actor->id,
$carbonDate != null ? $carbonDate->utc() : null,
Arr::get($attributes, 'publicPoll'),
diff --git a/src/Content/PollsDirectory.php b/src/Content/PollsDirectory.php
new file mode 100644
index 00000000..d7633233
--- /dev/null
+++ b/src/Content/PollsDirectory.php
@@ -0,0 +1,92 @@
+ 'username',
+ 'username_za' => '-username',
+ 'newest' => '-joinedAt',
+ 'oldest' => 'joinedAt',
+ 'most_discussions' => '-discussionCount',
+ 'least_discussions' => 'discussionCount',
+ ];
+
+ public function __construct(Client $api, Factory $view, SettingsRepositoryInterface $settings)
+ {
+ $this->api = $api;
+ $this->view = $view;
+ $this->settings = $settings;
+ }
+
+ private function getDocument(User $actor, array $params, ServerRequestInterface $request)
+ {
+ $actor->assertCan('seePollsList');
+
+ return json_decode($this->api->withQueryParams($params)->withParentRequest($request)->get('/fof/polls')->getBody());
+ }
+
+ public function __invoke(Document $document, ServerRequestInterface $request): Document
+ {
+ $queryParams = $request->getQueryParams();
+ $actor = RequestUtil::getActor($request);
+
+ $sort = Arr::pull($queryParams, 'sort') ?: $this->settings->get('fof-polls.directory-default-sort');
+ $q = Arr::pull($queryParams, 'q');
+ $page = Arr::pull($queryParams, 'page', 1);
+
+ $params = [
+ // ?? used to prevent null values. null would result in the whole sortMap array being sent in the params
+ 'sort' => Arr::get($this->sortMap, $sort ?? '', ''),
+ 'filter' => compact('q'),
+ 'page' => ['offset' => ($page - 1) * 20, 'limit' => 20],
+ ];
+
+ $apiDocument = $this->getDocument($actor, $params, $request);
+
+ $document->content = $this->view->make('fof-polls::directory.index', compact('page', 'apiDocument'));
+
+ $document->payload['apiDocument'] = $apiDocument;
+
+ return $document;
+ }
+}
diff --git a/src/Listeners/ClearFormatterCache.php b/src/Listeners/ClearFormatterCache.php
new file mode 100644
index 00000000..e5af563c
--- /dev/null
+++ b/src/Listeners/ClearFormatterCache.php
@@ -0,0 +1,28 @@
+settings as $key => $value) {
+ if ($key === 'fof-polls.optionsColorBlend') {
+ resolve('fof-user-bio.formatter')->flush();
+
+ return;
+ }
+ }
+ }
+}
diff --git a/src/Poll.php b/src/Poll.php
index abd9ef39..c448ac82 100755
--- a/src/Poll.php
+++ b/src/Poll.php
@@ -85,6 +85,11 @@ public static function build($question, $postId, $actorId, $endDate, $publicPoll
return $poll;
}
+ public function isGlobal(): bool
+ {
+ return $this->post_id === null;
+ }
+
/**
* @return bool
*/
diff --git a/src/PollRepository.php b/src/PollRepository.php
index 2118e506..cc585d80 100644
--- a/src/PollRepository.php
+++ b/src/PollRepository.php
@@ -47,4 +47,9 @@ public function findOrFail($id, User $actor = null): Poll
{
return $this->queryVisibleTo($actor)->findOrFail($id);
}
+
+ public function find($id, User $actor = null): ?Poll
+ {
+ return $this->queryVisibleTo($actor)->find($id);
+ }
}
diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/integration/api/CreatePollTest.php b/tests/integration/api/CreatePollTest.php
new file mode 100644
index 00000000..edcd1e6a
--- /dev/null
+++ b/tests/integration/api/CreatePollTest.php
@@ -0,0 +1,479 @@
+extension('fof-polls');
+
+ $this->prepareDatabase([
+ 'users' => [
+ $this->normalUser(),
+ ['id' => 3, 'username' => 'polluser', 'email' => 'polluser@machine.local', 'password' => 'too-obscure', 'is_email_confirmed' => true],
+ ],
+ 'discussions' => [
+ ['id' => 1, 'title' => 'Discussion 1', 'comment_count' => 1, 'participant_count' => 1, 'created_at' => '2021-01-01 00:00:00'],
+ ],
+ 'posts' => [
+ ['id' => 1, 'user_id' => 1, 'discussion_id' => 1, 'number' => 1, 'created_at' => '2021-01-01 00:00:00', 'content' => 'Post 1', 'type' => 'comment'],
+ ],
+ 'group_user' => [
+ ['user_id' => 3, 'group_id' => 4],
+ ],
+ 'group_permission' => [
+ ['permission' => 'discussion.polls.start', 'group_id' => 4],
+ ['permission' => 'startGlobalPoll', 'group_id' => 4],
+ ],
+ ]);
+ }
+
+ public function authorizedUserProvider(): array
+ {
+ return [
+ [1],
+ [3],
+ ];
+ }
+
+ public function unauthorizedUserProvider(): array
+ {
+ return [
+ [2],
+ ];
+ }
+
+ /**
+ * @dataProvider authorizedUserProvider
+ *
+ * @test
+ */
+ public function authorized_user_can_create_poll_in_post(int $userId)
+ {
+ $response = $this->send(
+ $this->request(
+ 'POST',
+ '/api/posts',
+ [
+ 'authenticatedAs' => $userId,
+ 'json' => [
+ 'data' => [
+ 'attributes' => [
+ 'content' => 'Here is my poll',
+ 'poll' => [
+ 'question' => 'What is your favourite colour?',
+ 'publicPoll' => false,
+ 'hideVotes' => false,
+ 'allowChangeVote' => true,
+ 'allowMultipleVotes' => false,
+ 'maxVotes' => 0,
+ 'endDate' => false,
+ 'options' => [
+ [
+ 'answer' => 'Red',
+ ],
+ [
+ 'answer' => 'Blue',
+ ],
+ [
+ 'answer' => 'Yellow',
+ ],
+ ],
+ ],
+ ],
+ 'relationships' => [
+ 'discussion' => [
+ 'data' => [
+ 'type' => 'discussions',
+ 'id' => 1,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]
+ )
+ );
+
+ $this->assertEquals(201, $response->getStatusCode());
+
+ $json = json_decode($response->getBody()->getContents(), true);
+ $data = $json['data'];
+
+ $this->assertArrayHasKey('polls', $data['relationships']);
+
+ $pollId = $data['relationships']['polls']['data'][0]['id'];
+ $this->assertNotNull($pollId);
+
+ $poll = Poll::find($pollId);
+
+ $this->assertNotNull($poll);
+
+ $this->assertEquals('What is your favourite colour?', $poll->question);
+
+ $response = $this->send(
+ $this->request(
+ 'GET',
+ '/api/fof/polls/'.$pollId,
+ [
+ 'authenticatedAs' => $userId,
+ ]
+ )
+ );
+
+ $this->assertEquals(200, $response->getStatusCode());
+
+ $json = json_decode($response->getBody()->getContents(), true);
+
+ $this->assertFalse($json['data']['attributes']['isGlobal']);
+ }
+
+ /**
+ * @dataProvider unauthorizedUserProvider
+ *
+ * @test
+ */
+ public function unauthorized_user_cannot_create_poll_in_post(int $userId)
+ {
+ $response = $this->send(
+ $this->request(
+ 'POST',
+ '/api/posts',
+ [
+ 'authenticatedAs' => $userId,
+ 'json' => [
+ 'data' => [
+ 'attributes' => [
+ 'content' => 'Here is my poll',
+ 'poll' => [
+ 'question' => 'What is your favourite colour?',
+ 'publicPoll' => false,
+ 'hideVotes' => false,
+ 'allowChangeVote' => true,
+ 'allowMultipleVotes' => false,
+ 'maxVotes' => 0,
+ 'endDate' => false,
+ 'options' => [
+ [
+ 'answer' => 'Red',
+ ],
+ [
+ 'answer' => 'Blue',
+ ],
+ [
+ 'answer' => 'Yellow',
+ ],
+ ],
+ ],
+ ],
+ 'relationships' => [
+ 'discussion' => [
+ 'data' => [
+ 'type' => 'discussions',
+ 'id' => 1,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]
+ )
+ );
+
+ $this->assertEquals(422, $response->getStatusCode());
+ $errors = json_decode($response->getBody()->getContents(), true)['errors'];
+
+ $this->assertEquals('validation_error', $errors[0]['code']);
+ $this->assertEquals('/data/attributes/poll', $errors[0]['source']['pointer']);
+ }
+
+ /**
+ * @dataProvider authorizedUserProvider
+ *
+ * @test
+ */
+ public function authorized_user_can_create_post_poll_on_api(int $userId)
+ {
+ $response = $this->send(
+ $this->request(
+ 'POST',
+ '/api/fof/polls',
+ [
+ 'authenticatedAs' => $userId,
+ 'json' => [
+ 'data' => [
+ 'attributes' => [
+ 'question' => 'Add a poll to an existing post',
+ 'publicPoll' => false,
+ 'hideVotes' => false,
+ 'allowChangeVote' => true,
+ 'allowMultipleVotes' => false,
+ 'maxVotes' => 0,
+ 'endDate' => false,
+ 'options' => [
+ [
+ 'answer' => 'Yes',
+ ],
+ [
+ 'answer' => 'No',
+ ],
+ ],
+ ],
+ 'relationships' => [
+ 'post' => [
+ 'data' => [
+ 'type' => 'posts',
+ 'id' => 1,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]
+ )
+ );
+
+ $this->assertEquals(201, $response->getStatusCode());
+
+ $json = json_decode($response->getBody()->getContents(), true);
+ $data = $json['data'];
+ $attributes = $data['attributes'];
+
+ $this->assertEquals('Add a poll to an existing post', $attributes['question']);
+
+ $pollId = $data['id'];
+ $this->assertNotNull($pollId);
+
+ $poll = Poll::find($pollId);
+ $this->assertNotNull($poll);
+ $this->assertEquals(1, $poll->post_id);
+ }
+
+ /**
+ * @dataProvider unauthorizedUserProvider
+ *
+ * @test
+ */
+ public function unauthorized_user_cannot_create_post_poll_on_api(int $userId)
+ {
+ $response = $this->send(
+ $this->request(
+ 'POST',
+ '/api/fof/polls',
+ [
+ 'authenticatedAs' => $userId,
+ 'json' => [
+ 'data' => [
+ 'attributes' => [
+ 'question' => 'Add a poll to an existing post',
+ 'publicPoll' => false,
+ 'hideVotes' => false,
+ 'allowChangeVote' => true,
+ 'allowMultipleVotes' => false,
+ 'maxVotes' => 0,
+ 'endDate' => false,
+ 'options' => [
+ [
+ 'answer' => 'Yes',
+ ],
+ [
+ 'answer' => 'No',
+ ],
+ ],
+ ],
+ 'relationships' => [
+ 'post' => [
+ 'data' => [
+ 'type' => 'posts',
+ 'id' => 1,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]
+ )
+ );
+
+ $this->assertEquals(403, $response->getStatusCode());
+ }
+
+ /**
+ * @dataProvider authorizedUserProvider
+ *
+ * @test
+ */
+ public function authorized_user_cannot_create_post_poll_with_invalid_postId(int $userId)
+ {
+ $response = $this->send(
+ $this->request(
+ 'POST',
+ '/api/fof/polls',
+ [
+ 'authenticatedAs' => $userId,
+ 'json' => [
+ 'data' => [
+ 'attributes' => [
+ 'question' => 'Add a poll to an existing post',
+ 'publicPoll' => false,
+ 'hideVotes' => false,
+ 'allowChangeVote' => true,
+ 'allowMultipleVotes' => false,
+ 'maxVotes' => 0,
+ 'endDate' => false,
+ 'options' => [
+ [
+ 'answer' => 'Yes',
+ ],
+ [
+ 'answer' => 'No',
+ ],
+ ],
+ ],
+ 'relationships' => [
+ 'post' => [
+ 'data' => [
+ 'type' => 'posts',
+ 'id' => 299,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]
+ )
+ );
+
+ $this->assertEquals(404, $response->getStatusCode());
+ }
+
+ /**
+ * @dataProvider authorizedUserProvider
+ *
+ * @test
+ */
+ public function authorized_user_can_create_global_poll_on_api(int $userId)
+ {
+ $response = $this->send(
+ $this->request(
+ 'POST',
+ '/api/fof/polls',
+ [
+ 'authenticatedAs' => $userId,
+ 'json' => [
+ 'data' => [
+ 'attributes' => [
+ 'question' => 'Add a global poll',
+ 'publicPoll' => false,
+ 'hideVotes' => false,
+ 'allowChangeVote' => true,
+ 'allowMultipleVotes' => false,
+ 'maxVotes' => 0,
+ 'endDate' => false,
+ 'options' => [
+ [
+ 'answer' => 'Yes',
+ ],
+ [
+ 'answer' => 'No',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]
+ )
+ );
+
+ $this->assertEquals(201, $response->getStatusCode());
+
+ $json = json_decode($response->getBody()->getContents(), true);
+
+ $data = $json['data'];
+ $attributes = $data['attributes'];
+
+ $this->assertEquals('Add a global poll', $attributes['question']);
+
+ $pollId = $data['id'];
+ $this->assertNotNull($pollId);
+
+ $poll = Poll::find($pollId);
+ $this->assertNotNull($poll);
+ $this->assertNull($poll->post_id);
+
+ $response = $this->send(
+ $this->request(
+ 'GET',
+ '/api/fof/polls/'.$pollId,
+ [
+ 'authenticatedAs' => $userId,
+ ]
+ )
+ );
+
+ $this->assertEquals(200, $response->getStatusCode());
+
+ $json = json_decode($response->getBody()->getContents(), true);
+
+ $this->assertTrue($json['data']['attributes']['isGlobal']);
+ }
+
+ /**
+ * @dataProvider unauthorizedUserProvider
+ *
+ * @test
+ */
+ public function unauthorized_user_cannot_create_global_poll_on_api(int $userId)
+ {
+ $response = $this->send(
+ $this->request(
+ 'POST',
+ '/api/fof/polls',
+ [
+ 'authenticatedAs' => $userId,
+ 'json' => [
+ 'data' => [
+ 'attributes' => [
+ 'question' => 'Add a global poll',
+ 'publicPoll' => false,
+ 'hideVotes' => false,
+ 'allowChangeVote' => true,
+ 'allowMultipleVotes' => false,
+ 'maxVotes' => 0,
+ 'endDate' => false,
+ 'options' => [
+ [
+ 'answer' => 'Yes',
+ ],
+ [
+ 'answer' => 'No',
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]
+ )
+ );
+
+ $this->assertEquals(403, $response->getStatusCode());
+ }
+}
diff --git a/tests/integration/api/ForumSerializerTest.php b/tests/integration/api/ForumSerializerTest.php
new file mode 100644
index 00000000..2490a969
--- /dev/null
+++ b/tests/integration/api/ForumSerializerTest.php
@@ -0,0 +1,92 @@
+extension('fof-polls');
+
+ $this->prepareDatabase([
+ 'users' => [
+ $this->normalUser(),
+ ['id' => 3, 'username' => 'pollsuser', 'email' => 'polls@machine.local', 'password' => 'too-obscure', 'is_email_confirmed' => 1],
+ ],
+ 'group_user' => [
+ ['user_id' => 3, 'group_id' => 4],
+ ],
+ 'group_permission' => [
+ ['permission' => 'discussion.polls.start', 'group_id' => 4],
+ ],
+ ]);
+ }
+
+ /**
+ * @test
+ */
+ public function guest_does_not_have_discussion_polls_start_permission()
+ {
+ $response = $this->send(
+ $this->request('GET', '/api')
+ );
+
+ $this->assertEquals(200, $response->getStatusCode());
+
+ $body = json_decode($response->getBody()->getContents());
+
+ $this->assertFalse($body->data->attributes->canStartPolls);
+ }
+
+ /**
+ * @test
+ */
+ public function normal_user_does_not_have_discussion_polls_start_permission()
+ {
+ $response = $this->send(
+ $this->request('GET', '/api', [
+ 'authenticatedAs' => 2,
+ ])
+ );
+
+ $this->assertEquals(200, $response->getStatusCode());
+
+ $body = json_decode($response->getBody()->getContents());
+
+ $this->assertFalse($body->data->attributes->canStartPolls);
+ }
+
+ /**
+ * @test
+ */
+ public function user_with_discussion_polls_start_permission_has_discussion_polls_start_permission()
+ {
+ $response = $this->send(
+ $this->request('GET', '/api', [
+ 'authenticatedAs' => 3,
+ ])
+ );
+
+ $this->assertEquals(200, $response->getStatusCode());
+
+ $body = json_decode($response->getBody()->getContents());
+
+ $this->assertTrue($body->data->attributes->canStartPolls);
+ }
+}
diff --git a/tests/integration/setup.php b/tests/integration/setup.php
new file mode 100644
index 00000000..d0d1c31d
--- /dev/null
+++ b/tests/integration/setup.php
@@ -0,0 +1,18 @@
+run();
diff --git a/tests/phpunit.integration.xml b/tests/phpunit.integration.xml
new file mode 100644
index 00000000..90fbbff3
--- /dev/null
+++ b/tests/phpunit.integration.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ ../src/
+
+
+
+
+ ./integration
+ ./integration/tmp
+
+
+
diff --git a/tests/phpunit.unit.xml b/tests/phpunit.unit.xml
new file mode 100644
index 00000000..d3a4a3e3
--- /dev/null
+++ b/tests/phpunit.unit.xml
@@ -0,0 +1,27 @@
+
+
+
+
+ ../src/
+
+
+
+
+ ./unit
+
+
+
+
+
+
diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep
new file mode 100644
index 00000000..e69de29b