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>
This commit is contained in:
115
framework/qml/tests/TestHttpServer.cpp
Normal file
115
framework/qml/tests/TestHttpServer.cpp
Normal file
@@ -0,0 +1,115 @@
|
||||
#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
|
||||
Reference in New Issue
Block a user