[Server] Let users fetch their own private chat history (#7506)

* [Server] Let users fetch their own private chat history

Players lose their private conversations on reconnect or on another
device. Servatrice can already log private messages, but only
moderators can read the logs.

Add Command_GetPrivateChatHistory / Response_PrivateChatHistory so a
registered user can page through their own logged conversation with one
other user. Operators opt in with logging/allow_user_chat_history
(default off, and log_user_msg_chat must be on). The caller is matched
by account id so reused guest or deleted-account names can't read older
messages; only time, sender name and text are returned. Pages hold
1..200 messages and never split a second, since log_time has 1-second
resolution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Server] Harden private chat history identity matching

Match both participants by account id and name so a federated user's remote id cannot collide with a local account, and stop storing foreign ids in the local chat log. Extract the paging assembly into a pure helper and cover the whole-second boundary rules with gtests.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com>
This commit is contained in:
SwitchOCE 2026-11-09 23:21:43 +11:00 • committed by GitHub
parent 3d8c729517
commit 7709184ecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 395 additions and 4 deletions

View file

@ -804,10 +804,13 @@ Response::ResponseCode Server_ProtocolHandler::cmdMessage(const Command_Message
userInterface->sendProtocolItem(*se); userInterface->sendProtocolItem(*se);
rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, se); rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, se);
databaseInterface->logMessage(userInfo->id(), QString::fromStdString(userInfo->name()), databaseInterface->logMessage(
QString::fromStdString(userInfo->address()), QString::fromStdString(cmd.message()), userInfo->id(), QString::fromStdString(userInfo->name()), QString::fromStdString(userInfo->address()),
Server_DatabaseInterface::MessageTargetChat, userInterface->getUserInfo()->id(), QString::fromStdString(cmd.message()), Server_DatabaseInterface::MessageTargetChat,
receiver); // A federated user's id belongs to the remote server's
// database, not ours. Logging it would collide with a
// local id and corrupt local history lookups.
userInterface->getUserInfo()->server_id() == -1 ? userInterface->getUserInfo()->id() : 0, receiver);
resetIdleTimer(); resetIdleTimer();
return Response::RespOk; return Response::RespOk;
} }

View file

@ -33,6 +33,7 @@ set(PROTO_FILES
command_dump_zone.proto command_dump_zone.proto
command_flip_card.proto command_flip_card.proto
command_game_say.proto command_game_say.proto
command_get_private_chat_history.proto
command_get_server_stats.proto command_get_server_stats.proto
command_inc_card_counter.proto command_inc_card_counter.proto
command_inc_counter.proto command_inc_counter.proto
@ -164,6 +165,7 @@ set(PROTO_FILES
response_list_users.proto response_list_users.proto
response_login.proto response_login.proto
response_password_salt.proto response_password_salt.proto
response_private_chat_history.proto
response_register.proto response_register.proto
response_replay_download.proto response_replay_download.proto
response_replay_download_by_game_id.proto response_replay_download_by_game_id.proto
@ -202,6 +204,7 @@ set(PROTO_FILES
serverinfo_player.proto serverinfo_player.proto
serverinfo_playerping.proto serverinfo_playerping.proto
serverinfo_playerproperties.proto serverinfo_playerproperties.proto
serverinfo_private_chat_message.proto
serverinfo_replay.proto serverinfo_replay.proto
serverinfo_replay_match.proto serverinfo_replay_match.proto
serverinfo_report.proto serverinfo_report.proto

View file

@ -0,0 +1,18 @@
syntax = "proto2";
import "session_commands.proto";
/**
* Fetch the caller's private conversation with user_name.
* user_name must be an active registered account; guest and deleted-account
* names have no stable account id, so they are rejected with RespNameNotFound.
* before is exclusive Unix seconds; zero or unset applies no upper time bound.
* limit is clamped to 1..200; a whole second may exceed it to preserve pagination.
*/
message Command_GetPrivateChatHistory {
extend SessionCommand {
optional Command_GetPrivateChatHistory ext = 1035;
}
optional string user_name = 1;
optional uint64 before = 2;
optional uint32 limit = 3 [default = 50];
}

View file

@ -78,6 +78,7 @@ message Response {
PASSWORD_SALT = 1017; // Response containing password salt PASSWORD_SALT = 1017; // Response containing password salt
GET_ADMIN_NOTES = 1018; // Response with admin notes GET_ADMIN_NOTES = 1018; // Response with admin notes
GET_SERVER_STATS = 1019; // Response with server status statistics GET_SERVER_STATS = 1019; // Response with server status statistics
PRIVATE_CHAT_HISTORY = 1020; // Response with the caller's private conversation
REPLAY_LIST = 1100; // Response listing replays REPLAY_LIST = 1100; // Response listing replays
REPLAY_DOWNLOAD = 1101; // Response for replay download REPLAY_DOWNLOAD = 1101; // Response for replay download
REPLAY_GET_CODE = 1102; // Response containing replay code REPLAY_GET_CODE = 1102; // Response containing replay code

View file

@ -0,0 +1,16 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_private_chat_message.proto";
/**
* Private conversation messages, oldest first, without account IDs or IP addresses.
* When has_more is true, use the oldest returned time as before on the next request.
* Seconds are never split across pages; ordering within a second is unspecified.
*/
message Response_PrivateChatHistory {
extend Response {
optional Response_PrivateChatHistory ext = 1020;
}
repeated ServerInfo_PrivateChatMessage messages = 1;
optional bool has_more = 2;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
/**
* A private conversation message with its time in Unix seconds.
*/
message ServerInfo_PrivateChatMessage {
optional uint64 time = 1;
optional string sender_name = 2;
optional string message = 3;
}

View file

@ -37,6 +37,7 @@ message SessionCommand {
DECK_SHARE_LIST_MINE = 1032; DECK_SHARE_LIST_MINE = 1032;
DECK_SHARE_REMOVE = 1033; DECK_SHARE_REMOVE = 1033;
SUBMIT_PASSWORD_VERIFIER = 1034; SUBMIT_PASSWORD_VERIFIER = 1034;
GET_PRIVATE_CHAT_HISTORY = 1035;
REPLAY_LIST = 1100; REPLAY_LIST = 1100;
REPLAY_DOWNLOAD = 1101; REPLAY_DOWNLOAD = 1101;
REPLAY_MODIFY_MATCH = 1102; REPLAY_MODIFY_MATCH = 1102;

View file

@ -10,6 +10,7 @@ set(servatrice_SOURCES
src/forwarded_address.cpp src/forwarded_address.cpp
src/main.cpp src/main.cpp
src/metrics_registry.cpp src/metrics_registry.cpp
src/private_chat_paging.cpp
src/ratelimiter.cpp src/ratelimiter.cpp
src/servatrice.cpp src/servatrice.cpp
src/servatrice_connection_pool.cpp src/servatrice_connection_pool.cpp

View file

@ -534,6 +534,10 @@ log_user_msg_chat=false
; Log user messages coming from other servers in the network ; Log user messages coming from other servers in the network
log_user_msg_isl=false log_user_msg_isl=false
; Allow registered users to read their retained private messages; requires log_user_msg_chat=true
; Privacy: exposes previously logged conversations to their participants on any device
allow_user_chat_history=false
[audit] [audit]
; Servatrice can record certain actions being performed in the database for server operators to better understand ; Servatrice can record certain actions being performed in the database for server operators to better understand

View file

@ -0,0 +1,31 @@
#include "private_chat_paging.h"
#include <algorithm>
PrivateChatPage assemblePrivateChatPage(const QList<ServerInfo_PrivateChatMessage> &newestFirst, int limit)
{
PrivateChatPage page;
page.messages = newestFirst;
page.hasMore = newestFirst.size() > limit;
if (!page.hasMore) {
std::reverse(page.messages.begin(), page.messages.end());
return page;
}
// The (limit+1)th message is the first one that does not fit. If its second
// appears anywhere in the page, that whole second would be split, so drop
// every message in that second and leave it for the next page.
const quint64 boundary = newestFirst.at(limit).time();
while (!page.messages.isEmpty() && page.messages.last().time() == boundary) {
page.messages.removeLast();
}
if (page.messages.isEmpty()) {
page.needsWholeSecond = true;
page.boundarySecond = boundary;
return page;
}
std::reverse(page.messages.begin(), page.messages.end());
return page;
}

View file

@ -0,0 +1,30 @@
#ifndef PRIVATE_CHAT_PAGING_H
#define PRIVATE_CHAT_PAGING_H
#include <QList>
#include <libcockatrice/protocol/pb/serverinfo_private_chat_message.pb.h>
/** One page of private chat history, ready for the response. */
struct PrivateChatPage
{
/** Messages for the response, oldest first. */
QList<ServerInfo_PrivateChatMessage> messages;
/** Whether messages older than this page may still exist. */
bool hasMore = false;
/**
* Set when every fetched message belongs to the same second, so the caller
* has to fetch that whole second before it can answer. messages is empty in
* that case and boundarySecond names the second to fetch.
*/
bool needsWholeSecond = false;
quint64 boundarySecond = 0;
};
/**
* Assembles one newest-first page from up to limit + 1 messages ordered newest
* first. A second is never split across pages: when the limit would cut into a
* second, that whole second is deferred to the next page instead.
*/
PrivateChatPage assemblePrivateChatPage(const QList<ServerInfo_PrivateChatMessage> &newestFirst, int limit);
#endif // PRIVATE_CHAT_PAGING_H

View file

@ -8,6 +8,7 @@
#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_warning.pb.h" #include "libcockatrice/protocol/pb/serverinfo_warning.pb.h"
#include "private_chat_paging.h"
#include "servatrice.h" #include "servatrice.h"
#include "server.h" #include "server.h"
#include "server_protocolhandler.h" #include "server_protocolhandler.h"
@ -2638,6 +2639,92 @@ int Servatrice_DatabaseInterface::clearBlacklistedBanners()
return cleared; return cleared;
} }
bool Servatrice_DatabaseInterface::getPrivateChatHistory(int callerId,
const QString &callerName,
int peerId,
const QString &peerName,
quint64 before,
int limit,
QList<ServerInfo_PrivateChatMessage> &messages,
bool &hasMore)
{
messages.clear();
hasMore = false;
if (callerId < 1 || peerId < 1 || callerName.isEmpty() || peerName.isEmpty() || !checkSql()) {
return false;
}
// Match both participants by account id, and verify their names as well.
// Names can be reused (guests, re-registration), so ids are what separate
// earlier holders; names are what reject a federated user's remote id that
// happens to collide with a local one in the shared log table.
const QString conversationFilter = " FROM {prefix}_log WHERE target_type = 'chat'"
" AND ((sender_id = :caller_sender_id AND sender_name = :caller_name"
" AND target_id = :peer_target_id AND target_name = :peer_name)"
" OR (target_id = :caller_target_id AND target_name = :caller_name"
" AND sender_id = :peer_sender_id AND sender_name = :peer_name))";
const QString selectMessages = "SELECT UNIX_TIMESTAMP(log_time), sender_name, log_message";
const auto bindParticipants = [&](QSqlQuery *query) {
query->bindValue(":caller_sender_id", callerId);
query->bindValue(":caller_name", callerName);
query->bindValue(":peer_target_id", peerId);
query->bindValue(":peer_name", peerName);
query->bindValue(":peer_sender_id", peerId);
query->bindValue(":caller_target_id", callerId);
};
const auto readMessages = [&](QSqlQuery *query, QList<ServerInfo_PrivateChatMessage> &out) {
while (query->next()) {
ServerInfo_PrivateChatMessage message;
message.set_time(query->value(0).toULongLong());
message.set_sender_name(query->value(1).toString().toStdString());
message.set_message(query->value(2).toString().toStdString());
out.append(message);
}
};
QString queryString = selectMessages + conversationFilter;
if (before) {
queryString.append(" AND log_time < FROM_UNIXTIME(:before)");
}
queryString.append(" ORDER BY log_time DESC LIMIT :limit_size");
QSqlQuery *query = prepareQuery(queryString);
bindParticipants(query);
if (before) {
query->bindValue(":before", before);
}
query->bindValue(":limit_size", limit + 1);
if (!execSqlQuery(query)) {
return false;
}
QList<ServerInfo_PrivateChatMessage> newestFirst;
readMessages(query, newestFirst);
PrivateChatPage page = assemblePrivateChatPage(newestFirst, limit);
if (page.needsWholeSecond) {
query = prepareQuery(selectMessages + conversationFilter + " AND log_time = FROM_UNIXTIME(:second)");
bindParticipants(query);
query->bindValue(":second", page.boundarySecond);
if (!execSqlQuery(query)) {
return false;
}
readMessages(query, page.messages);
std::reverse(page.messages.begin(), page.messages.end());
query = prepareQuery("SELECT 1" + conversationFilter + " AND log_time < FROM_UNIXTIME(:before) LIMIT 1");
bindParticipants(query);
query->bindValue(":before", page.boundarySecond);
if (!execSqlQuery(query)) {
return false;
}
page.hasMore = query->next();
}
messages = page.messages;
hasMore = page.hasMore;
return true;
}
QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user, QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user,
const QString &ipaddress, const QString &ipaddress,
const QString &gamename, const QString &gamename,

View file

@ -12,6 +12,7 @@
#include <libcockatrice/protocol/pb/serverinfo_ban.pb.h> #include <libcockatrice/protocol/pb/serverinfo_ban.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h> #include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h> #include <libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_private_chat_message.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h> #include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user_session.pb.h> #include <libcockatrice/protocol/pb/serverinfo_user_session.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_warning.pb.h> #include <libcockatrice/protocol/pb/serverinfo_warning.pb.h>
@ -224,6 +225,14 @@ public:
const QString &beforeTime = QString(), const QString &beforeTime = QString(),
bool *hasMore = nullptr); bool *hasMore = nullptr);
QList<ServerInfo_UserSession> getUserSessions(const QString &userName, int limit); QList<ServerInfo_UserSession> getUserSessions(const QString &userName, int limit);
bool getPrivateChatHistory(int callerId,
const QString &callerName,
int peerId,
const QString &peerName,
quint64 before,
int limit,
QList<ServerInfo_PrivateChatMessage> &messages,
bool &hasMore);
QList<ServerInfo_UserAlt> getUserAlts(const QString &userName, QList<ServerInfo_UserAlt> getUserAlts(const QString &userName,
int depth = 1, int depth = 1,
bool useEmail = true, bool useEmail = true,

View file

@ -98,6 +98,7 @@
#include <libcockatrice/protocol/pb/command_deck_share_list_mine.pb.h> #include <libcockatrice/protocol/pb/command_deck_share_list_mine.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_remove.pb.h> #include <libcockatrice/protocol/pb/command_deck_share_remove.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h> #include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/command_get_private_chat_history.pb.h>
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h> #include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
#include <libcockatrice/protocol/pb/command_replay_delete_match.pb.h> #include <libcockatrice/protocol/pb/command_replay_delete_match.pb.h>
#include <libcockatrice/protocol/pb/command_replay_download.pb.h> #include <libcockatrice/protocol/pb/command_replay_download.pb.h>
@ -139,6 +140,7 @@
#include <libcockatrice/protocol/pb/response_get_server_stats.pb.h> #include <libcockatrice/protocol/pb/response_get_server_stats.pb.h>
#include <libcockatrice/protocol/pb/response_moderator_last_logins.pb.h> #include <libcockatrice/protocol/pb/response_moderator_last_logins.pb.h>
#include <libcockatrice/protocol/pb/response_password_salt.pb.h> #include <libcockatrice/protocol/pb/response_password_salt.pb.h>
#include <libcockatrice/protocol/pb/response_private_chat_history.pb.h>
#include <libcockatrice/protocol/pb/response_register.pb.h> #include <libcockatrice/protocol/pb/response_register.pb.h>
#include <libcockatrice/protocol/pb/response_remove_user_avatar.pb.h> #include <libcockatrice/protocol/pb/response_remove_user_avatar.pb.h>
#include <libcockatrice/protocol/pb/response_remove_user_banner.pb.h> #include <libcockatrice/protocol/pb/response_remove_user_banner.pb.h>
@ -414,6 +416,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm
return cmdDeckShareRemove(cmd.GetExtension(Command_DeckShareRemove::ext), rc); return cmdDeckShareRemove(cmd.GetExtension(Command_DeckShareRemove::ext), rc);
case SessionCommand::DECK_SHARE_DOWNLOAD: case SessionCommand::DECK_SHARE_DOWNLOAD:
return cmdDeckShareDownload(cmd.GetExtension(Command_DeckShareDownload::ext), rc); return cmdDeckShareDownload(cmd.GetExtension(Command_DeckShareDownload::ext), rc);
case SessionCommand::GET_PRIVATE_CHAT_HISTORY:
return cmdGetPrivateChatHistory(cmd.GetExtension(Command_GetPrivateChatHistory::ext), rc);
case SessionCommand::ACCOUNT_PASSWORD: case SessionCommand::ACCOUNT_PASSWORD:
return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc); return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc);
case SessionCommand::REQUEST_PASSWORD_SALT: case SessionCommand::REQUEST_PASSWORD_SALT:
@ -1702,6 +1706,49 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReplaySubmitCode(const
return Response::RespOk; return Response::RespOk;
} }
Response::ResponseCode AbstractServerSocketInterface::cmdGetPrivateChatHistory(const Command_GetPrivateChatHistory &cmd,
ResponseContainer &rc)
{
if (authState != PasswordRight) {
return Response::RespFunctionNotAllowed;
}
if (!userInfo || !(userInfo->user_level() & ServerInfo_User::IsRegistered)) {
return Response::RespFunctionNotAllowed;
}
if (!settingsCache->value("logging/log_user_msg_chat", false).toBool() ||
!settingsCache->value("logging/allow_user_chat_history", false).toBool()) {
return Response::RespFunctionNotAllowed;
}
const QString peer = nameFromStdString(cmd.user_name());
if (peer.isEmpty()) {
return Response::RespInvalidData;
}
// Resolve the peer to an account id. Matching by name alone would merge the
// conversations of everyone who has ever used that name.
const int peerId = sqlInterface->getUserIdInDB(peer);
if (peerId < 1) {
return Response::RespNameNotFound;
}
const int limit = qBound<quint32>(1, cmd.limit(), 200);
QList<ServerInfo_PrivateChatMessage> messages;
bool hasMore = false;
if (!sqlInterface->getPrivateChatHistory(userInfo->id(), QString::fromStdString(userInfo->name()), peerId, peer,
cmd.before(), limit, messages, hasMore)) {
return Response::RespInternalError;
}
Response_PrivateChatHistory *re = new Response_PrivateChatHistory;
for (const auto &message : messages) {
re->add_messages()->CopyFrom(message);
}
re->set_has_more(hasMore);
rc.setResponseExtension(re);
return Response::RespOk;
}
// MODERATOR FUNCTIONS. // MODERATOR FUNCTIONS.
// May be called by admins and moderators. Permission is checked by the calling function. // May be called by admins and moderators. Permission is checked by the calling function.
Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Command_ViewLogHistory &cmd, Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Command_ViewLogHistory &cmd,

View file

@ -81,6 +81,7 @@ class Command_ForgotPasswordReset;
class Command_GetAdminNotes; class Command_GetAdminNotes;
class Command_GetBanHistory; class Command_GetBanHistory;
class Command_GetModeratorLastLogins; class Command_GetModeratorLastLogins;
class Command_GetPrivateChatHistory;
class Command_GetServerStats; class Command_GetServerStats;
class Command_GetUserAlts; class Command_GetUserAlts;
class Command_GetUserSessions; class Command_GetUserSessions;
@ -142,6 +143,7 @@ private:
Response::ResponseCode cmdAddToList(const Command_AddToList &cmd, ResponseContainer &rc); Response::ResponseCode cmdAddToList(const Command_AddToList &cmd, ResponseContainer &rc);
Response::ResponseCode cmdRemoveFromList(const Command_RemoveFromList &cmd, ResponseContainer &rc); Response::ResponseCode cmdRemoveFromList(const Command_RemoveFromList &cmd, ResponseContainer &rc);
Response::ResponseCode cmdGetPrivateChatHistory(const Command_GetPrivateChatHistory &cmd, ResponseContainer &rc);
int getDeckPathId(int basePathId, QStringList path); int getDeckPathId(int basePathId, QStringList path);
int getDeckPathId(const QString &path); int getDeckPathId(const QString &path);
bool deckListHelper(int folderId, bool deckListHelper(int folderId,

View file

@ -426,6 +426,19 @@ cockatrice_add_test(
LABELS LABELS
server server
) )
cockatrice_add_test(
NAME
private_chat_paging_test
SOURCES
${CMAKE_SOURCE_DIR}/servatrice/src/private_chat_paging.cpp
private_chat_paging_test.cpp
LIBS
libcockatrice_protocol
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/servatrice/src
LABELS
server
)
cockatrice_add_test( cockatrice_add_test(
NAME NAME
latency_tracker_test latency_tracker_test

View file

@ -0,0 +1,115 @@
#include "private_chat_paging.h"
#include <gtest/gtest.h>
namespace
{
ServerInfo_PrivateChatMessage messageAt(quint64 time, const QString &text)
{
ServerInfo_PrivateChatMessage message;
message.set_time(time);
message.set_sender_name("sender");
message.set_message(text.toStdString());
return message;
}
QList<ServerInfo_PrivateChatMessage> newestFirst(std::initializer_list<quint64> times)
{
QList<ServerInfo_PrivateChatMessage> messages;
for (const quint64 time : times) {
messages.append(messageAt(time, QString::number(time)));
}
return messages;
}
QList<quint64> timesOf(const QList<ServerInfo_PrivateChatMessage> &messages)
{
QList<quint64> times;
for (const auto &message : messages) {
times.append(message.time());
}
return times;
}
} // namespace
TEST(PrivateChatPaging, EmptyInputProducesEmptyPage)
{
const PrivateChatPage page = assemblePrivateChatPage({}, 50);
EXPECT_TRUE(page.messages.isEmpty());
EXPECT_FALSE(page.hasMore);
EXPECT_FALSE(page.needsWholeSecond);
}
TEST(PrivateChatPaging, PageShorterThanLimitIsReturnedOldestFirst)
{
const PrivateChatPage page = assemblePrivateChatPage(newestFirst({300, 200, 100}), 50);
EXPECT_EQ(timesOf(page.messages), QList<quint64>({100, 200, 300}));
EXPECT_FALSE(page.hasMore);
EXPECT_FALSE(page.needsWholeSecond);
}
TEST(PrivateChatPaging, PageExactlyAtLimitIsReturnedWithoutMore)
{
const PrivateChatPage page = assemblePrivateChatPage(newestFirst({300, 200, 100}), 3);
EXPECT_EQ(timesOf(page.messages), QList<quint64>({100, 200, 300}));
EXPECT_FALSE(page.hasMore);
}
TEST(PrivateChatPaging, DistinctSecondsKeepWholePage)
{
const PrivateChatPage page = assemblePrivateChatPage(newestFirst({300, 200, 100}), 2);
EXPECT_EQ(timesOf(page.messages), QList<quint64>({200, 300}));
EXPECT_TRUE(page.hasMore);
EXPECT_FALSE(page.needsWholeSecond);
}
TEST(PrivateChatPaging, BoundarySecondIsDeferredToNextPage)
{
const PrivateChatPage page = assemblePrivateChatPage(newestFirst({300, 200, 200}), 2);
EXPECT_EQ(timesOf(page.messages), QList<quint64>({300}));
EXPECT_TRUE(page.hasMore);
EXPECT_FALSE(page.needsWholeSecond);
}
TEST(PrivateChatPaging, WholeSecondIsRequestedWhenNothingElseFits)
{
const PrivateChatPage page = assemblePrivateChatPage(newestFirst({200, 200, 200}), 2);
EXPECT_TRUE(page.messages.isEmpty());
EXPECT_TRUE(page.hasMore);
EXPECT_TRUE(page.needsWholeSecond);
EXPECT_EQ(page.boundarySecond, 200);
}
TEST(PrivateChatPaging, WholeSecondIsRequestedForSingleMessageLimit)
{
const PrivateChatPage page = assemblePrivateChatPage(newestFirst({200, 200}), 1);
EXPECT_TRUE(page.messages.isEmpty());
EXPECT_TRUE(page.needsWholeSecond);
EXPECT_EQ(page.boundarySecond, 200);
}
TEST(PrivateChatPaging, BoundarySecondAtPageEdgeIsDropped)
{
const PrivateChatPage page = assemblePrivateChatPage(newestFirst({400, 400, 300, 300}), 3);
EXPECT_EQ(timesOf(page.messages), QList<quint64>({400, 400}));
EXPECT_TRUE(page.hasMore);
EXPECT_FALSE(page.needsWholeSecond);
}
TEST(PrivateChatPaging, ReturnedMessagesKeepSenderAndText)
{
const PrivateChatPage page = assemblePrivateChatPage({messageAt(200, "hello"), messageAt(100, "world")}, 50);
ASSERT_EQ(page.messages.size(), 2);
EXPECT_EQ(page.messages.at(0).message(), "world");
EXPECT_EQ(page.messages.at(1).message(), "hello");
EXPECT_EQ(page.messages.at(0).sender_name(), "sender");
}