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"