From 7709184ecc40f01da88b4ddb4f96a562ba97b6ce Mon Sep 17 00:00:00 2001 From: SwitchOCE <116769543+SwitchOCE@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:21:43 +1100 Subject: [PATCH] [Server] Let users fetch their own private chat history (#7506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 * [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 Co-authored-by: Lukas BrĂ¼bach <44814898+BruebachL@users.noreply.github.com> --- .../server/remote/server_protocolhandler.cpp | 11 +- .../libcockatrice/protocol/pb/CMakeLists.txt | 3 + .../pb/command_get_private_chat_history.proto | 18 +++ .../libcockatrice/protocol/pb/response.proto | 1 + .../pb/response_private_chat_history.proto | 16 +++ .../pb/serverinfo_private_chat_message.proto | 10 ++ .../protocol/pb/session_commands.proto | 1 + servatrice/CMakeLists.txt | 1 + servatrice/servatrice.ini.example | 4 + servatrice/src/private_chat_paging.cpp | 31 +++++ servatrice/src/private_chat_paging.h | 30 +++++ .../src/servatrice_database_interface.cpp | 87 +++++++++++++ .../src/servatrice_database_interface.h | 9 ++ servatrice/src/serversocketinterface.cpp | 47 +++++++ servatrice/src/serversocketinterface.h | 2 + tests/CMakeLists.txt | 13 ++ tests/private_chat_paging_test.cpp | 115 ++++++++++++++++++ 17 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_get_private_chat_history.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_private_chat_history.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_private_chat_message.proto create mode 100644 servatrice/src/private_chat_paging.cpp create mode 100644 servatrice/src/private_chat_paging.h create mode 100644 tests/private_chat_paging_test.cpp diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 78b9d955f..fccec85ef 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -804,10 +804,13 @@ Response::ResponseCode Server_ProtocolHandler::cmdMessage(const Command_Message userInterface->sendProtocolItem(*se); rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, se); - databaseInterface->logMessage(userInfo->id(), QString::fromStdString(userInfo->name()), - QString::fromStdString(userInfo->address()), QString::fromStdString(cmd.message()), - Server_DatabaseInterface::MessageTargetChat, userInterface->getUserInfo()->id(), - receiver); + databaseInterface->logMessage( + userInfo->id(), QString::fromStdString(userInfo->name()), QString::fromStdString(userInfo->address()), + QString::fromStdString(cmd.message()), Server_DatabaseInterface::MessageTargetChat, + // 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(); return Response::RespOk; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 5fba2631d..9c1f8ba28 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -33,6 +33,7 @@ set(PROTO_FILES command_dump_zone.proto command_flip_card.proto command_game_say.proto + command_get_private_chat_history.proto command_get_server_stats.proto command_inc_card_counter.proto command_inc_counter.proto @@ -164,6 +165,7 @@ set(PROTO_FILES response_list_users.proto response_login.proto response_password_salt.proto + response_private_chat_history.proto response_register.proto response_replay_download.proto response_replay_download_by_game_id.proto @@ -202,6 +204,7 @@ set(PROTO_FILES serverinfo_player.proto serverinfo_playerping.proto serverinfo_playerproperties.proto + serverinfo_private_chat_message.proto serverinfo_replay.proto serverinfo_replay_match.proto serverinfo_report.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_private_chat_history.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_private_chat_history.proto new file mode 100644 index 000000000..ae9508d18 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_private_chat_history.proto @@ -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]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index caf9febde..59f96e98c 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -78,6 +78,7 @@ message Response { PASSWORD_SALT = 1017; // Response containing password salt GET_ADMIN_NOTES = 1018; // Response with admin notes 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_DOWNLOAD = 1101; // Response for replay download REPLAY_GET_CODE = 1102; // Response containing replay code diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_private_chat_history.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_private_chat_history.proto new file mode 100644 index 000000000..829df695c --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_private_chat_history.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_private_chat_message.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_private_chat_message.proto new file mode 100644 index 000000000..1827f54cc --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_private_chat_message.proto @@ -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; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index 10aefe7b5..24e147951 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -37,6 +37,7 @@ message SessionCommand { DECK_SHARE_LIST_MINE = 1032; DECK_SHARE_REMOVE = 1033; SUBMIT_PASSWORD_VERIFIER = 1034; + GET_PRIVATE_CHAT_HISTORY = 1035; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 9d8dd46d2..0204b15a6 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -10,6 +10,7 @@ set(servatrice_SOURCES src/forwarded_address.cpp src/main.cpp src/metrics_registry.cpp + src/private_chat_paging.cpp src/ratelimiter.cpp src/servatrice.cpp src/servatrice_connection_pool.cpp diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index ac291fd7f..47dc1407f 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -534,6 +534,10 @@ log_user_msg_chat=false ; Log user messages coming from other servers in the network 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] ; Servatrice can record certain actions being performed in the database for server operators to better understand diff --git a/servatrice/src/private_chat_paging.cpp b/servatrice/src/private_chat_paging.cpp new file mode 100644 index 000000000..872a1e733 --- /dev/null +++ b/servatrice/src/private_chat_paging.cpp @@ -0,0 +1,31 @@ +#include "private_chat_paging.h" + +#include + +PrivateChatPage assemblePrivateChatPage(const QList &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; +} diff --git a/servatrice/src/private_chat_paging.h b/servatrice/src/private_chat_paging.h new file mode 100644 index 000000000..ee503f2d9 --- /dev/null +++ b/servatrice/src/private_chat_paging.h @@ -0,0 +1,30 @@ +#ifndef PRIVATE_CHAT_PAGING_H +#define PRIVATE_CHAT_PAGING_H + +#include +#include + +/** One page of private chat history, ready for the response. */ +struct PrivateChatPage +{ + /** Messages for the response, oldest first. */ + QList 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 &newestFirst, int limit); + +#endif // PRIVATE_CHAT_PAGING_H diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 1327c85d5..c11f63363 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -8,6 +8,7 @@ #include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h" #include "libcockatrice/protocol/pb/serverinfo_warning.pb.h" +#include "private_chat_paging.h" #include "servatrice.h" #include "server.h" #include "server_protocolhandler.h" @@ -2638,6 +2639,92 @@ int Servatrice_DatabaseInterface::clearBlacklistedBanners() return cleared; } +bool Servatrice_DatabaseInterface::getPrivateChatHistory(int callerId, + const QString &callerName, + int peerId, + const QString &peerName, + quint64 before, + int limit, + QList &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 &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 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 Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user, const QString &ipaddress, const QString &gamename, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index c3cc57ab2..13a581673 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -224,6 +225,14 @@ public: const QString &beforeTime = QString(), bool *hasMore = nullptr); QList getUserSessions(const QString &userName, int limit); + bool getPrivateChatHistory(int callerId, + const QString &callerName, + int peerId, + const QString &peerName, + quint64 before, + int limit, + QList &messages, + bool &hasMore); QList getUserAlts(const QString &userName, int depth = 1, bool useEmail = true, diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index c32ccad49..49cc053b3 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -98,6 +98,7 @@ #include #include #include +#include #include #include #include @@ -139,6 +140,7 @@ #include #include #include +#include #include #include #include @@ -414,6 +416,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdDeckShareRemove(cmd.GetExtension(Command_DeckShareRemove::ext), rc); case SessionCommand::DECK_SHARE_DOWNLOAD: 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: return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc); case SessionCommand::REQUEST_PASSWORD_SALT: @@ -1702,6 +1706,49 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReplaySubmitCode(const 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(1, cmd.limit(), 200); + QList 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. // May be called by admins and moderators. Permission is checked by the calling function. Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Command_ViewLogHistory &cmd, diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index cd37286c6..0e77d7458 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -81,6 +81,7 @@ class Command_ForgotPasswordReset; class Command_GetAdminNotes; class Command_GetBanHistory; class Command_GetModeratorLastLogins; +class Command_GetPrivateChatHistory; class Command_GetServerStats; class Command_GetUserAlts; class Command_GetUserSessions; @@ -142,6 +143,7 @@ private: Response::ResponseCode cmdAddToList(const Command_AddToList &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(const QString &path); bool deckListHelper(int folderId, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ae90ee896..6eb59fc72 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -426,6 +426,19 @@ cockatrice_add_test( LABELS 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( NAME latency_tracker_test diff --git a/tests/private_chat_paging_test.cpp b/tests/private_chat_paging_test.cpp new file mode 100644 index 000000000..4e5919517 --- /dev/null +++ b/tests/private_chat_paging_test.cpp @@ -0,0 +1,115 @@ +#include "private_chat_paging.h" + +#include + +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 newestFirst(std::initializer_list times) +{ + QList messages; + for (const quint64 time : times) { + messages.append(messageAt(time, QString::number(time))); + } + return messages; +} + +QList timesOf(const QList &messages) +{ + QList 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({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({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({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({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({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"); +}