Question 4 de T7 : le masquage à la réception remplace TOUTE occurrence du token dans ce que l'optimiseur renvoie ; un token court, présent par hasard dans un identifiant de charge, corromprait le plan reçu (unknown-load). - SetOptimizerToken (writeToken) refuse un token non vide de moins de 16 caractères (points de code UTF-8) : EnergyErrorInvalidParameter, fault `token-refused` comme les autres refus ; rien n'est modifié ; le motif est journalisé, jamais la valeur. Vide = retrait, inchangé. - Le client relit le fichier au début de chaque tentative ; plus court que 16 caractères, il ne se connecte PAS (ni avec ce token, ni sans token : les mesures du site ne partent pas vers un optimiseur que rien n'authentifie). lastErrorCode `token-too-short`, dit une fois par série, nouvelle tentative au backoff jusqu'au remplacement du fichier. INTERFACE.md (SetOptimizerToken, lastErrorCode) et protocole §3 (E-T2-8, forme du token). Tests : 15 refusé, 16 accepté, 15 « é » refusés (caractères, pas octets), vide = retrait, fichier court sans connexion puis token conforme envoyé ; tokens des suites portés à 16 au moins. Contre-épreuves M4a (refus retiré) et M4b (fichier court envoyé) : mordent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
835 lines
36 KiB
C++
835 lines
36 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 "../common/etm-powersync-fakeoptimizer.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;
|
|
}
|
|
|
|
} // 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 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();
|
|
|
|
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-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);
|
|
}
|
|
|
|
QTEST_GUILESS_MAIN(TestOptimizerClient)
|
|
#include "testoptimizerclient.moc"
|