Patrick Schurig 0fdbede6c3 feat(optimiseur): client de transport unix/tcp vers l'optimiseur (brief 4 T2)
OptimizerClient : QLocalSocket ou QTcpSocket, NDJSON, JSON-RPC 2.0, identifiants
croissants sur toute la vie du client, une requête GetPlan en vol au plus (la plus
récente remplace l'attente ; la réponse obsolète est reconnue par son id et ignorée).

Handshake §3 : majeure comparée, capabilities mémorisé (présent ≠ absent), délai de
5 s compté depuis le début de la tentative ; reconnexion au backoff 1 → 60 s, remis
à 1 s après un handshake accepté. GetPlan : 10 s, connexion conservée. Ligne non
JSON, message sans id, id jamais émis, ligne de plus de 1 Mio : erreur comptée,
connexion conservée.

Horloge et réveil injectables (OptimizerClientClock, monotone) : aucune lecture de
l'horloge murale. Token relu à chaque connexion dans settingsPath()/
energy-optimizer.token, jamais journalisé ; writeToken() pose le fichier par
QSaveFile en 0600 (droits posés avant l'écriture du secret). Pas de RPC.

Le client n'est instancié nulle part dans le moteur. Suite légère
nymea-energy-optimizerclient : faux serveurs QLocalServer/QTcpServer, temps simulé,
37 cas ; 17 mutations du client, toutes mordent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:12:18 +02:00

910 lines
38 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 <QCoreApplication>
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLocalServer>
#include <QLocalSocket>
#include <QLoggingCategory>
#include <QPointer>
#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;
}
// Attente d'une condition d'E/S. L'horloge murale ne borne QUE l'attente du test, jamais un
// délai du client.
template <typename F>
bool waitFor(F condition, int timeoutMs = 5000)
{
QElapsedTimer t;
t.start();
while (!condition()) {
if (t.elapsed() > timeoutMs)
return false;
QCoreApplication::processEvents(QEventLoop::AllEvents, 10);
}
return true;
}
// Laisse passer les événements en attente sans rien attendre de précis : sert à constater
// qu'il NE se passe rien.
void settle(int ms = 50)
{
QElapsedTimer t;
t.start();
while (t.elapsed() < ms)
QCoreApplication::processEvents(QEventLoop::AllEvents, 5);
}
} // 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;
};
/*! Faux optimiseur : accepte une connexion à la fois, découpe ce qu'il reçoit en lignes JSON,
* et répond ce que le test lui dicte. */
class FakeOptimizer : public QObject
{
Q_OBJECT
public:
enum Transport { Unix, Tcp };
FakeOptimizer(Transport transport, const QString &dir) : m_transport(transport), m_dir(dir) {}
bool listen()
{
if (m_transport == Unix) {
m_local = new QLocalServer(this);
QLocalServer::removeServer(socketPath());
connect(m_local, &QLocalServer::newConnection, this, [this] {
while (QLocalSocket *s = m_local->nextPendingConnection())
adopt(s);
});
return m_local->listen(socketPath());
}
m_tcp = new QTcpServer(this);
connect(m_tcp, &QTcpServer::newConnection, this, [this] {
while (QTcpSocket *s = m_tcp->nextPendingConnection())
adopt(s);
});
// Port déjà choisi lors d'une écoute précédente : on le reprend (reconnexion).
return m_tcp->listen(QHostAddress::LocalHost, m_port) && (m_port = m_tcp->serverPort()) != 0;
}
QString socketPath() const { return m_dir + QStringLiteral("/optimizer.sock"); }
QString endpoint() const
{
if (m_transport == Unix)
return QStringLiteral("unix://") + socketPath();
return QStringLiteral("tcp://127.0.0.1:%1").arg(m_port);
}
bool waitMessage(QJsonObject *out, int timeoutMs = 5000)
{
if (!waitFor([this] { return !m_messages.isEmpty(); }, timeoutMs))
return false;
*out = m_messages.takeFirst();
return true;
}
void sendRaw(const QByteArray &bytes)
{
QVERIFY(m_client);
m_client->write(bytes);
if (auto *tcp = qobject_cast<QTcpSocket *>(m_client.data()))
tcp->flush();
else if (auto *local = qobject_cast<QLocalSocket *>(m_client.data()))
local->flush();
}
void sendJson(const QJsonObject &o) { sendRaw(QJsonDocument(o).toJson(QJsonDocument::Compact) + '\n'); }
void replyResult(qint64 id, const QJsonObject &result)
{
sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", id}, {"result", result}});
}
void abortClient()
{
if (auto *tcp = qobject_cast<QTcpSocket *>(m_client.data()))
tcp->abort();
else if (auto *local = qobject_cast<QLocalSocket *>(m_client.data()))
local->abort();
}
int connections = 0;
int disconnections = 0;
private:
void adopt(QIODevice *s)
{
++connections;
m_client = s;
m_buffer.clear();
connect(s, &QIODevice::readyRead, this, [this, s] {
m_buffer.append(s->readAll());
int nl;
while ((nl = m_buffer.indexOf('\n')) >= 0) {
m_messages.append(QJsonDocument::fromJson(m_buffer.left(nl)).object());
m_buffer.remove(0, nl + 1);
}
});
if (auto *tcp = qobject_cast<QTcpSocket *>(s))
connect(tcp, &QTcpSocket::disconnected, this, [this] { ++disconnections; });
else if (auto *local = qobject_cast<QLocalSocket *>(s))
connect(local, &QLocalSocket::disconnected, this, [this] { ++disconnections; });
}
Transport m_transport;
QString m_dir;
quint16 m_port = 0;
QLocalServer *m_local = nullptr;
QTcpServer *m_tcp = nullptr;
QPointer<QIODevice> m_client;
QByteArray m_buffer;
QList<QJsonObject> m_messages;
};
static QJsonObject handshakeResult(const QString &version = QStringLiteral("1.1"), bool withCapabilities = true)
{
QJsonObject r{{"protocolVersion", version}, {"name", "faux-optimiseur"}, {"planning", true}};
if (withCapabilities)
r.insert("capabilities", QJsonObject{{"maxHorizonH", 48}, {"kinds", QJsonArray{"setpoint", "state"}}});
return r;
}
class TestOptimizerClient : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
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();
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::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();
QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.1"));
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.1 — 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-7f3a"));
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-7f3a"));
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", &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"));
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"));
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"));
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')));
QVERIFY(f.open(QIODevice::ReadOnly));
QCOMPARE(f.readAll(), QByteArray("secret-deux")); // 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"});
// 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));
}
QTEST_GUILESS_MAIN(TestOptimizerClient)
#include "testoptimizerclient.moc"