diff --git a/Sources/Actions/Moderation/Logs.php b/Sources/Actions/Moderation/Logs.php index 1f70326e90c..6698e528c97 100644 --- a/Sources/Actions/Moderation/Logs.php +++ b/Sources/Actions/Moderation/Logs.php @@ -344,7 +344,8 @@ public static function list_getModLogEntries(int $start, int $items_per_page, st 'reg_group_id' => 0, 'log_type' => $log_type, 'modlog_query' => $modlog_query, - 'sort' => $sort, + // Entries that tie on the sorted column stay in the order they were logged, turned around along with it. + 'sort' => $sort . ', lm.id_action' . (preg_match('/\sDESC$/i', $sort) ? ' DESC' : ''), 'start' => $start, 'max' => $items_per_page, ]), diff --git a/tests/Integration/ModLogTest.php b/tests/Integration/ModLogTest.php new file mode 100644 index 00000000000..93b81e98f19 --- /dev/null +++ b/tests/Integration/ModLogTest.php @@ -0,0 +1,148 @@ +logTwice(); + + $this->assertSame([$newer, $older], $this->listed('lm.log_time DESC')); + } + + /** + * Every column can tie, so every column needs the same tiebreaker. The + * sort a list passes is the column's default or its reverse, and the ties + * turn around along with it. + * + * @param string $sort The ORDER BY clause the list passes. + * @param bool $newest_first Whether the tied entries should come out newest first. + */ + #[DataProvider('sorts')] + public function testTiesFollowTheDirectionOfTheSort(string $sort, bool $newest_first): void + { + [$older, $newer] = $this->logTwice(); + + $this->assertSame($newest_first ? [$newer, $older] : [$older, $newer], $this->listed($sort)); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * The sorts both log lists offer, each of which ties for two entries made + * by the same moderator at the same time. + * + * @return array The cases. + */ + public static function sorts(): array + { + return [ + 'time, oldest first' => ['lm.log_time', false], + 'action' => ['lm.action', false], + 'action, reversed' => ['lm.action DESC', true], + 'moderator' => ['mem.real_name', false], + 'moderator, reversed' => ['mem.real_name DESC', true], + 'position, reversed' => ['mg.group_name DESC', true], + 'ip' => ['lm.ip', false], + ]; + } + + /****************** + * Internal methods + ******************/ + + /** + * Acts as the administrator, who sees the whole log. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->actingAs($this->adminId()); + $this->time = random_int(1_000_000_000, 1_100_000_000); + } + + /** + * Logs two identical entries in the administration log at the same time. + * + * @return array The older entry's id and the newer one's. + */ + private function logTwice(): array + { + $ids = []; + + for ($i = 0; $i < 2; $i++) { + $ids[] = (int) Db::$db->insert( + 'insert', + '{db_prefix}log_actions', + [ + 'id_log' => 'int', + 'log_time' => 'int', + 'id_member' => 'int', + 'action' => 'string', + 'extra' => 'string', + ], + [[3, $this->time, $this->adminId(), 'modlog_test', '{}']], + ['id_action'], + 1, + ); + } + + return $ids; + } + + /** + * Lists the test's entries from the administration log. + * + * @param string $sort The ORDER BY clause, as the list passes it. + * @return array The ids of the entries, in the order they were listed. + */ + private function listed(string $sort): array + { + return array_keys(Logs::list_getModLogEntries( + 0, + 10, + $sort, + 'lm.log_time = {int:tied_time}', + ['tied_time' => $this->time], + 3, + )); + } +}