From 952f64d24b210d82e9eaa27d2922b1bc37ff87dd Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 21 Oct 2026 14:01:16 +0200 Subject: [PATCH] =?UTF-8?q?test(optimizerclient)=20:=20le=20moteur=20face?= =?UTF-8?q?=20aux=20r=C3=A8gles=201.2=20=E2=80=94=20fermeture,=20lots,=20l?= =?UTF-8?q?ongueur=20de=20ligne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preuves, côté moteur, des points Q4, Q7 et Q8 du protocole 1.2. Aucun code du moteur n'est modifié. - testARefusedHandshakeIsClosedByTheEngine (Q4) : -32001 ou -32602 au handshake, faux optimiseur qui garde son socket ouvert → c'est le moteur qui ferme (handshake-refused), nouvelle tentative au backoff seulement. - testAReceivedBatchIsNeverUnpacked (Q8, reçu) : un lot portant la réponse attendue est une ligne écartée (invalid-json), connexion conservée, réponse non lue ; la requête finit en délai dépassé. - testEachLineSentIsOneObject (Q8, émis) : lu sur les octets, chaque ligne émise est UN objet JSON, une requête remplacée donne une ligne de plus. - testAContextOverTheLineLimitIsStillSent (Q7, émis) : ÉCART constaté, non corrigé dans ce lot — requestPlan() ne borne pas ce qu'il émet ; un GetPlan de plus de 1 Mio part tel quel. QEXPECT_FAIL : le test passera en XPASS le jour où le moteur bornera l'émission. Q7 en réception était déjà éprouvé par testLineLengthLimit. Co-Authored-By: Claude Opus 5.5 --- .../optimizerclient/testoptimizerclient.cpp | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tests/auto/optimizerclient/testoptimizerclient.cpp b/tests/auto/optimizerclient/testoptimizerclient.cpp index 82ed520..8d7fd80 100644 --- a/tests/auto/optimizerclient/testoptimizerclient.cpp +++ b/tests/auto/optimizerclient/testoptimizerclient.cpp @@ -52,6 +52,37 @@ int countLog(const QString &needle) 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 lines() + { + if (!socket && server.hasPendingConnections()) + socket = server.nextPendingConnection(); + if (socket) + received += socket->readAll(); + QList 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 @@ -112,6 +143,11 @@ private slots: void testWriteToken(); void testTokenNeverInAnyLog(); void testAShortTokenFileOpensNoConnection(); + void testARefusedHandshakeIsClosedByTheEngine_data(); + void testARefusedHandshakeIsClosedByTheEngine(); + void testAReceivedBatchIsNeverUnpacked(); + void testEachLineSentIsOneObject(); + void testAContextOverTheLineLimitIsStillSent(); private: // Démarre le client et mène le handshake jusqu'à Ready. @@ -847,5 +883,133 @@ void TestOptimizerClient::testAShortTokenFileOpensNoConnection() QCOMPARE(server.connections, 1); } +void TestOptimizerClient::testARefusedHandshakeIsClosedByTheEngine_data() +{ + QTest::addColumn("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 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::testAContextOverTheLineLimitIsStillSent() +{ + // ÉCART au protocole 1.2 (Q7, maxLineBytes dans les deux sens), constaté et NON CORRIGÉ dans le + // lot qui norme la règle : requestPlan() n'applique pas kMaxLineBytes à ce qu'il ÉMET. Rien ne + // borne non plus la taille d'un contexte en amont — un `label` de charge saisi par SetLoadConfig + // suffit. Un optimiseur conforme jette la ligne (-32600) et la requête finit en délai dépassé. + // Le jour où le moteur borne l'émission, ce test passe (XPASS) et doit être réécrit. + 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{{"loads", QVariantList{QVariantMap{ + {"id", "ecs"}, {"label", QString(OptimizerClient::kMaxLineBytes, QLatin1Char('x'))}}}}}; + QCOMPARE(client.requestPlan(context), quint64(2)); + QVERIFY(waitFor([&] { return raw.lines().size() == 2; }, 10000)); + const QByteArray line = raw.lines().at(1); + QVERIFY(line.contains("\"GetPlan\"")); + QEXPECT_FAIL("", "écart Q7 : le moteur émet une ligne de plus de maxLineBytes (rapport protocole 1.2)", Continue); + QVERIFY2(line.size() <= OptimizerClient::kMaxLineBytes, + qPrintable(QStringLiteral("GetPlan émis sur %1 octets").arg(line.size()))); +} + QTEST_GUILESS_MAIN(TestOptimizerClient) #include "testoptimizerclient.moc"