Files
php-qml/framework/qml/tests/TestHttpServer.cpp

116 lines
4.4 KiB
C++
Raw Permalink Normal View History

qml: defer ReactiveListModel/ReactiveObject initial fetch to componentComplete() setBaseUrl() and setSource() used to fire refresh() inline as soon as both `baseUrl` and `source` were populated — but setToken() never triggered a refresh. QML evaluates literal property assignments before bindings to other objects' properties, so a model declared with literal `source` plus bindings to `BackendConnection.url` / `BackendConnection.token` (the exact shape of make:bridge:window's output) could fire its GET *before* the `token` binding had landed. The unauthenticated request hit Symfony's SessionAuthenticator, came back 401, and the model parked at `ready === false` with an empty list. Mercure subscribed anonymously (the model explicitly clears the SSE client's bearer), so subsequent server-side mutations propagated fine — masking the initial-fetch failure as "list is empty until something changes". Hit by the second window in examples/todo. Both classes now implement QQmlParserStatus and trigger the initial refresh from componentComplete(), where every binding (literal *and* singleton-derived) is guaranteed to have landed. After completion, individual setter changes still trigger refresh inline — so token rotation / URL reassignment after first load behave unchanged. Regression test under framework/qml/tests/tst_reactive_list_model.qml using the v0.2.0 qmltestrunner harness. Adds a TestHttpServer helper that mimics SessionAuthenticator's 401-on-no-bearer behaviour so the regression is observable; verified the test fails against the unfixed production code (`Actual: ""` vs `Expected: "Bearer testtoken"` on the captured Authorization header). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:19:56 +02:00
#include "TestHttpServer.h"
#include <QHostAddress>
#include <QTcpSocket>
namespace PhpQml::Bridge::Tests {
TestHttpServer::TestHttpServer(QObject* parent)
: QObject(parent)
{
m_server.listen(QHostAddress::LocalHost, 0);
connect(&m_server, &QTcpServer::newConnection,
this, &TestHttpServer::onNewConnection);
}
QString TestHttpServer::url() const
{
return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort());
}
void TestHttpServer::setResponseBody(const QString& v)
{
if (m_responseBody == v) return;
m_responseBody = v;
emit responseBodyChanged();
}
void TestHttpServer::setResponseStatus(int v)
{
if (m_responseStatus == v) return;
m_responseStatus = v;
emit responseStatusChanged();
}
void TestHttpServer::onNewConnection()
{
while (auto* sock = m_server.nextPendingConnection()) {
// One buffer per socket, owned by the socket so it dies with it.
// (The original thread_local trick leaked between connections.)
auto* buffer = new QByteArray;
connect(sock, &QObject::destroyed, [buffer]() { delete buffer; });
connect(sock, &QTcpSocket::readyRead, this, [this, sock, buffer]() {
buffer->append(sock->readAll());
const int headerEnd = buffer->indexOf("\r\n\r\n");
if (headerEnd < 0) return;
const QByteArray headerBlock = buffer->left(headerEnd);
buffer->clear();
const QList<QByteArray> lines = headerBlock.split('\n');
QString requestLine;
QString authHeader;
for (int i = 0; i < lines.size(); ++i) {
QByteArray line = lines[i];
if (line.endsWith('\r')) line.chop(1);
if (i == 0) {
requestLine = QString::fromUtf8(line);
continue;
}
const int colon = line.indexOf(':');
if (colon < 0) continue;
const QByteArray name = line.left(colon).trimmed();
const QByteArray value = line.mid(colon + 1).trimmed();
if (name.compare("Authorization", Qt::CaseInsensitive) == 0) {
authHeader = QString::fromUtf8(value);
}
}
// Only count + capture metrics for /api/… GETs. SSE reconnect
// attempts from MercureClient hit /.well-known/mercure on the
// same port and would otherwise inflate the request count and
// overwrite the captured headers we want to assert against.
const bool isApiGet = requestLine.startsWith(QStringLiteral("GET /api/"));
if (isApiGet) {
if (m_lastRequestLine != requestLine) {
m_lastRequestLine = requestLine;
emit lastRequestLineChanged();
}
if (m_lastAuthHeader != authHeader) {
m_lastAuthHeader = authHeader;
emit lastAuthHeaderChanged();
}
++m_apiGetCount;
emit apiGetCountChanged();
}
// For /api/ routes, mimic SessionAuthenticator and reject
// requests without an Authorization header. This is what
// exposes the property-order race in the regression test:
// pre-fix, the GET went out unauthenticated, this server
// returned 401, and the model parked with `ready === false`.
const bool needAuth = isApiGet;
const bool isAuthed = !authHeader.isEmpty();
const bool reject = needAuth && !isAuthed;
const int status = reject ? 401 : m_responseStatus;
const QByteArray body = reject
? QByteArrayLiteral(R"({"type":"about:blank","title":"Unauthorized","status":401})")
: m_responseBody.toUtf8();
QByteArray resp;
resp.append("HTTP/1.1 ").append(QByteArray::number(status))
.append(' ').append(status == 200 ? "OK" : "STATUS").append("\r\n");
resp.append("Content-Type: application/json\r\n");
resp.append("Content-Length: ").append(QByteArray::number(body.size())).append("\r\n");
resp.append("Connection: close\r\n\r\n");
resp.append(body);
sock->write(resp);
sock->disconnectFromHost();
});
connect(sock, &QTcpSocket::disconnected, sock, &QObject::deleteLater);
}
}
} // namespace PhpQml::Bridge::Tests