ZaStoGram_desktop/Telegram/SourceFiles/test/test_wallet_ready.cpp
John Preston f716d66012 [ai] Correct the wallet refresh helper's stale notes
Task: 2026/08/26/show-server-wallet-transaction-history
2026-10-08 22:23:54 +04:00

426 lines
13 KiB
C++

/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#ifdef _DEBUG
#include "test/test_wallet_ready.h"
#include "base/flat_map.h"
#include "test/test_log.h"
#include "test/test_runner.h"
#include "wallet/wallet_session.h"
namespace Test {
namespace {
struct Watch {
crl::time started = 0;
crl::time deadline = 0;
int samples = 0;
int counts[4] = {};
WalletRefreshVerdict last;
QString transitions;
bool done = false;
};
[[nodiscard]] base::flat_map<QString, Watch> &Watches() {
static auto result = base::flat_map<QString, Watch>();
return result;
}
[[nodiscard]] QString Describe(
const WalletFreshnessReading &reading,
crl::time window) {
return u"at=%1 stateAtMs=%2 stateAgeMs=%3 windowMs=%4"_q
.arg(qint64(reading.atMs))
.arg(qint64(reading.stateAtMs))
.arg(qint64(reading.atMs - reading.stateAtMs))
.arg(qint64(window));
}
void LogSettle(const QString &name, bool settled) {
LogRaw(u"WALLET_REFRESH_SETTLE: settled=%1 %2"_q
.arg(settled ? 1 : 0)
.arg(WalletRefreshSettleDetails(name)));
}
// The observation is deliberately not printed here: every caller passes
// it to the neighbouring Check as its |details|, which the harness prints
// on the passing verdict too, so noting it would log the same reading
// twice. Only the label and the state name are carried nowhere else.
void NoteVerdict(
const QString &label,
const WalletRefreshVerdict &verdict) {
Note(u"wallet readiness self-test: %1 state=%2"_q
.arg(label)
.arg(WalletRefreshStateName(verdict.state)));
}
[[nodiscard]] QString PublicReadOut(Wallet::Session &wallet) {
return u"presence=%1 balanceNano=%2 history=%3 hasNext=%4 "
"collectibles=%5 sendState=%6"_q
.arg(int(wallet.presence()))
.arg(qint64(wallet.balanceNano()))
.arg(int(wallet.history().size()))
.arg(wallet.historyHasNext() ? 1 : 0)
.arg(int(wallet.collectibles().size()))
.arg(int(wallet.sendState()));
}
} // namespace
QString WalletRefreshStateName(WalletRefreshState state) {
switch (state) {
case WalletRefreshState::Unreadable:
return u"unreadable"_q;
case WalletRefreshState::Unstamped:
return u"unstamped"_q;
case WalletRefreshState::Stale:
return u"stale"_q;
case WalletRefreshState::Ready:
return u"ready"_q;
}
return u"missing"_q;
}
WalletRefreshVerdict ReadWalletRefresh(
const WalletFreshnessReading &reading,
crl::time window) {
const auto observation = Describe(reading, window);
if (reading.atMs <= 0
|| reading.stateAtMs < 0
|| reading.stateAtMs > reading.atMs) {
return { WalletRefreshState::Unreadable, observation };
} else if (!reading.stateAtMs) {
return {
WalletRefreshState::Unstamped,
observation + u" unstamped=state"_q,
};
} else if (reading.atMs - reading.stateAtMs > window) {
return { WalletRefreshState::Stale, observation };
}
return { WalletRefreshState::Ready, observation };
}
bool WalletRefreshSettled(
const QString &name,
Fn<WalletRefreshVerdict()> observe,
crl::time deadline) {
auto &watch = Watches()[name];
if (watch.done) {
return true;
}
const auto now = crl::now();
if (!watch.started) {
watch.started = now;
watch.deadline = deadline;
}
const auto first = !watch.samples;
const auto previous = watch.last.state;
++watch.samples;
watch.last = observe();
++watch.counts[int(watch.last.state)];
if (first || (watch.last.state != previous)) {
if (!watch.transitions.isEmpty()) {
watch.transitions += ',';
}
watch.transitions += u"%1@%2"_q
.arg(WalletRefreshStateName(watch.last.state))
.arg(qint64(now - watch.started));
}
if (watch.last.ready()) {
watch.done = true;
LogSettle(name, true);
Pass(u"wallet refresh settle: %1"_q.arg(name));
return true;
} else if (now - watch.started >= watch.deadline) {
watch.done = true;
LogSettle(name, false);
Fail(
u"wallet refresh settle: %1"_q.arg(name),
WalletRefreshSettleDetails(name));
return true;
}
return false;
}
QString WalletRefreshSettleDetails(const QString &name) {
const auto &watches = Watches();
const auto i = watches.find(name);
if (i == watches.end()) {
return u"name=%1 not started"_q.arg(name);
}
const auto &watch = i->second;
const auto tallies = u"unreadable=%1 unstamped=%2 stale=%3 ready=%4"_q
.arg(watch.counts[int(WalletRefreshState::Unreadable)])
.arg(watch.counts[int(WalletRefreshState::Unstamped)])
.arg(watch.counts[int(WalletRefreshState::Stale)])
.arg(watch.counts[int(WalletRefreshState::Ready)]);
return u"name=%1 state=%2 observation=%3 samples=%4 %5 elapsedMs=%6 "
"deadlineMs=%7 transitions=%8"_q
.arg(name)
.arg(WalletRefreshStateName(watch.last.state))
.arg(watch.last.observation.isEmpty()
? u"<none>"_q
: watch.last.observation)
.arg(watch.samples)
.arg(tallies)
.arg(qint64(watch.started ? (crl::now() - watch.started) : 0))
.arg(qint64(watch.deadline))
.arg(watch.transitions.isEmpty()
? u"<none>"_q
: watch.transitions);
}
void AppendWalletRefreshSelfTest(
not_null<Runner*> runner,
Fn<Wallet::Session*()> resolve,
Fn<WalletFreshnessReading()> reading) {
struct State {
Wallet::Session *wallet = nullptr;
bool drained = false;
crl::time clearedAt = 0;
crl::time drainedAfterMs = -1;
};
const auto state = new State();
const auto observe = [=] {
return ReadWalletRefresh(reading());
};
const auto readOut = [=] {
return state->wallet
? PublicReadOut(*state->wallet)
: u"wallet=missing"_q;
};
runner->add({
.name = u"wallet readiness self-test: positive"_q,
.run = [=] {
state->wallet = resolve();
Check(
state->wallet != nullptr,
u"fixture gate: the wallet session resolved"_q);
const auto wallet = state->wallet;
if (!wallet) {
return;
}
// refreshHistory() is the first touch because its
// ensureLoaded() is what sends the first wallet.getState:
// presence(), startPolling() and debugClearNetworkState()
// all answer for Presence::Unknown on a session nothing has
// touched yet, and debugClearNetworkState() early-returns
// on anything but Ready. Nothing here reads the presence
// either: that request has only just gone out, so the answer
// deciding it cannot have arrived and a gate on it would
// refuse a healthy fixture. The settle below is the wait.
wallet->refreshHistory();
wallet->startPolling();
},
.until = [=] {
return WalletRefreshSettled(
u"wallet-ready-self-test-positive"_q,
observe,
kWalletSettleTimeout);
},
.then = [=] {
const auto verdict = observe();
NoteVerdict(u"positive"_q, verdict);
Check(
verdict.ready(),
u"the server's wallet state was applied recently, "
"stamping the wallet freshness field"_q,
verdict.observation);
const auto presence = state->wallet
? int(state->wallet->presence())
: -1;
Check(
presence == int(Wallet::Presence::Ready),
u"fixture gate: the test account has a wallet"_q,
u"presence=%1"_q.arg(presence));
Note(readOut());
},
.timeout = kWalletSettleTimeout + kDefaultStageTimeout,
.timeoutDetails = [] {
return WalletRefreshSettleDetails(
u"wallet-ready-self-test-positive"_q);
},
});
runner->add({
.name = u"wallet readiness self-test: negative"_q,
.run = [=] {
const auto wallet = state->wallet;
if (!wallet) {
return;
}
// The clear bumps _networkGeneration, zeroes the stamp and
// _stateRequestedAt, cancels the outstanding wallet.getState
// and clears _historyDone — which is why the observed
// callback has to be queued after it, since a callback
// queued before is discarded, not drained. It also zeroes
// the polling count, cancels the poll timer and stops the
// stream, so nothing can restamp the field while the state
// is cleared. It deliberately leaves the presence alone, so
// the refreshHistory() below still runs its body and queues
// the callback; that queue is now drained by a real
// wallet.getTransactions answering or failing, so the
// completion arrives after a network round trip instead of on
// the spot and the stage timeout is what covers it. That round
// trip stamps only _historyRefreshedAt, so the field this test
// reads is still unstamped when the completion fires.
wallet->debugClearNetworkState();
state->clearedAt = crl::now();
wallet->refreshHistory([=] {
state->drained = true;
state->drainedAfterMs = crl::now() - state->clearedAt;
});
},
.until = [=] {
return state->drained;
},
.then = [=] {
const auto verdict = observe();
NoteVerdict(u"negative"_q, verdict);
Check(
verdict.state == WalletRefreshState::Unstamped,
u"the drained completion left the wallet freshness "
"field unstamped"_q,
u"drained=1 drainedAfterMs=%1 %2 %3"_q
.arg(qint64(state->drainedAfterMs))
.arg(verdict.observation)
.arg(readOut()));
},
.timeout = crl::time(90000),
.timeoutDetails = [=] {
return u"drained=%1 drainedAfterMs=%2 %3 %4"_q
.arg(state->drained ? 1 : 0)
.arg(qint64(state->drainedAfterMs))
.arg(observe().observation)
.arg(readOut());
},
});
runner->add({
.name = u"wallet readiness self-test: recovery"_q,
.run = [=] {
// debugRestoreNetworkState() restores the polling count and
// calls updatePollingState(), which takes a pollTick()
// immediately, so the first tick after it is read while the
// stamp is still zero. That is run 1's reading, reproduced
// on demand and refused by the same predicate. The clear
// zeroed _stateRequestedAt too, so that tick's refreshState()
// is not held off by the floor and re-asks at once.
if (const auto wallet = state->wallet) {
wallet->debugRestoreNetworkState();
}
},
.until = [=] {
return WalletRefreshSettled(
u"wallet-ready-self-test-recovery"_q,
observe,
kWalletSettleTimeout);
},
.then = [=] {
const auto verdict = observe();
NoteVerdict(u"recovery"_q, verdict);
Check(
verdict.state == WalletRefreshState::Ready,
u"the cleared wallet recovered: a later wallet.getState "
"stamped the freshness field again"_q,
verdict.observation);
Note(readOut());
},
.timeout = kWalletSettleTimeout + kDefaultStageTimeout,
.timeoutDetails = [] {
return WalletRefreshSettleDetails(
u"wallet-ready-self-test-recovery"_q);
},
});
runner->add({
.name = u"wallet readiness self-test: refusal text"_q,
.run = [] {
const auto zeroed = ReadWalletRefresh({
.atMs = crl::now(),
.stateAtMs = 0,
});
NoteVerdict(u"refusal text"_q, zeroed);
Check(
zeroed.state == WalletRefreshState::Unstamped,
u"a reading whose stamps were never written is refused "
"as unstamped, never reported as ready"_q,
zeroed.observation);
const auto named = zeroed.observation.contains(u"at="_q)
&& zeroed.observation.contains(u"stateAgeMs="_q)
&& zeroed.observation.contains(u"windowMs="_q);
Check(
named,
u"the refusal text names the last observed values and "
"the window it judged them against"_q,
zeroed.observation);
const auto details = WalletRefreshSettleDetails(
u"self-test-unstarted"_q);
const auto explained = details.contains(
u"self-test-unstarted"_q)
&& details.contains(u"not started"_q);
Check(
explained,
u"an unstarted settle explains itself by name instead "
"of reading as a bare not ready"_q,
details);
const auto atMs = kWalletRefreshWindow * 4;
const auto staleAtMs = atMs - kWalletRefreshWindow * 2;
const auto stale = ReadWalletRefresh({
.atMs = atMs,
.stateAtMs = staleAtMs,
});
NoteVerdict(u"stale"_q, stale);
Check(
stale.state == WalletRefreshState::Stale,
u"a stamp that was written but fell outside the window "
"is refused as stale, never reported as ready"_q,
stale.observation);
const auto edgeAtMs = atMs - kWalletRefreshWindow;
const auto edge = ReadWalletRefresh({
.atMs = atMs,
.stateAtMs = edgeAtMs,
});
NoteVerdict(u"window edge"_q, edge);
Check(
edge.state == WalletRefreshState::Ready,
u"a stamp exactly the window old is still ready, so "
"stale begins past the window and not at it"_q,
edge.observation);
const auto unfilled = ReadWalletRefresh(
WalletFreshnessReading());
NoteVerdict(u"unfilled"_q, unfilled);
Check(
unfilled.state == WalletRefreshState::Unreadable,
u"a reading the caller never filled in is refused as "
"unreadable, never treated as fresh"_q,
unfilled.observation);
},
});
runner->add({
.name = u"wallet readiness self-test: teardown"_q,
.run = [=] {
// This releases the only session-owned state the self-test
// took. A timed-out stage or the watchdog skips every stage
// after it, so a run that ends early leaves the polling
// count raised for the rest of that process.
if (const auto wallet = state->wallet) {
wallet->stopPolling();
Note(u"wallet readiness self-test: pollingRequested=%1"_q
.arg(wallet->pollingRequested() ? 1 : 0));
}
},
});
}
} // namespace Test
#endif // _DEBUG