Patrick Schurig cd91baf98f optimiseur : le moteur n'émet jamais de ligne de plus de 1 Mio (Q7, protocole 1.2)
Ferme l'écart Q7 trouvé au lot protocole 1.2. Une requête GetPlan dont la ligne
dépasserait maxLineBytes n'est plus écrite sur le socket : OptimizerClient::requestPlan()
sérialise d'abord, rend l'identifiant, puis émet planFailed(id, "request-too-long") au
tour de boucle suivant. L'échec compte (règle des échecs consécutifs, §7), la connexion
est conservée, et le journal le dit une fois par connexion, avec la taille, jamais le
contenu. Avant : la ligne partait, l'optimiseur la jetait, et la requête finissait en
délai dépassé sans que la cause se voie.

Le handshake n'est pas contrôlé : sa ligne est bornée par construction (token de 4 096
octets au plus, 24 Kio une fois échappé).

- knownErrorCodes() : + request-too-long ; LastErrorCode du contrat régénéré
  (--enums-from), blocs générés de la prose régénérés.
- OPTIMIZER_PROTOCOL.md §0/§2 et x-etm-transport : l'écart connu devient la règle tenue.
  Pas de changement de version.
- Tests : testARequestOverTheLineLimitIsNeverSent remplace le XFAIL (aucun octet émis,
  limite exacte éprouvée des deux côtés), testARequestTooLongIsLoggedOncePerConnection,
  testARequestTooLongCountsAsAFailure (socketscheduler).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:07:11 +02:00

1113 lines
50 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
//
// Transport client vers l'optimiseur (brief 4 T2). Faux optimiseurs dans le processus
// (QLocalServer / QTcpServer), temps SIMULÉ : la boucle d'événements ne sert qu'aux E/S réelles
// des sockets, jamais à laisser s'écouler un délai du client.
#include "etm/scheduler/optimizerclient.h"
#include "etm/scheduler/optimizerprotocol.h"
#include "../common/etm-powersync-fakeoptimizer.h"
#include "../common/etm-powersync-contrat.h"
#include <QCoreApplication>
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLocalServer>
#include <QLocalSocket>
#include <QLoggingCategory>
#include <QPointer>
#include <QRegularExpression>
#include <QSignalSpy>
#include <QTcpServer>
#include <QTcpSocket>
#include <QTemporaryDir>
#include <QTest>
#include <sys/stat.h>
// Le moteur la définit dans smartchargingmanager.cpp ; cette cible légère ne le lie pas.
Q_LOGGING_CATEGORY(dcNymeaEnergy, "NymeaEnergy")
namespace {
// Journal capturé : TOUS les niveaux, debug compris (règle posée en initTestCase).
QStringList s_log;
QtMessageHandler s_previousHandler = nullptr;
void captureMessage(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
s_log.append(message);
if (s_previousHandler)
s_previousHandler(type, context, message);
}
int countLog(const QString &needle)
{
int n = 0;
for (const QString &line : std::as_const(s_log))
n += line.contains(needle) ? 1 : 0;
return n;
}
/*! Optimiseur BRUT : ce que le client écrit, octet par octet, sans le découpage du faux
* optimiseur (qui ne rend que des objets, et ne verrait donc pas un lot). */
struct RawOptimizer
{
QLocalServer server;
QLocalSocket *socket = nullptr;
QByteArray received;
QString listen(const QString &path)
{
QLocalServer::removeServer(path);
return server.listen(path) ? QStringLiteral("unix://") + path : QString();
}
//! Lignes complètes reçues jusqu'ici, dans l'ordre (sans leur '\n').
QList<QByteArray> lines()
{
if (!socket && server.hasPendingConnections())
socket = server.nextPendingConnection();
if (socket)
received += socket->readAll();
QList<QByteArray> l = received.split('\n');
l.removeLast(); // ce qui suit le dernier '\n' : vide, ou une ligne pas encore finie
return l;
}
void send(const QByteArray &line)
{
socket->write(line + '\n');
socket->flush();
}
};
} // namespace
/*! Horloge simulée : le temps n'avance que par advance(), et les réveils échus sonnent dans
* l'ordre, chacun à son instant exact. */
class SimClock : public OptimizerClientClock
{
Q_OBJECT
public:
qint64 nowMs() const override { return m_now; }
void armWakeUp(qint64 delayMs) override { m_deadline = m_now + delayMs; }
void disarmWakeUp() override { m_deadline = -1; }
bool armed() const { return m_deadline >= 0; }
qint64 remaining() const { return m_deadline - m_now; }
void advance(qint64 ms)
{
const qint64 target = m_now + ms;
while (m_deadline >= 0 && m_deadline <= target) {
m_now = m_deadline;
m_deadline = -1;
emit wakeUp();
}
m_now = target;
}
private:
qint64 m_now = 1000000; // origine arbitraire non nulle
qint64 m_deadline = -1;
};
class TestOptimizerClient : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void cleanupTestCase();
void init();
void testEndpointParsing_data();
void testEndpointParsing();
void testNotStartedOpensNothing();
void testInvalidEndpointIsNotStarted();
void testHandshakeAndPlan_data();
void testHandshakeAndPlan();
void testWrongMajorIsRefused();
void testInvalidHandshakeIsRefused_data();
void testInvalidHandshakeIsRefused();
void testWrongTokenThenFixedToken();
void testHandshakeTimeout();
void testPlanTimeoutKeepsConnection();
void testAbruptCloseThenReconnect();
void testBackoffDoublesToSixtySeconds();
void testStaleResponseIsIgnored();
void testLineLengthLimit();
void testBadLinesKeepConnection();
void testWriteToken();
void testTokenNeverInAnyLog();
void testAShortTokenFileOpensNoConnection();
void testARefusedHandshakeIsClosedByTheEngine_data();
void testARefusedHandshakeIsClosedByTheEngine();
void testAReceivedBatchIsNeverUnpacked();
void testEachLineSentIsOneObject();
void testARequestOverTheLineLimitIsNeverSent();
void testARequestTooLongIsLoggedOncePerConnection();
private:
// Démarre le client et mène le handshake jusqu'à Ready.
void bringUp(OptimizerClient &client, FakeOptimizer &server, const QJsonObject &result = handshakeResult());
QTemporaryDir m_dir;
};
void TestOptimizerClient::cleanupTestCase()
{
// Lot contrat OpenRPC : chaque Handshake que le client a ENVOYÉ dans ce processus est jugé contre
// le contrat (docs/ — cette suite n'embarque pas le sérialiseur). Les GetPlan de cette suite ne
// le sont pas : leurs params sont FABRIQUÉS par le test, le client n'en connaît pas le contenu ;
// ceux du sérialiseur réel sont jugés par `socketscheduler`, `refoptimizer` et `protocolschema`.
const QList<QJsonObject> handshakes = Contrat::deMethode(FakeOptimizer::capture(), QStringLiteral("Handshake"));
FakeOptimizer::capture().clear();
if (handshakes.isEmpty())
return;
const Contrat::Verdict v = Contrat::juger(handshakes, Contrat::schemaDuDepot(), {QStringLiteral("--requests-only")});
QVERIFY2(v.code == 0 && v.compte(QStringLiteral("Handshake.params")) == handshakes.size(),
qUtf8Printable(QStringLiteral("Handshake du client NON CONFORME au contrat :\n") + v.sortie.right(3000)));
}
void TestOptimizerClient::initTestCase()
{
QLoggingCategory::setFilterRules(QStringLiteral("NymeaEnergy.debug=true"));
s_previousHandler = qInstallMessageHandler(captureMessage);
}
void TestOptimizerClient::init()
{
s_log.clear();
QVERIFY(m_dir.isValid());
}
void TestOptimizerClient::bringUp(OptimizerClient &client, FakeOptimizer &server, const QJsonObject &result)
{
client.start();
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake"));
server.replyResult(hs.value("id").toInteger(), result);
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
}
void TestOptimizerClient::testEndpointParsing_data()
{
QTest::addColumn<QString>("text");
QTest::addColumn<int>("kind");
QTest::addColumn<QString>("canonical");
QTest::newRow("unix") << "unix:///run/powersync/optimizer.sock" << int(OptimizerEndpoint::Unix)
<< "unix:///run/powersync/optimizer.sock";
QTest::newRow("tcp ipv4") << "tcp://192.168.1.10:7400" << int(OptimizerEndpoint::Tcp) << "tcp://192.168.1.10:7400";
QTest::newRow("tcp nom") << "tcp://nas.local:7400" << int(OptimizerEndpoint::Tcp) << "tcp://nas.local:7400";
QTest::newRow("tcp ipv6") << "tcp://[fd00::1]:7400" << int(OptimizerEndpoint::Tcp) << "tcp://[fd00::1]:7400";
QTest::newRow("unix relatif") << "unix://run/x.sock" << int(OptimizerEndpoint::Invalid) << "";
QTest::newRow("unix vide") << "unix://" << int(OptimizerEndpoint::Invalid) << "";
QTest::newRow("tcp sans port") << "tcp://192.168.1.10" << int(OptimizerEndpoint::Invalid) << "";
QTest::newRow("tcp port 0") << "tcp://h:0" << int(OptimizerEndpoint::Invalid) << "";
QTest::newRow("tcp port trop grand") << "tcp://h:65536" << int(OptimizerEndpoint::Invalid) << "";
QTest::newRow("tcp port signé") << "tcp://h:+80" << int(OptimizerEndpoint::Invalid) << "";
QTest::newRow("tcp reste") << "tcp://h:80/x" << int(OptimizerEndpoint::Invalid) << "";
QTest::newRow("tcp ipv6 nu") << "tcp://fd00::1:7400" << int(OptimizerEndpoint::Invalid) << "";
QTest::newRow("tcp hôte vide") << "tcp://:7400" << int(OptimizerEndpoint::Invalid) << "";
QTest::newRow("http") << "http://h:80" << int(OptimizerEndpoint::Invalid) << "";
QTest::newRow("vide") << "" << int(OptimizerEndpoint::Invalid) << "";
}
void TestOptimizerClient::testEndpointParsing()
{
QFETCH(QString, text);
QFETCH(int, kind);
QFETCH(QString, canonical);
const OptimizerEndpoint e = OptimizerEndpoint::parse(text);
QCOMPARE(int(e.kind), kind);
if (e.isValid())
QCOMPARE(e.toString(), canonical);
}
void TestOptimizerClient::testNotStartedOpensNothing()
{
// Le moteur n'instancie pas encore le client ; le jour où il le fera, la construction seule
// ne doit rien ouvrir, rien armer, rien écrire au journal.
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
{
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
settle();
clock.advance(120000);
settle();
QCOMPARE(client.state(), OptimizerClient::Stopped);
QCOMPARE(client.requestPlan({}), quint64(0));
}
QCOMPARE(server.connections, 0);
QVERIFY(!clock.armed());
QCOMPARE(s_log, QStringList());
}
void TestOptimizerClient::testInvalidEndpointIsNotStarted()
{
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse("tcp://nulle-part"), QString(), "9.9", &clock);
client.start();
QCOMPARE(client.state(), OptimizerClient::Stopped);
QCOMPARE(client.lastErrorCode(), QStringLiteral("invalid-endpoint"));
QVERIFY(!clock.armed());
QCOMPARE(countLog("[Optimiseur] adresse invalide"), 1);
}
void TestOptimizerClient::testHandshakeAndPlan_data()
{
QTest::addColumn<int>("transport");
QTest::newRow("unix") << int(FakeOptimizer::Unix);
QTest::newRow("tcp") << int(FakeOptimizer::Tcp);
}
void TestOptimizerClient::testHandshakeAndPlan()
{
QFETCH(int, transport);
FakeOptimizer server(FakeOptimizer::Transport(transport), m_dir.path());
QVERIFY(server.listen());
SimClock clock;
// Fichier de token ABSENT : pas de clé token du tout (ni vide, ni nulle).
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), m_dir.filePath("absent.token"), "9.9", &clock);
QSignalSpy readySpy(&client, &OptimizerClient::ready);
QSignalSpy planSpy(&client, &OptimizerClient::planReceived);
client.start();
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
QCOMPARE(client.state(), OptimizerClient::Handshaking);
QCOMPARE(hs.value("jsonrpc").toString(), QStringLiteral("2.0"));
QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake"));
QCOMPARE(hs.value("id").toInteger(), 1);
const QJsonObject params = hs.value("params").toObject();
// Protocole 1.2 : la version ANNONCÉE au handshake est celle que le moteur sert
// (GetOptimizerProtocol) et celle du contrat — deux constantes, une seule valeur.
QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.2"));
QCOMPARE(QString::fromLatin1(OptimizerClient::kProtocolVersion), QString::fromLatin1(OptimizerProtocol::ProtocolVersion));
QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9"));
QVERIFY(!params.contains("token"));
// Seule l'échéance du handshake est armée, à 5 s depuis le début de la tentative.
QVERIFY(clock.armed());
QCOMPARE(clock.remaining(), qint64(5000));
server.replyResult(1, handshakeResult());
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
QCOMPARE(readySpy.count(), 1);
QVERIFY(client.hasCapabilities());
QCOMPARE(client.capabilities().value("maxHorizonH").toInt(), 48);
QCOMPARE(client.capabilities().value("kinds").toStringList(), (QStringList{"setpoint", "state"}));
QCOMPARE(client.serverName(), QStringLiteral("faux-optimiseur"));
QCOMPARE(client.serverProtocolVersion(), QStringLiteral("1.1"));
QVERIFY(client.serverPlanning());
QVERIFY(!clock.armed()); // prêt et rien en vol : aucun réveil
QCOMPARE(countLog("[Optimiseur] connecté à " + server.endpoint()), 1);
// GetPlan : params transmis tels quels, identifiants croissants.
const QVariantMap context{{"timestamp", "2026-10-16T10:00:00Z"}, {"loads", QVariantList{}}};
const quint64 id = client.requestPlan(context);
QCOMPARE(id, quint64(2));
QCOMPARE(clock.remaining(), qint64(10000));
QJsonObject req;
QVERIFY(server.waitMessage(&req));
QCOMPARE(req.value("method").toString(), QStringLiteral("GetPlan"));
QCOMPARE(req.value("id").toInteger(), 2);
QCOMPARE(req.value("params").toObject().toVariantMap(), context);
server.replyResult(2, QJsonObject{{"planId", "p1"}, {"slots", QJsonArray{}}});
QVERIFY(waitFor([&] { return planSpy.count() == 1; }));
QCOMPARE(planSpy.at(0).at(0).value<quint64>(), quint64(2));
QCOMPARE(planSpy.at(0).at(1).toMap().value("planId").toString(), QStringLiteral("p1"));
QCOMPARE(client.pendingRequestId(), quint64(0));
QVERIFY(!clock.armed());
QCOMPARE(client.requestPlan(context), quint64(3));
// Sans capabilities : mémorisé comme ABSENT, pas comme vide (§3, seul le présent restreint).
client.stop();
QCOMPARE(client.state(), OptimizerClient::Stopped);
bringUp(client, server, handshakeResult("1.4", false));
QVERIFY(!client.hasCapabilities());
QCOMPARE(client.serverProtocolVersion(), QStringLiteral("1.4")); // même majeure : accepté
}
void TestOptimizerClient::testWrongMajorIsRefused()
{
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
QSignalSpy readySpy(&client, &OptimizerClient::ready);
client.start();
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
server.replyResult(1, handshakeResult("2.0"));
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
QCOMPARE(client.lastErrorCode(), QStringLiteral("major-mismatch"));
QCOMPARE(readySpy.count(), 0);
QCOMPARE(client.requestPlan({}), quint64(0));
QVERIFY(waitFor([&] { return server.disconnections == 1; }));
QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.2 — majeures différentes, refusé"), 1);
// Nouvelle tentative au backoff (§3), pas avant.
clock.advance(999);
settle();
QCOMPARE(server.connections, 1);
clock.advance(1);
QVERIFY(server.waitMessage(&hs));
QCOMPARE(server.connections, 2);
QCOMPARE(hs.value("id").toInteger(), 2); // les identifiants traversent les reconnexions
server.replyResult(2, handshakeResult("1.0"));
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
}
void TestOptimizerClient::testInvalidHandshakeIsRefused_data()
{
QTest::addColumn<QByteArray>("reply");
QTest::addColumn<QString>("code");
QTest::newRow("protocolVersion absent")
<< QByteArray(R"({"jsonrpc":"2.0","id":1,"result":{"name":"x"}})") << "invalid-handshake";
QTest::newRow("protocolVersion non chaîne")
<< QByteArray(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1.1}})") << "invalid-handshake";
QTest::newRow("result non objet")
<< QByteArray(R"({"jsonrpc":"2.0","id":1,"result":"1.1"})") << "invalid-handshake";
QTest::newRow("capabilities non objet")
<< QByteArray(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.1","capabilities":["setpoint"]}})")
<< "invalid-handshake";
QTest::newRow("erreur JSON-RPC")
<< QByteArray(R"({"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"inconnu"}})") << "handshake-refused";
}
void TestOptimizerClient::testInvalidHandshakeIsRefused()
{
QFETCH(QByteArray, reply);
QFETCH(QString, code);
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
QSignalSpy readySpy(&client, &OptimizerClient::ready);
client.start();
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
server.sendRaw(reply + '\n');
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
QCOMPARE(client.lastErrorCode(), code);
QCOMPARE(readySpy.count(), 0);
QCOMPARE(clock.remaining(), qint64(1000));
QCOMPARE(countLog("(" + code + ") :"), 1);
}
void TestOptimizerClient::testWrongTokenThenFixedToken()
{
const QString tokenPath = m_dir.filePath("energy-optimizer.token");
QVERIFY(OptimizerClient::writeToken(tokenPath, "jeton-faux-7f3a9"));
FakeOptimizer server(FakeOptimizer::Tcp, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), tokenPath, "9.9", &clock);
// 1. Mauvais token : le serveur ferme la connexion (§3).
client.start();
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
QCOMPARE(hs.value("params").toObject().value("token").toString(), QStringLiteral("jeton-faux-7f3a9"));
server.abortClient();
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
QCOMPARE(client.lastErrorCode(), QStringLiteral("closed-during-handshake"));
QCOMPARE(countLog("un token a été envoyé"), 1);
// 2. Le serveur refuse par une erreur JSON-RPC : même issue, autre code.
clock.advance(1000);
QVERIFY(server.waitMessage(&hs));
server.sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", hs.value("id")},
{"error", QJsonObject{{"code", -32001}, {"message", "token refusé"}}}});
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
QCOMPARE(client.lastErrorCode(), QStringLiteral("handshake-refused"));
// 3. Token corrigé sur disque, sans toucher au client : relu à la connexion suivante.
QVERIFY(OptimizerClient::writeToken(tokenPath, "jeton-juste-91c2"));
clock.advance(1999);
settle();
QCOMPARE(server.connections, 2);
clock.advance(1);
QVERIFY(server.waitMessage(&hs));
QCOMPARE(hs.value("params").toObject().value("token").toString(), QStringLiteral("jeton-juste-91c2"));
server.replyResult(hs.value("id").toInteger(), handshakeResult());
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
QCOMPARE(countLog("(après 2 échec(s))"), 1);
// 4. Token retiré : la connexion suivante part SANS clé token.
QVERIFY(OptimizerClient::writeToken(tokenPath, QByteArray()));
QVERIFY(!QFile::exists(tokenPath));
server.abortClient();
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
clock.advance(1000);
QVERIFY(server.waitMessage(&hs));
QVERIFY(!hs.value("params").toObject().contains("token"));
}
void TestOptimizerClient::testHandshakeTimeout()
{
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
client.start();
QJsonObject hs;
QVERIFY(server.waitMessage(&hs)); // reçu, jamais répondu
clock.advance(4999);
settle();
QCOMPARE(client.state(), OptimizerClient::Handshaking);
clock.advance(1);
QCOMPARE(client.state(), OptimizerClient::Waiting);
QCOMPARE(client.lastErrorCode(), QStringLiteral("handshake-timeout"));
QVERIFY(waitFor([&] { return server.disconnections == 1; }));
QCOMPARE(clock.remaining(), qint64(1000)); // prochaine tentative au backoff
// Une réponse tardive au handshake abandonné ne ressuscite rien.
clock.advance(1000);
QVERIFY(server.waitMessage(&hs));
QCOMPARE(hs.value("id").toInteger(), 2);
server.replyResult(1, handshakeResult());
settle();
QCOMPARE(client.state(), OptimizerClient::Handshaking);
QCOMPARE(client.staleResponses(), 1);
server.replyResult(2, handshakeResult());
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
}
void TestOptimizerClient::testPlanTimeoutKeepsConnection()
{
FakeOptimizer server(FakeOptimizer::Tcp, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
bringUp(client, server);
QSignalSpy failSpy(&client, &OptimizerClient::planFailed);
QSignalSpy planSpy(&client, &OptimizerClient::planReceived);
const quint64 id = client.requestPlan({{"n", 1}});
QJsonObject req;
QVERIFY(server.waitMessage(&req));
clock.advance(9999);
settle();
QCOMPARE(failSpy.count(), 0);
clock.advance(1);
QCOMPARE(failSpy.count(), 1);
QCOMPARE(failSpy.at(0).at(0).value<quint64>(), id);
QCOMPARE(failSpy.at(0).at(1).toString(), QStringLiteral("timeout"));
QCOMPARE(client.state(), OptimizerClient::Ready); // connexion conservée
QCOMPARE(server.disconnections, 0);
QVERIFY(!clock.armed());
// La réponse tardive est reconnue par son id et ne commande rien.
server.replyResult(qint64(id), QJsonObject{{"planId", "tardif"}});
QVERIFY(waitFor([&] { return client.staleResponses() == 1; }));
QCOMPARE(planSpy.count(), 0);
const quint64 next = client.requestPlan({{"n", 2}});
QVERIFY(server.waitMessage(&req));
server.replyResult(qint64(next), QJsonObject{{"planId", "frais"}});
QVERIFY(waitFor([&] { return planSpy.count() == 1; }));
QCOMPARE(planSpy.at(0).at(1).toMap().value("planId").toString(), QStringLiteral("frais"));
}
void TestOptimizerClient::testAbruptCloseThenReconnect()
{
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
bringUp(client, server);
QSignalSpy failSpy(&client, &OptimizerClient::planFailed);
const quint64 id = client.requestPlan({});
QJsonObject req;
QVERIFY(server.waitMessage(&req));
server.abortClient();
QVERIFY(waitFor([&] { return failSpy.count() == 1; }));
QCOMPARE(failSpy.at(0).at(0).value<quint64>(), id);
QCOMPARE(failSpy.at(0).at(1).toString(), QStringLiteral("disconnected"));
QCOMPARE(client.state(), OptimizerClient::Waiting);
QCOMPARE(client.lastErrorCode(), QStringLiteral("connection-lost"));
QCOMPARE(client.requestPlan({}), quint64(0));
clock.advance(999);
settle();
QCOMPARE(server.connections, 1);
clock.advance(1);
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
server.replyResult(hs.value("id").toInteger(), handshakeResult());
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
QCOMPARE(client.nextBackoffMs(), qint64(1000)); // remis à 1 s après succès
}
void TestOptimizerClient::testBackoffDoublesToSixtySeconds()
{
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path()); // n'écoute pas encore
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
int attempts = 0;
connect(&client, &OptimizerClient::stateChanged, this, [&](OptimizerClient::State s) {
if (s == OptimizerClient::Connecting)
++attempts;
});
client.start();
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
QCOMPARE(attempts, 1);
QCOMPARE(client.lastErrorCode(), QStringLiteral("connect-failed"));
const QList<qint64> delays{1000, 2000, 4000, 8000, 16000, 32000, 60000, 60000};
for (const qint64 d : delays) {
const int before = attempts;
QCOMPARE(clock.remaining(), d);
clock.advance(d - 1);
settle(10);
QCOMPARE(attempts, before);
clock.advance(1);
QVERIFY(waitFor([&] { return attempts == before + 1 && client.state() == OptimizerClient::Waiting; }));
}
// Une série d'échecs identiques : annoncée UNE fois, puis comptée.
QCOMPARE(countLog("(connect-failed) :"), 1);
QVERIFY(server.listen());
clock.advance(60000);
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
server.replyResult(hs.value("id").toInteger(), handshakeResult());
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
QCOMPARE(countLog("(après 9 échec(s))"), 1);
QCOMPARE(client.nextBackoffMs(), qint64(1000));
}
void TestOptimizerClient::testStaleResponseIsIgnored()
{
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
bringUp(client, server);
QSignalSpy planSpy(&client, &OptimizerClient::planReceived);
QSignalSpy failSpy(&client, &OptimizerClient::planFailed);
const quint64 first = client.requestPlan({{"trigger", "cycle"}});
clock.advance(3000);
const quint64 second = client.requestPlan({{"trigger", "load-config"}});
QCOMPARE(second, first + 1);
QCOMPARE(client.pendingRequestId(), second);
QCOMPARE(clock.remaining(), qint64(10000)); // l'attente repart pour la seconde
QJsonObject a, b;
QVERIFY(server.waitMessage(&a));
QVERIFY(server.waitMessage(&b));
server.replyResult(qint64(first), QJsonObject{{"planId", "ancien"}});
QVERIFY(waitFor([&] { return client.staleResponses() == 1; }));
QCOMPARE(planSpy.count(), 0);
QCOMPARE(client.pendingRequestId(), second);
server.replyResult(qint64(second), QJsonObject{{"planId", "récent"}});
QVERIFY(waitFor([&] { return planSpy.count() == 1; }));
QCOMPARE(planSpy.at(0).at(0).value<quint64>(), second);
QCOMPARE(planSpy.at(0).at(1).toMap().value("planId").toString(), QStringLiteral("récent"));
QCOMPARE(failSpy.count(), 0); // le remplacement est silencieux
QCOMPARE(client.protocolErrors(), 0);
}
void TestOptimizerClient::testLineLengthLimit()
{
FakeOptimizer server(FakeOptimizer::Tcp, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
bringUp(client, server);
QSignalSpy planSpy(&client, &OptimizerClient::planReceived);
QSignalSpy errorSpy(&client, &OptimizerClient::protocolError);
QJsonObject req;
// Exactement kMaxLineBytes : acceptée (des blancs complètent un JSON valide).
const quint64 first = client.requestPlan({});
QVERIFY(server.waitMessage(&req));
QByteArray atLimit = QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", qint64(first)},
{"result", QJsonObject{{"planId", "limite"}}}})
.toJson(QJsonDocument::Compact);
atLimit.append(QByteArray(OptimizerClient::kMaxLineBytes - atLimit.size(), ' '));
QCOMPARE(atLimit.size(), OptimizerClient::kMaxLineBytes);
server.sendRaw(atLimit + '\n');
QVERIFY(waitFor([&] { return planSpy.count() == 1; }, 10000));
QCOMPARE(client.protocolErrors(), 0);
// Un octet de plus : écartée, comptée, connexion conservée, requête toujours attendue.
const quint64 second = client.requestPlan({});
QVERIFY(server.waitMessage(&req));
QByteArray over = QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", qint64(second)},
{"result", QJsonObject{{"planId", "trop-long"}}}})
.toJson(QJsonDocument::Compact);
over.append(QByteArray(OptimizerClient::kMaxLineBytes + 1 - over.size(), ' '));
server.sendRaw(over + '\n');
QVERIFY(waitFor([&] { return errorSpy.count() == 1; }, 10000));
QCOMPARE(errorSpy.at(0).at(0).toString(), QStringLiteral("line-too-long"));
settle();
QCOMPARE(planSpy.count(), 1);
QCOMPARE(client.state(), OptimizerClient::Ready);
QCOMPARE(client.pendingRequestId(), second);
// 3 Mio sans aucune fin de ligne : écartés AVANT la fin (la mémoire reste bornée), une seule
// erreur ; tout jusqu'au '\n' est jeté, et la ligne d'APRÈS est lue normalement.
server.sendRaw(QByteArray(3 * OptimizerClient::kMaxLineBytes, 'x'));
QVERIFY(waitFor([&] { return errorSpy.count() == 2; }, 10000));
server.sendRaw("yyy\n"); // fin de la MÊME ligne trop longue : jetée avec elle
server.replyResult(qint64(second), QJsonObject{{"planId", "après"}});
QVERIFY(waitFor([&] { return planSpy.count() == 2; }, 10000));
QCOMPARE(planSpy.at(1).at(1).toMap().value("planId").toString(), QStringLiteral("après"));
QCOMPARE(client.protocolErrors(), 2);
QCOMPARE(server.disconnections, 0);
}
void TestOptimizerClient::testBadLinesKeepConnection()
{
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
bringUp(client, server);
QSignalSpy planSpy(&client, &OptimizerClient::planReceived);
QSignalSpy errorSpy(&client, &OptimizerClient::protocolError);
QSignalSpy notifSpy(&client, &OptimizerClient::notificationReceived);
const quint64 id = client.requestPlan({});
QJsonObject req;
QVERIFY(server.waitMessage(&req));
server.sendRaw("ceci n'est pas du JSON\n");
server.sendRaw("[1,2,3]\n");
server.sendRaw("{\"jsonrpc\":\"2.0\",\"result\":{}}\n");
server.sendRaw("{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32700}}\n");
server.sendRaw("{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{}}\n");
server.sendRaw("{\"jsonrpc\":\"2.0\",\"id\":\"abc\",\"result\":{}}\n");
server.sendRaw("{\"jsonrpc\":\"2.0\",\"method\":\"UpdateLearned\",\"params\":{\"confidence\":0.4}}\n");
server.sendRaw("\n");
QVERIFY(waitFor([&] { return errorSpy.count() == 6 && notifSpy.count() == 1; }));
QStringList codes;
for (const QList<QVariant> &e : std::as_const(errorSpy))
codes << e.at(0).toString();
QCOMPARE(codes, (QStringList{"invalid-json", "invalid-json", "missing-id", "missing-id", "unknown-id", "unknown-id"}));
QCOMPARE(notifSpy.at(0).at(0).toString(), QStringLiteral("UpdateLearned"));
QCOMPARE(client.state(), OptimizerClient::Ready);
QCOMPARE(client.pendingRequestId(), id);
// Une ligne de journal par NATURE d'erreur et par connexion, pas une par ligne.
QCOMPARE(countLog("ligne écartée (invalid-json) :"), 1);
QCOMPARE(countLog("ligne écartée (missing-id) :"), 1);
QCOMPARE(countLog("ligne écartée (unknown-id) :"), 1);
server.replyResult(qint64(id), QJsonObject{{"planId", "ok"}});
QVERIFY(waitFor([&] { return planSpy.count() == 1; }));
QCOMPARE(client.protocolErrors(), 6);
QCOMPARE(server.disconnections, 0);
}
void TestOptimizerClient::testWriteToken()
{
const QString path = m_dir.filePath("tok/energy-optimizer.token");
QVERIFY(QDir().mkpath(m_dir.filePath("tok")));
const mode_t previousUmask = ::umask(022);
struct stat st;
// Nouveau fichier : 0600 malgré un umask permissif.
QString error;
QVERIFY2(OptimizerClient::writeToken(path, "secret-un-16-car", &error), qPrintable(error));
QCOMPARE(::stat(QFile::encodeName(path).constData(), &st), 0);
QCOMPARE(int(st.st_mode & 0777), 0600);
QFile f(path);
QVERIFY(f.open(QIODevice::ReadOnly));
QCOMPARE(f.readAll(), QByteArray("secret-un-16-car"));
f.close();
// Fichier préexistant en 0644 (posé à la main) : remplacé ET resserré.
QVERIFY(QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner
| QFileDevice::ReadGroup | QFileDevice::ReadOther));
QVERIFY(OptimizerClient::writeToken(path, "secret-deux-1234"));
QCOMPARE(::stat(QFile::encodeName(path).constData(), &st), 0);
QCOMPARE(int(st.st_mode & 0777), 0600);
QVERIFY(f.open(QIODevice::ReadOnly));
QCOMPARE(f.readAll(), QByteArray("secret-deux-1234"));
f.close();
// Refus : blanc ou contrôle (le trimmed() de la relecture ne les rendrait pas), trop long.
QVERIFY(!OptimizerClient::writeToken(path, "avec\nretour", &error));
QVERIFY(!error.contains("avec"));
QVERIFY(!OptimizerClient::writeToken(path, " bord"));
QVERIFY(!OptimizerClient::writeToken(path, QByteArray(4097, 'a')));
// Trop court : moins de 16 caractères (le masquage remplace toute occurrence du token). Comptés
// en caractères : 15 « é » font 30 octets et restent refusés.
QVERIFY(!OptimizerClient::writeToken(path, "quinze-car-1234", &error));
QVERIFY2(error.contains("moins de 16 caractères"), qUtf8Printable(error));
QVERIFY(!error.contains("quinze"));
QVERIFY(!OptimizerClient::writeToken(path, QString(15, QChar(0xE9)).toUtf8()));
QVERIFY(f.open(QIODevice::ReadOnly));
QCOMPARE(f.readAll(), QByteArray("secret-deux-1234")); // intact
f.close();
// Seul ce fichier est écrit à côté : aucun temporaire laissé, rien d'autre (energy.conf).
QCOMPARE(QDir(m_dir.filePath("tok")).entryList(QDir::Files | QDir::Hidden), QStringList{"energy-optimizer.token"});
// 16 caractères : accepté, exactement à la borne.
QVERIFY2(OptimizerClient::writeToken(path, "seize-car-123456", &error), qPrintable(error));
QVERIFY(f.open(QIODevice::ReadOnly));
QCOMPARE(f.readAll(), QByteArray("seize-car-123456"));
f.close();
// Vide : retrait (plus de token configuré) ; retirer un absent n'est pas une erreur.
QVERIFY(OptimizerClient::writeToken(path, QByteArray()));
QVERIFY(!QFile::exists(path));
QVERIFY(OptimizerClient::writeToken(path, QByteArray()));
QVERIFY(OptimizerClient::defaultTokenPath().endsWith("/energy-optimizer.token"));
::umask(previousUmask);
QCOMPARE(s_log, QStringList()); // l'écriture ne journalise rien
}
void TestOptimizerClient::testTokenNeverInAnyLog()
{
// Décision Q2 : le token n'apparaît dans AUCUNE ligne de journal, à aucun niveau. Le journal
// est capturé debug compris, et chaque chemin qui manipule le token est parcouru : envoi,
// refus par fermeture, refus par erreur qui RECOPIE le token, droits trop larges, succès,
// GetPlan, perte de connexion.
const QByteArray token = "Zq81-secret-de-test-4e7b";
const QString tokenPath = m_dir.filePath("energy-optimizer.token");
QVERIFY(OptimizerClient::writeToken(tokenPath, token));
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), tokenPath, "9.9", &clock);
client.start();
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
// Le secret a bien transité : sans cela, son absence du journal ne prouverait rien.
QCOMPARE(hs.value("params").toObject().value("token").toString().toUtf8(), token);
server.abortClient();
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
clock.advance(1000);
QVERIFY(server.waitMessage(&hs));
server.sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", hs.value("id")},
{"error", QJsonObject{{"code", -32001},
{"message", "token " + QString::fromUtf8(token) + " refusé"}}}});
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
QVERIFY(QFile::setPermissions(tokenPath, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ReadOther));
clock.advance(2000);
QVERIFY(server.waitMessage(&hs));
server.replyResult(hs.value("id").toInteger(), handshakeResult());
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
const quint64 id = client.requestPlan({{"n", 1}});
QJsonObject req;
QVERIFY(server.waitMessage(&req));
server.replyResult(qint64(id), QJsonObject{{"planId", "p"}});
QVERIFY(waitFor([&] { return client.pendingRequestId() == 0; }));
server.abortClient();
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
client.stop();
// Le journal a bien coulé, à tous les niveaux, sur chaque chemin parcouru — une absence ne
// s'affirme que dans un flux dont on a prouvé qu'il coule.
QVERIFY(countLog("handshake envoyé") >= 3); // debug
QCOMPARE(countLog("(closed-during-handshake)"), 1); // avertissement
QCOMPARE(countLog("(handshake-refused)"), 1);
QCOMPARE(countLog("erreur JSON-RPC -32001"), 1);
QCOMPARE(countLog("lisible au-delà de son propriétaire"), 1);
QCOMPARE(countLog("[Optimiseur] connecté à"), 1); // info
QCOMPARE(countLog("(connection-lost)"), 1);
QVERIFY(s_log.size() >= 8);
const QString secret = QString::fromUtf8(token);
for (const QString &line : std::as_const(s_log))
QVERIFY2(!line.contains(secret) && !line.contains("Zq81"), qPrintable("token dans le journal : " + line));
}
void TestOptimizerClient::testAShortTokenFileOpensNoConnection()
{
// Un fichier de token de moins de 16 caractères (posé à la main : la RPC le refuse) : le client
// ne se connecte PAS — ni avec ce token, ni sans token. Dit une fois, sans la valeur. Relu à
// chaque tentative : un token conforme posé ensuite sert à la suivante.
const QString tokenPath = m_dir.filePath("energy-optimizer.token");
const QByteArray court = "court-4f2a-9c1e"; // 15 caractères
{
QFile f(tokenPath);
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Truncate));
QVERIFY(f.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner));
QCOMPARE(f.write(court + '\n'), qint64(court.size() + 1));
}
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), tokenPath, "9.9", &clock);
int attempts = 0;
connect(&client, &OptimizerClient::stateChanged, this, [&](OptimizerClient::State s) {
if (s == OptimizerClient::Connecting)
++attempts;
});
client.start();
settle();
QCOMPARE(client.state(), OptimizerClient::Waiting);
QCOMPARE(client.lastErrorCode(), QStringLiteral("token-too-short"));
QCOMPARE(server.connections, 0);
// Les tentatives suivantes relisent le fichier, échouent pareil, et ne le redisent pas.
clock.advance(1000);
settle();
clock.advance(2000);
settle();
QCOMPARE(attempts, 3);
QCOMPARE(server.connections, 0);
QCOMPARE(countLog("(token-too-short) :"), 1);
QCOMPARE(countLog("moins de 16 caractères"), 1);
for (const QString &line : std::as_const(s_log))
QVERIFY2(!line.contains(QString::fromUtf8(court)), qUtf8Printable(line));
// Remplacé par un token conforme : la tentative suivante se connecte et l'envoie.
QVERIFY(OptimizerClient::writeToken(tokenPath, "seize-car-123456"));
clock.advance(4000);
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
QCOMPARE(hs.value("params").toObject().value("token").toString(), QStringLiteral("seize-car-123456"));
QCOMPARE(server.connections, 1);
}
void TestOptimizerClient::testARefusedHandshakeIsClosedByTheEngine_data()
{
QTest::addColumn<int>("code");
QTest::newRow("token refusé, -32001") << -32001;
QTest::newRow("majeure incompatible, -32602") << -32602;
}
void TestOptimizerClient::testARefusedHandshakeIsClosedByTheEngine()
{
// Protocole 1.2 (Q4) : après un handshake refusé, les DEUX côtés ferment. Ce faux optimiseur
// envoie l'erreur et GARDE son socket ouvert : la déconnexion qu'il constate vient du moteur.
QFETCH(int, code);
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
client.start();
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
server.sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", hs.value("id")},
{"error", QJsonObject{{"code", code}, {"message", "refusé"}}}});
QVERIFY(waitFor([&] { return server.disconnections == 1; }));
QCOMPARE(client.state(), OptimizerClient::Waiting);
QCOMPARE(client.lastErrorCode(), QStringLiteral("handshake-refused"));
QCOMPARE(client.requestPlan({}), quint64(0));
// Nouvelle tentative au backoff, pas avant : la fermeture n'est pas une reconnexion immédiate.
settle();
QCOMPARE(server.connections, 1);
QCOMPARE(clock.remaining(), qint64(1000));
}
void TestOptimizerClient::testAReceivedBatchIsNeverUnpacked()
{
// Protocole 1.2 (Q8) : lots interdits dans les deux sens. REÇU, un lot est une ligne écartée
// (invalid-json : ce n'est pas un objet), comptée, connexion CONSERVÉE ; la réponse qu'il
// contient n'est pas lue. La requête en vol attend donc toujours, et son délai la compte en échec.
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
bringUp(client, server);
QSignalSpy planSpy(&client, &OptimizerClient::planReceived);
QSignalSpy failSpy(&client, &OptimizerClient::planFailed);
QSignalSpy errorSpy(&client, &OptimizerClient::protocolError);
const quint64 id = client.requestPlan({});
QJsonObject req;
QVERIFY(server.waitMessage(&req));
const QByteArray reponse = QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", qint64(id)},
{"result", QJsonObject{{"planId", "dans-un-lot"}}}})
.toJson(QJsonDocument::Compact);
server.sendRaw("[" + reponse + "]\n");
QVERIFY(waitFor([&] { return errorSpy.count() == 1; }));
QCOMPARE(errorSpy.at(0).at(0).toString(), QStringLiteral("invalid-json"));
settle();
QCOMPARE(planSpy.count(), 0);
QCOMPARE(client.pendingRequestId(), id);
QCOMPARE(client.state(), OptimizerClient::Ready);
clock.advance(OptimizerClient::kRequestTimeoutMs);
QCOMPARE(failSpy.count(), 1);
QCOMPARE(failSpy.at(0).at(1).toString(), QStringLiteral("timeout"));
settle();
QCOMPARE(client.state(), OptimizerClient::Ready);
QCOMPARE(server.disconnections, 0);
}
void TestOptimizerClient::testEachLineSentIsOneObject()
{
// Protocole 1.2 (Q8, sens moteur → optimiseur) : un message par ligne, jamais un lot. Le client
// n'a qu'un point d'écriture, send(), qui sérialise UN objet ; vérifié ici sur les octets.
RawOptimizer raw;
const QString endpoint = raw.listen(m_dir.filePath("brut.sock"));
QVERIFY(!endpoint.isEmpty());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(endpoint), QString(), "9.9", &clock);
client.start();
QVERIFY(waitFor([&] { return raw.lines().size() == 1; }));
raw.send(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2"}})");
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
const QVariantMap context{{"timestamp", "2026-10-16T10:00:00Z"},
{"loads", QVariantList{QVariantMap{{"id", "a"}}, QVariantMap{{"id", "b"}}}}};
QCOMPARE(client.requestPlan(context), quint64(2));
QCOMPARE(client.requestPlan(context), quint64(3)); // remplace la précédente : une ligne de plus, pas un lot
QVERIFY(waitFor([&] { return raw.lines().size() == 3; }));
const QList<QByteArray> lines = raw.lines();
QVERIFY(raw.received.endsWith('\n'));
for (int i = 0; i < lines.size(); ++i) {
QJsonParseError e;
const QJsonDocument doc = QJsonDocument::fromJson(lines.at(i), &e);
QVERIFY2(e.error == QJsonParseError::NoError && doc.isObject() && lines.at(i).startsWith('{'),
qPrintable(QStringLiteral("ligne %1 : pas un objet JSON seul").arg(i + 1)));
QCOMPARE(doc.object().value("id").toInteger(), qint64(i + 1));
QVERIFY(lines.at(i).size() <= OptimizerClient::kMaxLineBytes);
}
}
void TestOptimizerClient::testARequestOverTheLineLimitIsNeverSent()
{
// Protocole 1.2 (Q7, maxLineBytes dans les deux sens), sens moteur → optimiseur. Une requête
// dont la ligne dépasserait 1 Mio n'est PAS écrite : un optimiseur conforme la jetterait
// (-32600 à id null) et la requête finirait en délai dépassé, la cause perdue. Elle échoue
// tout de suite, sous son propre code, et la connexion reste utilisable. La limite est
// éprouvée des deux côtés : exactement kMaxLineBytes part, un octet de plus ne part pas.
RawOptimizer raw;
const QString endpoint = raw.listen(m_dir.filePath("brut.sock"));
QVERIFY(!endpoint.isEmpty());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(endpoint), QString(), "9.9", &clock);
QSignalSpy failSpy(&client, &OptimizerClient::planFailed);
QSignalSpy planSpy(&client, &OptimizerClient::planReceived);
client.start();
QVERIFY(waitFor([&] { return raw.lines().size() == 1; }));
raw.send(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2"}})");
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
const int recuApresHandshake = raw.received.size();
// Un contexte dont la ligne GetPlan fait exactement `octets` octets, '\n' non compté. Le
// libellé est en ASCII sans caractère à échapper : un caractère, un octet. Le gabarit est
// mesuré sur la forme compacte ; une erreur de calcul ferait échouer la comparaison de taille
// sur le fil, plus bas — elle ne peut pas faire passer le test à tort.
auto contexte = [](quint64 id, int octets) {
auto ligne = [id](const QString &label) {
return QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", qint64(id)}, {"method", "GetPlan"},
{"params", QJsonObject{{"loads", QJsonArray{QJsonObject{
{"id", "ecs"}, {"label", label}}}}}}})
.toJson(QJsonDocument::Compact).size();
};
const int gabarit = int(ligne(QString()));
return QVariantMap{{"loads", QVariantList{QVariantMap{
{"id", "ecs"}, {"label", QString(octets - gabarit, QLatin1Char('x'))}}}}};
};
// Un octet de trop : l'identifiant est rendu, mais l'échec n'est PAS émis avant le retour —
// l'appelant ne pourrait pas l'attribuer.
const quint64 tropLong = client.requestPlan(contexte(2, OptimizerClient::kMaxLineBytes + 1));
QCOMPARE(tropLong, quint64(2));
QCOMPARE(failSpy.count(), 0);
QVERIFY(waitFor([&] { return failSpy.count() == 1; }));
QCOMPARE(failSpy.at(0).at(0).value<quint64>(), tropLong);
QCOMPARE(failSpy.at(0).at(1).toString(), QStringLiteral("request-too-long"));
QCOMPARE(client.lastErrorCode(), QStringLiteral("request-too-long"));
QCOMPARE(client.pendingRequestId(), quint64(0));
QVERIFY(!clock.armed()); // aucun délai de 10 s ne court pour une requête jamais partie
QCOMPARE(client.state(), OptimizerClient::Ready);
QCOMPARE(client.protocolErrors(), 0); // ce n'est pas une ligne REÇUE écartée
// AUCUN octet n'est parti : le serveur n'a rien reçu de plus que le handshake.
settle();
raw.lines();
QCOMPARE(raw.received.size(), recuApresHandshake);
// La connexion sert la requête suivante, exactement à la limite : elle part, entière.
const quint64 limite = client.requestPlan(contexte(3, OptimizerClient::kMaxLineBytes));
QCOMPARE(limite, quint64(3));
QVERIFY(waitFor([&] { return raw.lines().size() == 2; }, 10000));
const QByteArray ligne = raw.lines().at(1);
QCOMPARE(ligne.size(), OptimizerClient::kMaxLineBytes);
QCOMPARE(QJsonDocument::fromJson(ligne).object().value("id").toInteger(), qint64(limite));
raw.send(R"({"jsonrpc":"2.0","id":3,"result":{"planId":"servi"}})");
QVERIFY(waitFor([&] { return planSpy.count() == 1; }));
QCOMPARE(planSpy.at(0).at(0).value<quint64>(), limite);
QCOMPARE(failSpy.count(), 1);
}
void TestOptimizerClient::testARequestTooLongIsLoggedOncePerConnection()
{
// Q7 : une requête trop longue se journalise UNE fois par connexion, avec sa taille et jamais
// son contenu. Une charge au libellé démesuré la reproduirait à chaque cycle : une ligne par
// minute noierait le journal. Chaque échec reste émis, donc compté par l'appelant.
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
bringUp(client, server);
QSignalSpy failSpy(&client, &OptimizerClient::planFailed);
const QString marque = QStringLiteral("contenu-jamais-journalise-");
const QVariantMap contexte{{"loads", QVariantList{QVariantMap{
{"id", "ecs"}, {"label", marque + QString(OptimizerClient::kMaxLineBytes, QLatin1Char('x'))}}}}};
const QString ligneJournal = QStringLiteral("NON ENVOYÉ");
for (int i = 1; i <= 3; ++i) {
client.requestPlan(contexte);
QVERIFY(waitFor([&] { return failSpy.count() == i; }));
}
QCOMPARE(countLog(ligneJournal), 1);
QCOMPARE(countLog(QStringLiteral("request-too-long")), 1); // aucune ligne de rappel non plus
QCOMPARE(countLog(marque), 0);
// La taille y est, et c'est celle de la requête, pas la limite.
const QRegularExpression taille(QStringLiteral("NON ENVOYÉ : (\\d+) octets"));
QRegularExpressionMatch m;
for (const QString &l : std::as_const(s_log))
if ((m = taille.match(l)).hasMatch())
break;
QVERIFY(m.hasMatch());
QVERIFY(m.captured(1).toInt() > OptimizerClient::kMaxLineBytes + marque.size());
// Nouvelle connexion : la première se redit.
server.abortClient();
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
clock.advance(client.nextBackoffMs());
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
server.replyResult(hs.value("id").toInteger(), handshakeResult());
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
client.requestPlan(contexte);
client.requestPlan(contexte); // remplace la précédente avant son échec différé
QVERIFY(waitFor([&] { return failSpy.count() == 4; }));
settle();
QCOMPARE(failSpy.count(), 4); // la requête remplacée n'émet rien (contrat de requestPlan)
QCOMPARE(countLog(ligneJournal), 2);
QCOMPARE(countLog(marque), 0);
}
QTEST_GUILESS_MAIN(TestOptimizerClient)
#include "testoptimizerclient.moc"