From 76788efaba4947f2bc266b0d898994c5bad62a6a Mon Sep 17 00:00:00 2001 From: David Wheatley Date: Thu, 29 Sep 2022 12:12:54 +0100 Subject: [PATCH] feat(statistics): support for custom date ranges (#3622) * feat: backend support for statistics custom date ranges * feat: use seconds-based timestamps on backend instead * feat: add frontend date selection option * feat: add tests for lifetime and timed stats * fix: add error alert when end date is after start date * fix: wrong label * fix: no data when start and end date are same day * fix: use utc dayjs for formatting custom date range on widget * chore: add dayjs as project dep * fix: make end date inclusive * feat: add integration test for custom date period * fix: incorrect ts expect error comment * fix: add missing type * fix: typing errors * fix(tests): remove type from class attribute definition * fix: extract default values to function body * fix: typo * chore: use small modal * fix: add missing `FormControl` class * fix: cast url params to int to enforce type * chore: `yarn format` Signed-off-by: Sami Mazouz Co-authored-by: Sami Mazouz --- extensions/statistics/js/package.json | 12 +- .../src/admin/components/StatisticsWidget.tsx | 189 +++++++++++++++--- .../StatisticsWidgetDateSelectionModal.tsx | 161 +++++++++++++++ extensions/statistics/less/admin.less | 4 + extensions/statistics/locale/en.yml | 15 +- .../src/Api/Controller/ShowStatisticsData.php | 43 +++- .../CanRequestCustomTimedStatisticsTest.php | 107 ++++++++++ .../api/CanRequestLifetimeStatisticsTest.php | 85 ++++++++ .../api/CanRequestTimedStatisticsTest.php | 99 +++++++++ 9 files changed, 678 insertions(+), 37 deletions(-) create mode 100644 extensions/statistics/js/src/admin/components/StatisticsWidgetDateSelectionModal.tsx create mode 100644 extensions/statistics/tests/integration/api/CanRequestCustomTimedStatisticsTest.php create mode 100644 extensions/statistics/tests/integration/api/CanRequestLifetimeStatisticsTest.php create mode 100644 extensions/statistics/tests/integration/api/CanRequestTimedStatisticsTest.php diff --git a/extensions/statistics/js/package.json b/extensions/statistics/js/package.json index 0dcbc0799..42295cba2 100644 --- a/extensions/statistics/js/package.json +++ b/extensions/statistics/js/package.json @@ -7,15 +7,15 @@ "frappe-charts": "^1.6.2" }, "devDependencies": { - "@types/mithril": "^2.0.11", - "prettier": "^2.7.1", - "flarum-webpack-config": "^2.0.0", - "webpack": "^5.73.0", - "webpack-cli": "^4.10.0", "@flarum/prettier-config": "^1.0.0", + "@types/mithril": "^2.0.11", "flarum-tsconfig": "^1.0.2", + "flarum-webpack-config": "^2.0.0", + "prettier": "^2.7.1", "typescript": "^4.7.4", - "typescript-coverage-report": "^0.6.4" + "typescript-coverage-report": "^0.6.4", + "webpack": "^5.73.0", + "webpack-cli": "^4.10.0" }, "scripts": { "dev": "webpack --mode development --watch", diff --git a/extensions/statistics/js/src/admin/components/StatisticsWidget.tsx b/extensions/statistics/js/src/admin/components/StatisticsWidget.tsx index 22dc445f4..ae01b9b15 100644 --- a/extensions/statistics/js/src/admin/components/StatisticsWidget.tsx +++ b/extensions/statistics/js/src/admin/components/StatisticsWidget.tsx @@ -3,13 +3,24 @@ import app from 'flarum/admin/app'; import SelectDropdown from 'flarum/common/components/SelectDropdown'; import Button from 'flarum/common/components/Button'; import abbreviateNumber from 'flarum/common/utils/abbreviateNumber'; +import extractText from 'flarum/common/utils/extractText'; import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; +import Placeholder from 'flarum/common/components/Placeholder'; import icon from 'flarum/common/helpers/icon'; import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget'; +import StatisticsWidgetDateSelectionModal, { IDateSelection, IStatisticsWidgetDateSelectionModalAttrs } from './StatisticsWidgetDateSelectionModal'; + import type Mithril from 'mithril'; +import dayjs from 'dayjs'; +import dayjsUtc from 'dayjs/plugin/utc'; +import dayjsLocalizedFormat from 'dayjs/plugin/localizedFormat'; + +dayjs.extend(dayjsUtc); +dayjs.extend(dayjsLocalizedFormat); + // @ts-expect-error No typings available import { Chart } from 'frappe-charts'; @@ -25,14 +36,23 @@ export default class StatisticsWidget extends DashboardWidget { chart: any; + customPeriod: IDateSelection | null = null; + timedData: Record = {}; lifetimeData: any; + customPeriodData: Record = {}; + + noData: boolean = false; loadingLifetime = true; loadingTimed: Record = this.entities.reduce((acc, curr) => { acc[curr] = 'unloaded'; return acc; }, {} as Record); + loadingCustom: Record = this.entities.reduce((acc, curr) => { + acc[curr] = 'unloaded'; + return acc; + }, {} as Record); selectedEntity = 'users'; selectedPeriod: undefined | string; @@ -105,17 +125,74 @@ export default class StatisticsWidget extends DashboardWidget { m.redraw(); } + async loadCustomRangeData(model: string): Promise { + this.loadingCustom[model] = 'loading'; + m.redraw(); + + // We clone so we can check that the same period is still selected + // once the HTTP request is complete and the data is to be displayed + const range = { ...this.customPeriod }; + try { + const data = await app.request({ + method: 'GET', + url: app.forum.attribute('apiUrl') + '/statistics', + params: { + period: 'custom', + model, + dateRange: { + start: range.start, + end: range.end, + }, + }, + }); + + if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) { + // The range this method was called with is no longer the selected. + // Bail out here. + return; + } + + this.customPeriodData[model] = data; + this.loadingCustom[model] = 'loaded'; + + m.redraw(); + } catch (e) { + if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) { + // The range this method was called with is no longer the selected. + // Bail out here. + return; + } + + console.error(e); + this.loadingCustom[model] = 'fail'; + } + } + className() { return 'StatisticsWidget'; } content() { - const loadingSelectedEntity = this.loadingTimed[this.selectedEntity] !== 'loaded'; + const loadingSelectedEntity = (this.selectedPeriod === 'custom' ? this.loadingCustom : this.loadingTimed)[this.selectedEntity] !== 'loaded'; - const thisPeriod = loadingSelectedEntity ? null : this.periods![this.selectedPeriod!]; + const thisPeriod = loadingSelectedEntity + ? null + : this.selectedPeriod === 'custom' + ? { + start: this.customPeriod?.end!, + end: this.customPeriod?.end!, + step: 86400, + } + : this.periods![this.selectedPeriod!]; - if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') { - this.loadTimedData(this.selectedEntity); + if (this.selectedPeriod === 'custom') { + if (!this.customPeriodData[this.selectedEntity] && this.loadingCustom[this.selectedEntity] === 'unloaded') { + this.loadCustomRangeData(this.selectedEntity); + } + } else { + if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') { + this.loadTimedData(this.selectedEntity); + } } return ( @@ -128,16 +205,56 @@ export default class StatisticsWidget extends DashboardWidget { ) : ( - {Object.keys(this.periods!).map((period) => ( - - ))} + {Object.keys(this.periods!) + .map((period) => ( + + )) + .concat([ + , + ])} )} @@ -148,11 +265,14 @@ export default class StatisticsWidget extends DashboardWidget { const thisPeriodCount = loadingSelectedEntity ? app.translator.trans('flarum-statistics.admin.statistics.loading') : this.getPeriodCount(entity, thisPeriod!); - const lastPeriodCount = loadingSelectedEntity - ? app.translator.trans('flarum-statistics.admin.statistics.loading') - : this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!)); + const lastPeriodCount = + this.selectedPeriod === 'custom' + ? null + : loadingSelectedEntity + ? app.translator.trans('flarum-statistics.admin.statistics.loading') + : this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!)); const periodChange = - loadingSelectedEntity || lastPeriodCount === 0 + loadingSelectedEntity || lastPeriodCount === 0 || lastPeriodCount === null ? 0 : (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100; @@ -197,6 +317,8 @@ export default class StatisticsWidget extends DashboardWidget { /> )} + + {this.noData && } ); } @@ -206,7 +328,14 @@ export default class StatisticsWidget extends DashboardWidget { return; } - const period = this.periods![this.selectedPeriod!]; + const period = + this.selectedPeriod === 'custom' + ? { + start: this.customPeriod?.start!, + end: this.customPeriod?.end!, + step: 86400, + } + : this.periods![this.selectedPeriod!]; const periodLength = period.end - period.start; const labels = []; const thisPeriod = []; @@ -216,12 +345,17 @@ export default class StatisticsWidget extends DashboardWidget { let label; if (period.step < 86400) { - label = dayjs.unix(i).format('h A'); + label = dayjs.unix(i).utc().format('h A'); } else { - label = dayjs.unix(i).format('D MMM'); + label = dayjs.unix(i).utc().format('D MMM'); if (period.step > 86400) { - label += ' - ' + dayjs.unix(i + period.step - 1).format('D MMM'); + label += + ' - ' + + dayjs + .unix(i + period.step - 1) + .utc() + .format('D MMM'); } } @@ -231,6 +365,15 @@ export default class StatisticsWidget extends DashboardWidget { lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength + period.step })); } + if (thisPeriod.length === 0) { + this.noData = true; + m.redraw(); + return; + } else { + this.noData = false; + m.redraw(); + } + const datasets = [{ values: lastPeriod }, { values: thisPeriod }]; const data = { labels, @@ -275,7 +418,7 @@ export default class StatisticsWidget extends DashboardWidget { } getPeriodCount(entity: string, period: { start: number; end: number }) { - const timed: Record = this.timedData[entity]; + const timed: Record = (this.selectedPeriod === 'custom' ? this.customPeriodData : this.timedData)[entity]; let count = 0; for (const t in timed) { diff --git a/extensions/statistics/js/src/admin/components/StatisticsWidgetDateSelectionModal.tsx b/extensions/statistics/js/src/admin/components/StatisticsWidgetDateSelectionModal.tsx new file mode 100644 index 000000000..86d5f251f --- /dev/null +++ b/extensions/statistics/js/src/admin/components/StatisticsWidgetDateSelectionModal.tsx @@ -0,0 +1,161 @@ +import app from 'flarum/admin/app'; +import ItemList from 'flarum/common/utils/ItemList'; +import generateElementId from 'flarum/admin/utils/generateElementId'; +import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; + +import Mithril from 'mithril'; +import Button from 'flarum/common/components/Button'; + +import dayjs from 'dayjs'; +import dayjsUtc from 'dayjs/plugin/utc'; + +dayjs.extend(dayjsUtc); + +export interface IDateSelection { + /** + * Timestamp (seconds, not ms) for start date + */ + start: number; + /** + * Timestamp (seconds, not ms) for end date + */ + end: number; +} + +export interface IStatisticsWidgetDateSelectionModalAttrs extends IInternalModalAttrs { + onModalSubmit: (dates: IDateSelection) => void; + value?: IDateSelection; +} + +interface IStatisticsWidgetDateSelectionModalState { + inputs: { + startDateVal: string; + endDateVal: string; + }; + ids: { + startDate: string; + endDate: string; + }; +} + +export default class StatisticsWidgetDateSelectionModal extends Modal { + /* @ts-expect-error core typings don't allow us to set the type of the state attr :( */ + state: IStatisticsWidgetDateSelectionModalState = { + inputs: { + startDateVal: dayjs().format('YYYY-MM-DD'), + endDateVal: dayjs().format('YYYY-MM-DD'), + }, + ids: { + startDate: generateElementId(), + endDate: generateElementId(), + }, + }; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + if (this.attrs.value) { + this.state.inputs = { + startDateVal: dayjs.utc(this.attrs.value.start * 1000).format('YYYY-MM-DD'), + endDateVal: dayjs.utc(this.attrs.value.end * 1000).format('YYYY-MM-DD'), + }; + } + } + + className(): string { + return 'StatisticsWidgetDateSelectionModal Modal--small'; + } + + title(): Mithril.Children { + return app.translator.trans('flarum-statistics.admin.date_selection_modal.title'); + } + + content(): Mithril.Children { + return ; + } + + items(): ItemList { + const items = new ItemList(); + + items.add('intro',

{app.translator.trans('flarum-statistics.admin.date_selection_modal.description')}

, 100); + + items.add( + 'date_start', +
+ + +
, + 90 + ); + + items.add( + 'date_end', +
+ + +
, + 80 + ); + + items.add( + 'submit', + , + 0 + ); + + return items; + } + + updateState(field: keyof IStatisticsWidgetDateSelectionModalState['inputs']): (e: InputEvent) => void { + return (e: InputEvent) => { + this.state.inputs[field] = (e.currentTarget as HTMLInputElement).value; + }; + } + + submitData(): IDateSelection { + // We force 'zulu' time (UTC) + return { + start: Math.floor(+dayjs.utc(this.state.inputs.startDateVal + 'Z') / 1000), + // Ensures that the end date is the end of the day + end: Math.floor( + +dayjs + .utc(this.state.inputs.endDateVal + 'Z') + .hour(23) + .minute(59) + .second(59) + .millisecond(999) / 1000 + ), + }; + } + + onsubmit(e: SubmitEvent): void { + e.preventDefault(); + + const data = this.submitData(); + + if (data.end < data.start) { + this.alertAttrs = { + type: 'error', + controls: app.translator.trans('flarum-statistics.admin.date_selection_modal.errors.end_before_start'), + }; + return; + } + + this.attrs.onModalSubmit(data); + this.hide(); + } +} diff --git a/extensions/statistics/less/admin.less b/extensions/statistics/less/admin.less index be1d66405..3b850896b 100644 --- a/extensions/statistics/less/admin.less +++ b/extensions/statistics/less/admin.less @@ -109,6 +109,10 @@ padding: 12px 16px; text-align: center; } + + .Placeholder { + padding-bottom: 32px; + } } /*! diff --git a/extensions/statistics/locale/en.yml b/extensions/statistics/locale/en.yml index 04fb4eada..4b2f09cf2 100644 --- a/extensions/statistics/locale/en.yml +++ b/extensions/statistics/locale/en.yml @@ -1,11 +1,21 @@ flarum-statistics: - ## # UNIQUE KEYS - The following keys are used in only one location each. ## # Translations in this namespace are used by the admin interface. admin: + # These translations are used in the date selection modal. + date_selection_modal: + description: | + Pick a custom date range to display statistics for. Loading data may take + multiple minutes on forums with a lot of activity. + end_date: End date (inclusive) + errors: + end_before_start: The end date must be after the start date. + start_date: Start date (inclusive) + submit_button: Confirm date range + title: Choose custom date range # These translations are used in the Statistics dashboard widget. statistics: @@ -16,9 +26,12 @@ flarum-statistics: mini_heading: Forum statistics previous_28_days_label: Previous 28 days previous_7_days_label: Previous 7 days + custom_label: Choose custom range... + custom_label_specified: "{fromDate} to {toDate}" loading: => core.ref.loading posts_heading: => core.ref.posts today_label: Today total_label: Total users_heading: => core.ref.users view_full: View more statistics + no_data: There is no data available for this date range. diff --git a/extensions/statistics/src/Api/Controller/ShowStatisticsData.php b/extensions/statistics/src/Api/Controller/ShowStatisticsData.php index bbe4718d5..0040c80c5 100644 --- a/extensions/statistics/src/Api/Controller/ShowStatisticsData.php +++ b/extensions/statistics/src/Api/Controller/ShowStatisticsData.php @@ -9,6 +9,7 @@ namespace Flarum\Statistics\Api\Controller; +use Carbon\Carbon; use DateTime; use Flarum\Discussion\Discussion; use Flarum\Http\RequestUtil; @@ -69,20 +70,39 @@ class ShowStatisticsData implements RequestHandlerInterface // control panel. $actor->assertAdmin(); - $reportingPeriod = Arr::get($request->getQueryParams(), 'period'); - $model = Arr::get($request->getQueryParams(), 'model'); + $query = $request->getQueryParams(); - return new JsonResponse($this->getResponse($model, $reportingPeriod)); + $reportingPeriod = Arr::get($query, 'period'); + $model = Arr::get($query, 'model'); + $customDateRange = Arr::get($query, 'dateRange'); + + return new JsonResponse($this->getResponse($model, $reportingPeriod, $customDateRange)); } - private function getResponse(?string $model, ?string $period): array + private function getResponse(?string $model, ?string $period, ?array $customDateRange): array { if ($period === 'lifetime') { return $this->getLifetimeStatistics(); } if (! Arr::exists($this->entities, $model)) { - throw new InvalidParameterException(); + throw new InvalidParameterException('A model must be specified'); + } + + if ($period === 'custom') { + $start = (int) $customDateRange['start']; + $end = (int) $customDateRange['end']; + + if (! $customDateRange || ! $start || ! $end) { + throw new InvalidParameterException('A custom date range must be specified'); + } + + // Seconds-based timestamps + $startRange = Carbon::createFromTimestampUTC($start)->toDateTime(); + $endRange = Carbon::createFromTimestampUTC($end)->toDateTime(); + + // We can't really cache this + return $this->getTimedCounts($this->entities[$model][0], $this->entities[$model][1], $startRange, $endRange); } return $this->getTimedStatistics($model); @@ -104,8 +124,16 @@ class ShowStatisticsData implements RequestHandlerInterface }); } - private function getTimedCounts(Builder $query, $column) + private function getTimedCounts(Builder $query, string $column, ?DateTime $startDate = null, ?DateTime $endDate = null) { + if (! isset($startDate)) { + $startDate = new DateTime('-365 days'); + } + + if (! isset($endDate)) { + $endDate = new DateTime(); + } + $results = $query ->selectRaw( 'DATE_FORMAT( @@ -115,7 +143,8 @@ class ShowStatisticsData implements RequestHandlerInterface [new DateTime('-25 hours')] ) ->selectRaw('COUNT(id) as count') - ->where($column, '>', new DateTime('-365 days')) + ->where($column, '>', $startDate) + ->where($column, '<=', $endDate) ->groupBy('time_group') ->pluck('count', 'time_group'); diff --git a/extensions/statistics/tests/integration/api/CanRequestCustomTimedStatisticsTest.php b/extensions/statistics/tests/integration/api/CanRequestCustomTimedStatisticsTest.php new file mode 100644 index 000000000..e1fbd40d0 --- /dev/null +++ b/extensions/statistics/tests/integration/api/CanRequestCustomTimedStatisticsTest.php @@ -0,0 +1,107 @@ +nowTime = Carbon::now()->subDays(10); + + $this->extension('flarum-statistics'); + + $this->prepareDatabase($this->getDatabaseData()); + } + + protected function getDatabaseData(): array + { + return [ + 'users' => [ + ['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()], + ['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(1)], + ['id' => 3, 'username' => 'normal2', 'email' => 'normal2@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(2)], + ], + 'discussions' => [ + ['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ], + 'posts' => [ + ['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()], + ['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)], + ['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)], + ['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(2)], + ['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 2, 'created_at' => $this->nowTime->copy()], + ], + ]; + } + + /** + * @test + */ + public function can_request_timed_stats() + { + $time = $this->nowTime->copy(); + + $start = $time->copy()->subDays(1)->startOfDay()->getTimestamp(); + $end = $time->copy()->endOfDay()->getTimestamp(); + + $timeStart = $time->copy()->startOfDay(); + + $models = [ + 'users' => [ + $timeStart->copy()->getTimestamp() => 1, + $timeStart->copy()->subDays(1)->getTimestamp() => 1, + ], 'discussions' => [ + $timeStart->copy()->getTimestamp() => 1, + $timeStart->copy()->subDays(1)->getTimestamp() => 2, + ], 'posts' => [ + $timeStart->copy()->getTimestamp() => 2, + $timeStart->copy()->subDays(1)->getTimestamp() => 2, + ] + ]; + + foreach ($models as $model => $data) { + $response = $this->send( + $this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([ + 'model' => $model, + 'period' => 'custom', + 'dateRange' => [ + 'start' => $start, + 'end' => $end, + ], + ]) + ); + + $body = json_decode($response->getBody()->getContents(), true); + + $this->assertEquals(200, $response->getStatusCode()); + + $this->assertEquals( + $data, + $body, + ); + } + } +} diff --git a/extensions/statistics/tests/integration/api/CanRequestLifetimeStatisticsTest.php b/extensions/statistics/tests/integration/api/CanRequestLifetimeStatisticsTest.php new file mode 100644 index 000000000..3a7e01746 --- /dev/null +++ b/extensions/statistics/tests/integration/api/CanRequestLifetimeStatisticsTest.php @@ -0,0 +1,85 @@ +nowTime = Carbon::now(); + + $this->extension('flarum-statistics'); + + $this->prepareDatabase($this->getDatabaseData()); + } + + protected function getDatabaseData(): array + { + return [ + 'users' => [ + ['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1], + ['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->subDays(1)], + ], + 'discussions' => [ + ['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime, 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ], + 'posts' => [ + ['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1], + ['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1], + ['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1], + ['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1], + ['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 2], + ], + ]; + } + + /** + * @test + */ + public function can_request_lifetime_stats() + { + $response = $this->send( + $this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([ + 'period' => 'lifetime', + ]) + ); + + $body = json_decode($response->getBody()->getContents(), true); + + $db = $this->getDatabaseData(); + + $this->assertEquals(200, $response->getStatusCode()); + + $this->assertEqualsCanonicalizing( + [ + 'users' => count($db['users']), + 'discussions' => count($db['discussions']), + 'posts' => count($db['posts']), + ], + $body + ); + } +} diff --git a/extensions/statistics/tests/integration/api/CanRequestTimedStatisticsTest.php b/extensions/statistics/tests/integration/api/CanRequestTimedStatisticsTest.php new file mode 100644 index 000000000..8dabfc6e7 --- /dev/null +++ b/extensions/statistics/tests/integration/api/CanRequestTimedStatisticsTest.php @@ -0,0 +1,99 @@ +nowTime = Carbon::now()->subDays(10); + + $this->extension('flarum-statistics'); + + $this->prepareDatabase($this->getDatabaseData()); + } + + protected function getDatabaseData(): array + { + return [ + 'users' => [ + ['id' => 1, 'username' => 'Muralf', 'email' => 'muralf@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()], + ['id' => 2, 'username' => 'normal', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1, 'joined_at' => $this->nowTime->copy()->subDays(1)], + ], + 'discussions' => [ + ['id' => 1, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy(), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 2, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 3, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(1), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ['id' => 4, 'title' => __CLASS__, 'created_at' => $this->nowTime->copy()->subDays(2), 'last_posted_at' => Carbon::now(), 'user_id' => 1, 'first_post_id' => 1, 'comment_count' => 1], + ], + 'posts' => [ + ['id' => 1, 'discussion_id' => 1, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()], + ['id' => 2, 'discussion_id' => 2, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)], + ['id' => 3, 'discussion_id' => 3, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(1)], + ['id' => 4, 'discussion_id' => 4, 'user_id' => 1, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 1, 'created_at' => $this->nowTime->copy()->subDays(2)], + ['id' => 5, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '

Text

', 'is_private' => 0, 'number' => 2, 'created_at' => $this->nowTime->copy()], + ], + ]; + } + + /** + * @test + */ + public function can_request_timed_stats() + { + $time = $this->nowTime->copy(); + $time->setTime(0, 0, 0, 0); + + $models = [ + 'users' => [ + $time->copy()->getTimestamp() => 1, + $time->copy()->subDays(1)->getTimestamp() => 1, + ], 'discussions' => [ + $time->copy()->getTimestamp() => 1, + $time->copy()->subDays(1)->getTimestamp() => 2, + $time->copy()->subDays(2)->getTimestamp() => 1, + ], 'posts' => [ + $time->copy()->getTimestamp() => 2, + $time->copy()->subDays(1)->getTimestamp() => 2, + $time->copy()->subDays(2)->getTimestamp() => 1, + ] + ]; + + foreach ($models as $model => $data) { + $response = $this->send( + $this->request('GET', '/api/statistics', ['authenticatedAs' => 1])->withQueryParams([ + 'model' => $model, + ]) + ); + + $body = json_decode($response->getBody()->getContents(), true); + + $this->assertEquals(200, $response->getStatusCode()); + + $this->assertEqualsCanonicalizing( + $data, + $body + ); + } + } +}