From 530b7feaa5b7bf148a04c89dc02ca882c8497db8 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 3 Nov 2026 11:45:43 +0100 Subject: [PATCH] test(app): add headless ViewModel, CLI and localization tests ViewModels run on Avalonia.Headless: wizard flows, password and file-lock handling, seed and private-key reveal gating, contacts, settings, sync and send against the fake server. The CLI entry point is invoked in-process end to end. Localization checks cover the 7 languages, placeholders and keys used in sources. Internal seams (UpdateCheck, ProfileOverride) keep the tests offline. --- src/App/PalladiumWallet.App.csproj | 4 + .../ViewModels/MainWindowViewModel.Update.cs | 6 +- src/App/ViewModels/MainWindowViewModel.cs | 5 +- tests/PalladiumWallet.Tests/App/CliTests.cs | 201 ++++++++++ .../App/HeadlessSetup.cs | 29 ++ .../App/LocalizationTests.cs | 88 +++++ .../App/MainWindowViewModelSecurityTests.cs | 212 ++++++++++ .../App/MainWindowViewModelTests.cs | 361 ++++++++++++++++++ tests/PalladiumWallet.Tests/App/VmTestBase.cs | 114 ++++++ .../Net/LiveServerTests.cs | 2 +- .../PalladiumWallet.Tests.csproj | 5 +- 11 files changed, 1023 insertions(+), 4 deletions(-) create mode 100644 tests/PalladiumWallet.Tests/App/CliTests.cs create mode 100644 tests/PalladiumWallet.Tests/App/HeadlessSetup.cs create mode 100644 tests/PalladiumWallet.Tests/App/LocalizationTests.cs create mode 100644 tests/PalladiumWallet.Tests/App/MainWindowViewModelSecurityTests.cs create mode 100644 tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs create mode 100644 tests/PalladiumWallet.Tests/App/VmTestBase.cs diff --git a/src/App/PalladiumWallet.App.csproj b/src/App/PalladiumWallet.App.csproj index beee6a0..e624752 100644 --- a/src/App/PalladiumWallet.App.csproj +++ b/src/App/PalladiumWallet.App.csproj @@ -27,4 +27,8 @@ + + + + diff --git a/src/App/ViewModels/MainWindowViewModel.Update.cs b/src/App/ViewModels/MainWindowViewModel.Update.cs index 3a63ae1..ab46efb 100644 --- a/src/App/ViewModels/MainWindowViewModel.Update.cs +++ b/src/App/ViewModels/MainWindowViewModel.Update.cs @@ -20,13 +20,17 @@ public partial class MainWindowViewModel [RelayCommand] private void CloseUpdateAvailable() => IsUpdateAvailableOpen = false; + /// Test seam: replaces the real GitHub check so tests never touch the network. + internal static System.Func> UpdateCheck { get; set; } = + version => UpdateChecker.CheckAsync(version); + /// /// Fire-and-forget check run once at startup. Best-effort: any failure or an /// up-to-date app silently does nothing (see ). /// private async Task CheckForUpdatesAsync() { - var latest = await UpdateChecker.CheckAsync(AppVersion); + var latest = await UpdateCheck(AppVersion); if (latest is null) return; UpdateReleaseUrl = latest.HtmlUrl; diff --git a/src/App/ViewModels/MainWindowViewModel.cs b/src/App/ViewModels/MainWindowViewModel.cs index 9be6aeb..8f6d80c 100644 --- a/src/App/ViewModels/MainWindowViewModel.cs +++ b/src/App/ViewModels/MainWindowViewModel.cs @@ -130,7 +130,10 @@ public partial class MainWindowViewModel : ViewModelBase // ---- helpers ---- private NetKind Net => NetKind.Mainnet; - private ChainProfile Profile => ChainProfiles.For(Net); + /// Test seam: lets tests drop the real bootstrap servers so nothing reaches the internet. + internal static ChainProfile? ProfileOverride { get; set; } + + private ChainProfile Profile => ProfileOverride ?? ChainProfiles.For(Net); private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net)); private string Fmt(long sats, bool withLabel = true) => diff --git a/tests/PalladiumWallet.Tests/App/CliTests.cs b/tests/PalladiumWallet.Tests/App/CliTests.cs new file mode 100644 index 0000000..27fc2cd --- /dev/null +++ b/tests/PalladiumWallet.Tests/App/CliTests.cs @@ -0,0 +1,201 @@ +using System.Reflection; +using NBitcoin; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Spv; +using PalladiumWallet.Core.Storage; +using PalladiumWallet.Tests.Net; + +namespace PalladiumWallet.Tests.App; + +/// +/// End-to-end tests of the CLI: the real entry point is invoked in-process with a captured +/// console. State is sandboxed: wallet files live in a temp folder (--file) and the data +/// root (server list, certificate pins) is redirected to it. +/// +[Collection("AppPaths")] // Console.SetOut and AppPaths are process-wide +public sealed class CliTests : IDisposable +{ + private const string Mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"plm-cli-{Guid.NewGuid()}"); + private readonly string? _savedOverride = AppPaths.OverrideDataRoot; + + public CliTests() + { + Directory.CreateDirectory(_dir); + AppPaths.OverrideDataRoot = _dir; + } + + public void Dispose() + { + AppPaths.OverrideDataRoot = _savedOverride; + try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ } + } + + private string WalletFile(string name = "w.json") => Path.Combine(_dir, name); + + private static async Task<(int Exit, string Out, string Err)> Cli(params string[] args) + { + var entry = Assembly.Load("PalladiumWallet.Cli").EntryPoint + ?? throw new InvalidOperationException("CLI has no entry point."); + var (oldOut, oldErr) = (Console.Out, Console.Error); + var (o, e) = (new StringWriter(), new StringWriter()); + Console.SetOut(o); Console.SetError(e); + try + { + // The top-level-statements entry point is a synchronous Main(string[]) → int wrapper. + var exit = await Task.Run(() => (int)entry.Invoke(null, [args])!); + return (exit, o.ToString(), e.ToString()); + } + finally { Console.SetOut(oldOut); Console.SetError(oldErr); } + } + + [Fact] + public async Task NoArguments_PrintsUsage_AndFails() + { + var (exit, output, _) = await Cli(); + Assert.Equal(1, exit); + Assert.Contains("PalladiumWallet CLI", output); + } + + [Theory] + [InlineData("12", 12)] + [InlineData("24", 24)] + public async Task NewSeed_GeneratesAValidMnemonicOfTheRequestedLength(string words, int count) + { + var (exit, output, _) = await Cli("newseed", "--words", words); + Assert.Equal(0, exit); + Assert.True(Bip39.TryParse(output.Trim(), out var m)); + Assert.Equal(count, m!.Words.Length); + } + + [Fact] + public async Task Addresses_MatchesTheCoreDerivation() + { + var (exit, output, _) = await Cli("addresses", Mnemonic, "--net", "regtest", "--count", "3"); + Assert.Equal(0, exit); + Assert.True(Bip39.TryParse(Mnemonic, out var m)); + var account = HdAccount.FromMnemonic(m!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest); + for (var i = 0; i < 3; i++) + Assert.Contains(account.GetReceiveAddress(i).ToString(), output); + Assert.Contains(account.ToSlip132(), output); + } + + [Fact] + public async Task Restore_WithAnInvalidMnemonic_FailsWithoutWritingAFile() + { + var (exit, _, err) = await Cli("restore", "abandon abandon abandon", "--file", WalletFile()); + Assert.Equal(1, exit); + Assert.Contains("Invalid mnemonic", err); + Assert.False(File.Exists(WalletFile())); + } + + [Fact] + public async Task Restore_Encrypts_AndInfoNeedsThePassword() + { + var (exit, _, _) = await Cli("restore", Mnemonic, "--net", "regtest", "--password", "pw", "--file", WalletFile()); + Assert.Equal(0, exit); + Assert.True(EncryptedFile.IsEncrypted(File.ReadAllText(WalletFile()))); + Assert.DoesNotContain("abandon", File.ReadAllText(WalletFile())); + + var (okExit, info, _) = await Cli("info", "--net", "regtest", "--password", "pw", "--file", WalletFile()); + Assert.Equal(0, okExit); + Assert.Contains("watch-only: False", info); + + var (badExit, _, err) = await Cli("info", "--net", "regtest", "--password", "wrong", "--file", WalletFile()); + Assert.Equal(1, badExit); + Assert.Contains("Error:", err); + } + + [Fact] + public async Task Create_ShowsTheMnemonicOnce_AndWritesTheWallet() + { + var (exit, output, _) = await Cli("create", "--net", "regtest", "--file", WalletFile()); + Assert.Equal(0, exit); + Assert.Contains("New mnemonic", output); + Assert.True(File.Exists(WalletFile())); + } + + [Fact] + public async Task RestoreXpub_AndRestoreAddress_ProduceWatchOnlyWallets() + { + Assert.True(Bip39.TryParse(Mnemonic, out var m)); + var account = HdAccount.FromMnemonic(m!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest); + + var (e1, _, _) = await Cli("restore-xpub", account.ToSlip132(), "--net", "regtest", "--file", WalletFile("x.json")); + Assert.Equal(0, e1); + Assert.Contains("watch-only: True", (await Cli("info", "--net", "regtest", "--file", WalletFile("x.json"))).Out); + + var (e2, _, _) = await Cli("restore-address", account.GetReceiveAddress(0).ToString(), "--net", "regtest", "--file", WalletFile("a.json")); + Assert.Equal(0, e2); + Assert.Contains("watch-only: True", (await Cli("info", "--net", "regtest", "--file", WalletFile("a.json"))).Out); + + var (e3, _, err3) = await Cli("restore-xpub", "garbage", "--net", "regtest", "--file", WalletFile("bad.json")); + Assert.Equal(1, e3); + Assert.Contains("not recognised", err3); + } + + [Fact] + public async Task Send_BeforeAnySync_AsksToSyncFirst() + { + await Cli("restore", Mnemonic, "--net", "regtest", "--file", WalletFile()); + var (exit, _, err) = await Cli("send", "--to", "x", "--all", "--net", "regtest", "--file", WalletFile()); + Assert.Equal(1, exit); + Assert.Contains("run 'sync' first", err); + } + + [Fact] + public async Task Sync_ThenSend_AgainstAFakeServer_ProducesASignedTransaction() + { + var net = PalladiumNetworks.Regtest; + Assert.True(Bip39.TryParse(Mnemonic, out var mnemonic)); + var account = HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest); + var funding = net.CreateTransaction(); + funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0))); + funding.Outputs.Add(Money.Satoshis(10_000_000), account.GetReceiveAddress(0)); + var txid = funding.GetHash(); + var scripthash = Scripthash.FromAddress(account.GetReceiveAddress(0)); + + string Header(uint256 root, int h) + { + var header = net.Consensus.ConsensusFactory.CreateBlockHeader(); + header.HashPrevBlock = uint256.Zero; + header.HashMerkleRoot = root; + header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + h); + header.Bits = new Target(0x1d00ffffu); + header.Nonce = (uint)h; + return Convert.ToHexString(header.ToBytes()).ToLowerInvariant(); + } + + await using var server = new FakeElectrumServer(); + server.Handle("blockchain.headers.subscribe", _ => new { height = 300, hex = Header(uint256.One, 300) }); + server.Handle("blockchain.scripthash.subscribe", _ => null); + server.Handle("blockchain.scripthash.get_history", p => + p[0].GetString() == scripthash ? new[] { new { tx_hash = txid.ToString(), height = 100 } } : Array.Empty()); + server.Handle("blockchain.transaction.get", _ => funding.ToHex()); + server.Handle("blockchain.transaction.get_merkle", _ => new { block_height = 100, pos = 0, merkle = Array.Empty() }); + server.Handle("blockchain.block.header", _ => Header(txid, 100)); + + await Cli("restore", Mnemonic, "--net", "regtest", "--file", WalletFile()); + var serverArg = $"{server.Host}:{server.Port}"; + + var (syncExit, syncOut, _) = await Cli("sync", "--server", serverArg, "--net", "regtest", "--file", WalletFile()); + Assert.Equal(0, syncExit); + Assert.Contains("0.1", syncOut); // 10,000,000 sat + Assert.Contains(txid.ToString(), syncOut); + Assert.DoesNotContain("(unverified)", syncOut); + + var dest = account.GetReceiveAddress(9).ToString(); + var (sendExit, sendOut, _) = await Cli("send", "--to", dest, "--amount", "0.05", "--feerate", "2", + "--server", serverArg, "--net", "regtest", "--file", WalletFile()); + Assert.Equal(0, sendExit); + Assert.Contains("NOT broadcast", sendOut); + var hex = sendOut.Split('\n', StringSplitOptions.TrimEntries).Last(l => l.Length > 100 && l.All(Uri.IsHexDigit)); + var tx = Transaction.Parse(hex, net); + Assert.Contains(tx.Outputs, o => o.Value == Money.Satoshis(5_000_000) && o.ScriptPubKey == account.GetReceiveAddress(9).ScriptPubKey); + Assert.Equal(txid, tx.Inputs[0].PrevOut.Hash); + Assert.Equal(0, server.CallCount("blockchain.transaction.broadcast")); // nothing broadcast without --broadcast + } +} diff --git a/tests/PalladiumWallet.Tests/App/HeadlessSetup.cs b/tests/PalladiumWallet.Tests/App/HeadlessSetup.cs new file mode 100644 index 0000000..628979f --- /dev/null +++ b/tests/PalladiumWallet.Tests/App/HeadlessSetup.cs @@ -0,0 +1,29 @@ +using Avalonia; +using Avalonia.Headless; +using PalladiumWallet.Tests.App; + +// Boots the real Avalonia Application headlessly (no window, no GPU) so ViewModels that use +// the dispatcher (DispatcherTimer, UIThread.Post) can be tested. The xunit-v3-only helper +// package (Avalonia.Headless.XUnit) is deliberately not used: this suite is on xunit 2. +[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))] + +namespace PalladiumWallet.Tests.App; + +public class TestAppBuilder +{ + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UseHeadless(new AvaloniaHeadlessPlatformOptions()); +} + +/// One shared headless Avalonia session; tests run their body on its UI thread. +internal static class UiSession +{ + private static readonly Lazy Session = + new(() => HeadlessUnitTestSession.StartNew(typeof(TestAppBuilder))); + + public static Task Run(Func body) => Session.Value.Dispatch(body, CancellationToken.None); + + public static Task Run(Action body) => + Session.Value.Dispatch(() => { body(); return Task.CompletedTask; }, CancellationToken.None); +} diff --git a/tests/PalladiumWallet.Tests/App/LocalizationTests.cs b/tests/PalladiumWallet.Tests/App/LocalizationTests.cs new file mode 100644 index 0000000..cde043c --- /dev/null +++ b/tests/PalladiumWallet.Tests/App/LocalizationTests.cs @@ -0,0 +1,88 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using PalladiumWallet.App.Localization; + +namespace PalladiumWallet.Tests.App; + +/// +/// Consistency of the in-code translation table: every key must have a non-empty value for +/// each of the 7 languages, format placeholders must match across languages, and every key +/// referenced from XAML or C# must exist (a missing key silently renders as the raw key). +/// +[Collection("AppPaths")] // shares the serialized collection for process-wide statics (Loc.Instance, AppPaths) +public partial class LocalizationTests +{ + private static Dictionary Table() => + (Dictionary)typeof(Loc) + .GetField("Strings", BindingFlags.NonPublic | BindingFlags.Static)!.GetValue(null)!; + + [Fact] + public void EveryKey_HasOneNonEmptyValuePerLanguage() + { + var bad = Table() + .Where(kv => kv.Value.Length != Loc.Languages.Length || kv.Value.Any(string.IsNullOrWhiteSpace)) + .Select(kv => kv.Key).ToList(); + Assert.True(bad.Count == 0, "Incomplete translations: " + string.Join(", ", bad)); + } + + [Fact] + public void LanguageNames_MatchLanguages() => + Assert.Equal(Loc.Languages.Length, Loc.LanguageNames.Length); + + [Fact] + public void FormatPlaceholders_AreIdenticalAcrossLanguages() + { + var bad = new List(); + foreach (var (key, values) in Table()) + { + var sets = values.Select(v => string.Join(",", Placeholder().Matches(v).Select(m => m.Value).Distinct().Order())).ToList(); + if (sets.Distinct().Count() > 1) bad.Add($"{key}: {string.Join(" | ", sets)}"); + } + Assert.True(bad.Count == 0, "Placeholder mismatch: " + string.Join("; ", bad)); + } + + [Fact] + public void Indexer_ReturnsTranslationPerLanguage_AndFallsBackToKey() + { + try + { + var table = Table(); + var key = table.Keys.First(); + for (var i = 0; i < Loc.Languages.Length; i++) + Assert.Equal(table[key][i], Loc.SwitchTo(Loc.Languages[i])[key]); + Assert.Equal("no.such.key", Loc.Tr("no.such.key")); + Assert.Equal("en", Loc.SwitchTo("xx").Language); // unknown language → English + } + finally { Loc.SwitchTo("en"); } + } + + [Fact] + public void KeysUsedInSources_ExistInTable() + { + var root = RepoRoot(); + if (root is null) return; // running from a packaged copy without sources + var keys = Table().Keys.ToHashSet(); + var missing = new SortedSet(); + foreach (var file in Directory.EnumerateFiles(Path.Combine(root, "src", "App"), "*.*", SearchOption.AllDirectories) + .Where(f => (f.EndsWith(".axaml") || f.EndsWith(".cs")) && !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") + && !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") + && Path.GetFileName(f) != "Loc.cs")) // Loc.cs only mentions the syntax in comments + { + var text = File.ReadAllText(file); + foreach (Match m in XamlKey().Matches(text)) if (!keys.Contains(m.Groups[1].Value)) missing.Add(m.Groups[1].Value); + foreach (Match m in CodeKey().Matches(text)) if (!keys.Contains(m.Groups[1].Value)) missing.Add(m.Groups[1].Value); + } + Assert.True(missing.Count == 0, "Keys used but not defined: " + string.Join(", ", missing)); + } + + private static string? RepoRoot() + { + for (var d = new DirectoryInfo(AppContext.BaseDirectory); d is not null; d = d.Parent) + if (File.Exists(Path.Combine(d.FullName, "PalladiumWallet.sln"))) return d.FullName; + return null; + } + + [GeneratedRegex(@"\{\d+(?::[^}]*)?\}")] private static partial Regex Placeholder(); + [GeneratedRegex(@"Loc\[([A-Za-z0-9_.]+)\]")] private static partial Regex XamlKey(); + [GeneratedRegex(@"Loc\.Tr\(""([A-Za-z0-9_.]+)""\)")] private static partial Regex CodeKey(); +} diff --git a/tests/PalladiumWallet.Tests/App/MainWindowViewModelSecurityTests.cs b/tests/PalladiumWallet.Tests/App/MainWindowViewModelSecurityTests.cs new file mode 100644 index 0000000..6fe3e17 --- /dev/null +++ b/tests/PalladiumWallet.Tests/App/MainWindowViewModelSecurityTests.cs @@ -0,0 +1,212 @@ +using PalladiumWallet.App.Localization; +using PalladiumWallet.App.ViewModels; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Storage; +using PalladiumWallet.Core.Wallet; + +namespace PalladiumWallet.Tests.App; + +/// +/// Secret-handling gates in the UI layer: the seed and private keys must stay hidden until the +/// wallet password is supplied, and must be hidden again on close. Also settings, wallet info +/// and the remaining wizard flows. +/// +[Collection("AppPaths")] +public sealed class MainWindowViewModelSecurityTests : VmTestBase +{ + [Fact] + public Task Seed_StaysHidden_UntilTheRightPasswordIsGiven() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, "pw", "s1"); + vm.OpenWalletInfoCommand.Execute(null); + Assert.True(vm.WalletInfoHasSeed); + Assert.True(vm.WalletInfoSeedNeedsPassword); + Assert.Equal("", vm.WalletInfoSeedText); + + vm.WalletInfoSeedPasswordInput = "nope"; + vm.RevealSeedCommand.Execute(null); + Assert.False(vm.IsWalletInfoSeedRevealed); + Assert.Equal(Loc.Tr("msg.wrongpassword"), vm.WalletInfoSeedError); + Assert.Equal("", vm.WalletInfoSeedText); + + vm.WalletInfoSeedPasswordInput = "pw"; + vm.RevealSeedCommand.Execute(null); + Assert.True(vm.IsWalletInfoSeedRevealed); + Assert.Equal(Mnemonic, vm.WalletInfoSeedText); + + vm.CloseWalletInfoCommand.Execute(null); // closing wipes the revealed state + Assert.False(vm.IsWalletInfoSeedRevealed); + Assert.Equal("", vm.WalletInfoSeedText); + }); + + [Fact] + public Task WatchOnlyWallet_HasNoSeedToReveal() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + var address = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet) + .GetReceiveAddress(0).ToString(); + vm.WizardStartImportAddressCommand.Execute(null); + vm.ImportAddressInput = address; + vm.WizardNextFromImportAddressCommand.Execute(null); + vm.EncryptWallet = false; + vm.CreateOrRestoreCommand.Execute(null); + vm.OpenWalletInfoCommand.Execute(null); + + Assert.False(vm.WalletInfoHasSeed); + vm.RevealSeedCommand.Execute(null); + Assert.False(vm.IsWalletInfoSeedRevealed); + }); + + [Fact] + public Task PrivateKey_IsRevealedOnlyAfterThePassword_AndOnlyWhenTheWalletHasKeys() => UiSession.Run(async () => + { + var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + WireFundedChain(account.GetReceiveAddress(0), 1_000_000); + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, "pw", "pk"); + await Eventually(() => !vm.IsSyncing && vm.Addresses.Count > 0); + + var row = vm.Addresses.First(a => !a.IsChange && a.Indice == 0); + Assert.True(row.HasPrivKey); + vm.ShowAddressInfo(row); + + vm.RequestPrivKeyRevealCommand.Execute(null); + Assert.True(vm.IsPrivKeyPromptOpen); + Assert.False(vm.IsAddressPrivKeyRevealed); + + vm.PrivKeyPromptPassword = "wrong"; + vm.ConfirmPrivKeyPasswordCommand.Execute(null); + Assert.False(vm.IsAddressPrivKeyRevealed); + Assert.Equal(Loc.Tr("msg.wrongpassword"), vm.PrivKeyPromptError); + + vm.PrivKeyPromptPassword = "pw"; + vm.ConfirmPrivKeyPasswordCommand.Execute(null); + Assert.True(vm.IsAddressPrivKeyRevealed); + Assert.False(vm.IsPrivKeyPromptOpen); + + vm.CloseAddressInfoCommand.Execute(null); // closing the sheet re-hides everything + vm.ShowAddressInfo(row); + Assert.False(vm.IsAddressPrivKeyRevealed); + }); + + [Fact] + public Task WatchOnlyAddressRows_NeverCarryAPrivateKey() => UiSession.Run(async () => + { + var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + WireFundedChain(account.GetReceiveAddress(0), 1_000_000); + var vm = new MainWindowViewModel(); + vm.WizardStartImportXkeyCommand.Execute(null); + vm.ImportXkeyInput = account.ToSlip132(); + vm.WizardNextFromImportXkeyCommand.Execute(null); + vm.EncryptWallet = false; + vm.CreateOrRestoreCommand.Execute(null); + await Eventually(() => !vm.IsSyncing && vm.Addresses.Count > 0); + + Assert.All(vm.Addresses, a => Assert.False(a.HasPrivKey)); + vm.ShowAddressInfo(vm.Addresses[0]); + vm.RequestPrivKeyRevealCommand.Execute(null); + Assert.False(vm.IsAddressPrivKeyRevealed); + Assert.False(vm.IsPrivKeyPromptOpen); + }); + + [Fact] + public Task Units_AndLanguages_AreAppliedAndPersisted() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + + vm.SetUnitCommand.Execute("sat"); + Assert.True(vm.IsUnitSat); + Assert.Equal("sat", vm.UnitLabel); + Assert.Equal("sat", AppConfig.Load(AppPaths.ConfigPath()).Unit); + + foreach (var lang in Loc.Languages) + { + vm.SetLanguageCommand.Execute(lang); + Assert.Equal(lang, vm.Loc.Language); + } + Assert.True(vm.IsLangZh); + Assert.False(vm.IsLangIt); + }); + + [Fact] + public Task SettingsFromDisk_AreRestoredByANewViewModel() => UiSession.Run(() => + { + new MainWindowViewModel().SetLanguageCommand.Execute("de"); + + var vm2 = new MainWindowViewModel(); + + Assert.Equal("de", vm2.Loc.Language); + Assert.True(vm2.IsLangDe); + }); + + [Fact] + public Task Wizard_BackNavigation_ReturnsToThePreviousStep() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + vm.WizardStartRestoreCommand.Execute(null); + vm.MnemonicInput = Mnemonic; + vm.WizardNextFromWordsCommand.Execute(null); + Assert.True(vm.IsStepPassphrase); + + vm.WizardBackCommand.Execute(null); + Assert.True(vm.IsStepWords); + vm.WizardBackCommand.Execute(null); + Assert.True(vm.IsStepStart); + }); + + [Fact] + public Task ImportWif_CreatesAnImportedKeyWallet_AndRejectsGarbage() => UiSession.Run(() => + { + var key = new NBitcoin.Key(); + var wif = key.GetWif(PalladiumNetworks.Mainnet).ToString(); + var vm = new MainWindowViewModel(); + + vm.WizardStartImportWifCommand.Execute(null); + vm.ImportWifInput = "definitely-not-a-wif"; + vm.WizardNextFromImportWifCommand.Execute(null); + Assert.True(vm.IsStepImportWif); + + vm.ImportWifInput = wif; + vm.WizardNextFromImportWifCommand.Execute(null); + vm.EncryptWallet = true; + vm.PasswordInput = vm.ConfirmPasswordInput = "pw"; + vm.CreateOrRestoreCommand.Execute(null); + + Assert.True(vm.IsWalletOpen); + Assert.Contains("imported", vm.NetworkInfo); + var stored = WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)), "pw"); + Assert.Equal(wif, Assert.Single(stored.WifKeys!)); + }); + + [Fact] + public Task ImportXprv_CreatesASpendableWallet() => UiSession.Run(() => + { + var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + var vm = new MainWindowViewModel(); + vm.WizardStartImportXkeyCommand.Execute(null); + vm.ImportXkeyInput = account.ToSlip132Private(); + vm.WizardNextFromImportXkeyCommand.Execute(null); + vm.EncryptWallet = false; + vm.CreateOrRestoreCommand.Execute(null); + + Assert.True(vm.IsWalletOpen); + Assert.False(vm.IsWatchOnlyAccount); + }); + + [Fact] + public Task ClosingTheWallet_ReleasesTheLock_SoItCanBeReopened() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, "pw", "relock"); + var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)); + Assert.Null(WalletLock.TryAcquire(path)); // held while open + + vm.CloseWalletCommand.Execute(null); + + using var l = WalletLock.TryAcquire(path); // released on close + Assert.NotNull(l); + Assert.False(vm.IsWalletOpen); + }); +} diff --git a/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs b/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs new file mode 100644 index 0000000..461c324 --- /dev/null +++ b/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs @@ -0,0 +1,361 @@ +using Avalonia.Threading; +using NBitcoin; +using PalladiumWallet.App.Localization; +using PalladiumWallet.App.ViewModels; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Net; +using PalladiumWallet.Core.Spv; +using PalladiumWallet.Core.Storage; +using PalladiumWallet.Core.Wallet; +using PalladiumWallet.Tests.Net; + +namespace PalladiumWallet.Tests.App; + +/// +/// Headless tests of the single application ViewModel. Everything is sandboxed: the data +/// root is a temp folder, the saved server points at an in-process FakeElectrumServer (so the +/// auto-connect never reaches a real server) and the GitHub update check is stubbed. +/// +[Collection("AppPaths")] +public sealed class MainWindowViewModelTests : VmTestBase +{ + // ---- startup, overlays, settings ---- + + [Fact] + public Task Startup_ShowsTheStartStep_WithNoWalletOpen() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + + Assert.True(vm.IsStepStart); + Assert.False(vm.IsWalletOpen); + Assert.True(vm.IsSetupVisible); + Assert.StartsWith("Palladium Wallet", vm.WindowTitle); + Assert.True(vm.IsDesktop); + }); + + [Fact] + public Task SelectTab_ChangesTheSelectedIndex() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + vm.SelectTabCommand.Execute("3"); + Assert.Equal(3, vm.SelectedTabIndex); + }); + + [Fact] + public Task SettingsAndHelpOverlays_OpenAndClose() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + vm.OpenSettingsCommand.Execute(null); + Assert.True(vm.IsSettingsOpen); + vm.CloseSettingsCommand.Execute(null); + Assert.False(vm.IsSettingsOpen); + vm.OpenHelpCommand.Execute(null); + Assert.True(vm.IsHelpOpen); + vm.CloseHelpCommand.Execute(null); + Assert.False(vm.IsHelpOpen); + }); + + [Fact] + public Task SetLanguage_SwitchesTheLocInstance_AndPersistsIt() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + var before = vm.Loc; + + vm.SetLanguageCommand.Execute("it"); + + Assert.NotSame(before, vm.Loc); // new instance → bindings re-evaluate + Assert.Equal("it", vm.Loc.Language); + Assert.Equal("it", AppConfig.Load(AppPaths.ConfigPath()).Language); + }); + + [Fact] + public Task AvailableUpdate_OpensTheUpdateOverlay() => UiSession.Run(async () => + { + MainWindowViewModel.UpdateCheck = _ => Task.FromResult(new LatestRelease("v9.9.9", "https://example.test/r")); + var vm = new MainWindowViewModel(); + + await Eventually(() => vm.IsUpdateAvailableOpen); + + Assert.Equal("v9.9.9", vm.UpdateAvailableTag); + Assert.Equal("https://example.test/r", vm.UpdateReleaseUrl); + vm.CloseUpdateAvailableCommand.Execute(null); + Assert.False(vm.IsUpdateAvailableOpen); + }); + + [Fact] + public Task NoUpdate_LeavesTheOverlayClosed() => UiSession.Run(async () => + { + var vm = new MainWindowViewModel(); + await Task.Delay(100); + Dispatcher.UIThread.RunJobs(); + Assert.False(vm.IsUpdateAvailableOpen); + }); + + // ---- wizard ---- + + [Fact] + public Task Restore_WithInvalidMnemonic_StaysOnTheWordsStep_WithAnError() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + vm.WizardStartRestoreCommand.Execute(null); + vm.MnemonicInput = "abandon abandon abandon"; + + vm.WizardNextFromWordsCommand.Execute(null); + + Assert.True(vm.IsStepWords); + Assert.Equal(Loc.Tr("msg.words.invalid"), vm.StatusMessage); + }); + + [Fact] + public Task NewWallet_ConfirmingTheWrongSeed_IsRejected() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + vm.WizardStartNewCommand.Execute(null); + Assert.True(vm.IsStepShowSeed); + Assert.True(Bip39.TryParse(vm.MnemonicInput, out _)); + vm.WizardNextFromShowSeedCommand.Execute(null); + vm.ConfirmMnemonicInput = Mnemonic; // not the displayed one + + vm.WizardNextFromConfirmSeedCommand.Execute(null); + + Assert.True(vm.IsStepConfirmSeed); + Assert.Equal(Loc.Tr("msg.seed.mismatch"), vm.StatusMessage); + }); + + [Fact] + public Task NewWallet_ConfirmingTheRightSeed_AdvancesToThePassphraseStep() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + vm.WizardStartNewCommand.Execute(null); + vm.WizardNextFromShowSeedCommand.Execute(null); + vm.ConfirmMnemonicInput = vm.MnemonicInput.ToUpperInvariant(); + + vm.WizardNextFromConfirmSeedCommand.Execute(null); + + Assert.True(vm.IsStepPassphrase); + }); + + [Fact] + public Task Password_Mismatch_OrEmpty_DoesNotCreateAnyFile() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + vm.WizardStartRestoreCommand.Execute(null); + vm.MnemonicInput = Mnemonic; + vm.WizardNextFromWordsCommand.Execute(null); + vm.WizardNextFromPassphraseCommand.Execute(null); + vm.WizardNextFromScriptTypeCommand.Execute(null); + + vm.PasswordInput = vm.ConfirmPasswordInput = ""; + vm.CreateOrRestoreCommand.Execute(null); + Assert.Equal(Loc.Tr("msg.password.required"), vm.StatusMessage); + + vm.PasswordInput = "one"; vm.ConfirmPasswordInput = "two"; + vm.CreateOrRestoreCommand.Execute(null); + Assert.Equal(Loc.Tr("msg.password.mismatch"), vm.StatusMessage); + + Assert.False(vm.IsWalletOpen); + Assert.Empty(AppPaths.WalletFiles(NetKind.Mainnet)); + }); + + [Fact] + public Task Restore_WithPassword_CreatesAnEncryptedWallet_AndOpensIt() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + + RestoreFlow(vm, Mnemonic, "s3cret", "main"); + + Assert.True(vm.IsWalletOpen); + var file = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)); + var content = File.ReadAllText(file); + Assert.True(EncryptedFile.IsEncrypted(content)); + Assert.DoesNotContain("abandon", content); // seed never in plaintext on disk + Assert.Contains("NativeSegwit", vm.NetworkInfo); + }); + + [Fact] + public Task Restore_WithoutEncryption_StoresPlainJson() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, password: null, name: "plain"); + + Assert.True(vm.IsWalletOpen); + Assert.False(EncryptedFile.IsEncrypted(File.ReadAllText(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet))))); + }); + + [Fact] + public Task Restore_OverAnExistingName_DoesNotOverwriteTheWallet() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, "pw", "dup"); + var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)); + var before = File.ReadAllBytes(path); + vm.CloseWalletCommand.Execute(null); + + RestoreFlow(vm, Mnemonic, "other", "dup"); + + Assert.Equal(Loc.Tr("msg.wallet.exists"), vm.StatusMessage); + Assert.Equal(before, File.ReadAllBytes(path)); + }); + + [Fact] + public Task ImportAddress_CreatesAWatchOnlyWallet() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + var address = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet) + .GetReceiveAddress(0).ToString(); + + vm.WizardStartImportAddressCommand.Execute(null); + vm.ImportAddressInput = address; + vm.WizardNextFromImportAddressCommand.Execute(null); + vm.EncryptWallet = false; + vm.CreateOrRestoreCommand.Execute(null); + + Assert.True(vm.IsWalletOpen); + Assert.True(vm.IsWatchOnlyAccount); + }); + + [Fact] + public Task ImportXpub_CreatesAWatchOnlyWallet_ButANonsenseKeyIsRejected() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + vm.WizardStartImportXkeyCommand.Execute(null); + vm.ImportXkeyInput = "not-an-extended-key"; + vm.WizardNextFromImportXkeyCommand.Execute(null); + Assert.True(vm.IsStepImportXkey); + Assert.Equal(Loc.Tr("msg.xkey.invalid"), vm.StatusMessage); + + vm.ImportXkeyInput = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet).ToSlip132(); + vm.WizardNextFromImportXkeyCommand.Execute(null); + vm.EncryptWallet = false; + vm.CreateOrRestoreCommand.Execute(null); + + Assert.True(vm.IsWalletOpen); + Assert.True(vm.IsWatchOnlyAccount); + }); + + + // ---- open existing ---- + + [Fact] + public Task OpenExisting_WithWrongPassword_ShowsAnError_AndStaysClosed() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, "right", "w1"); + vm.CloseWalletCommand.Execute(null); + + vm.WizardStartOpenCommand.Execute(null); + vm.PasswordInput = "wrong"; + vm.OpenExistingCommand.Execute(null); + + Assert.False(vm.IsWalletOpen); + Assert.Equal(Loc.Tr("msg.wrongpassword"), vm.StatusMessage); + }); + + [Fact] + public Task OpenExisting_WithTheRightPassword_OpensTheWallet() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, "right", "w1"); + vm.CloseWalletCommand.Execute(null); + + vm.WizardStartOpenCommand.Execute(null); + vm.PasswordInput = "right"; + vm.OpenExistingCommand.Execute(null); + + Assert.True(vm.IsWalletOpen); + }); + + [Fact] + public Task OpenExisting_WhileAnotherInstanceHoldsTheLock_IsRefused() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, "pw", "locked"); + var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)); + // The first VM still holds the exclusive lock: a second instance must not open it. + var second = new MainWindowViewModel(); + second.WizardStartOpenCommand.Execute(null); + second.PasswordInput = "pw"; + second.OpenExistingCommand.Execute(null); + + Assert.False(second.IsWalletOpen); + Assert.Equal(Loc.Tr("msg.wallet.locked"), second.StatusMessage); + Assert.True(File.Exists(path)); + }); + + // ---- contacts ---- + + [Fact] + public Task Contacts_AreAddedPersistedAndRemoved() => UiSession.Run(() => + { + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, null, "c"); + + vm.NewContactName = "Alice"; vm.NewContactAddress = "plm1qexample"; + vm.AddContactCommand.Execute(null); + vm.NewContactName = ""; vm.NewContactAddress = "ignored"; + vm.AddContactCommand.Execute(null); // blank name → ignored + + Assert.Single(vm.Contacts); + var stored = WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet))); + Assert.Equal("Alice", Assert.Single(stored.Contacts).Name); + + vm.SelectedContactInList = vm.Contacts[0]; + vm.RemoveSelectedContactCommand.Execute(null); + Assert.Empty(vm.Contacts); + Assert.Empty(WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet))).Contacts); + }); + + // ---- sync + send against the fake server ---- + + [Fact] + public Task OpeningAFundedWallet_SyncsAgainstTheServer_AndShowsTheVerifiedHistory() => UiSession.Run(async () => + { + var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + WireFundedChain(account.GetReceiveAddress(0), 2_500_000); + var vm = new MainWindowViewModel(); + + RestoreFlow(vm, Mnemonic, null, "funded"); + await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1); + + Assert.True(vm.History[0].Verified); + Assert.Contains("0.025", vm.BalanceText); + Assert.NotEmpty(vm.Addresses); + Assert.True(_server.CallCount("blockchain.transaction.get_merkle") >= 1); // Merkle proof was demanded + }); + + [Fact] + public Task Send_PreparesASignedTransaction_ForAFundedWallet_AndRejectsABadAmount() => UiSession.Run(async () => + { + var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet); + WireFundedChain(account.GetReceiveAddress(0), 50_000_000); + var vm = new MainWindowViewModel(); + RestoreFlow(vm, Mnemonic, null, "sender"); + await Eventually(() => !vm.IsSyncing && vm.History.Count == 1); + // Coins need MinConfirmations (tip 200, height 100 → 101 confirmations): spendable. + + vm.SendTo = account.GetReceiveAddress(7).ToString(); + vm.SendAmount = "not-a-number"; + await vm.PrepareSendCommand.ExecuteAsync(null); + Assert.Equal(Loc.Tr("msg.amount.invalid"), vm.SendPreview); + Assert.False(vm.HasPendingSend); + + vm.SendAmount = "0.1"; + await vm.PrepareSendCommand.ExecuteAsync(null); + Assert.True(vm.HasPendingSend, vm.SendPreview); + Assert.Contains("txid", vm.SendPreview); + + vm.SendAmount = "999"; + await vm.PrepareSendCommand.ExecuteAsync(null); + Assert.False(vm.HasPendingSend); + Assert.Contains(Loc.Tr("msg.error"), vm.SendPreview); + }); + + [Fact] + public Task Send_BeforeAnySync_AsksToSyncFirst() => UiSession.Run(async () => + { + var vm = new MainWindowViewModel(); + await vm.PrepareSendCommand.ExecuteAsync(null); + Assert.Equal(Loc.Tr("msg.send.sync.first"), vm.SendPreview); + }); +} diff --git a/tests/PalladiumWallet.Tests/App/VmTestBase.cs b/tests/PalladiumWallet.Tests/App/VmTestBase.cs new file mode 100644 index 0000000..9c131c2 --- /dev/null +++ b/tests/PalladiumWallet.Tests/App/VmTestBase.cs @@ -0,0 +1,114 @@ +using Avalonia.Threading; +using NBitcoin; +using PalladiumWallet.App.Localization; +using PalladiumWallet.App.ViewModels; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Crypto; +using PalladiumWallet.Core.Net; +using PalladiumWallet.Core.Spv; +using PalladiumWallet.Core.Storage; +using PalladiumWallet.Core.Wallet; +using PalladiumWallet.Tests.Net; + +namespace PalladiumWallet.Tests.App; + +/// +/// Headless tests of the single application ViewModel. Everything is sandboxed: the data +/// root is a temp folder, the saved server points at an in-process FakeElectrumServer (so the +/// auto-connect never reaches a real server) and the GitHub update check is stubbed. +/// +[Collection("AppPaths")] // static AppPaths / Loc / update-check seam +public abstract class VmTestBase : IDisposable +{ + protected const string Mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + private readonly string _root = Path.Combine(Path.GetTempPath(), $"plm-vm-{Guid.NewGuid()}"); + private readonly string? _savedOverride = AppPaths.OverrideDataRoot; + private readonly Func> _savedUpdateCheck = MainWindowViewModel.UpdateCheck; + protected readonly FakeElectrumServer _server = new(); + + protected VmTestBase() + { + AppPaths.OverrideDataRoot = _root; + MainWindowViewModel.UpdateCheck = _ => Task.FromResult(null); + // No bootstrap servers: the only reachable server is the in-process fake, so the + // VM's connect/reconnect loops can never touch a real indexing server. + MainWindowViewModel.ProfileOverride = ChainProfiles.Mainnet with { BootstrapServers = [] }; + new AppConfig { LastServerHost = _server.Host, LastServerPort = _server.Port, LastServerUseSsl = false } + .Save(AppPaths.ConfigPath()); + } + + public void Dispose() + { + MainWindowViewModel.UpdateCheck = _savedUpdateCheck; + // ProfileOverride is intentionally left in place: ViewModels created by a test keep their + // keep-alive timers running, and restoring the real profile would let them reconnect to + // the real bootstrap servers after the test is over. Nothing else in the suite uses it. + AppPaths.OverrideDataRoot = _savedOverride; + Loc.SwitchTo("en"); + _server.DisposeAsync().AsTask().GetAwaiter().GetResult(); + try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } + } + + protected static async Task Eventually(Func condition, int timeoutMs = 10_000) + { + for (var waited = 0; !condition(); waited += 20) + { + Assert.True(waited < timeoutMs, "condition not reached in time"); + Dispatcher.UIThread.RunJobs(); + await Task.Delay(20); + } + } + + protected static void RestoreFlow(MainWindowViewModel vm, string mnemonic, string? password, string? name = null) + { + vm.WizardStartRestoreCommand.Execute(null); + vm.MnemonicInput = mnemonic; + vm.WizardNextFromWordsCommand.Execute(null); + vm.WizardNextFromPassphraseCommand.Execute(null); + vm.WizardNextFromScriptTypeCommand.Execute(null); + vm.EncryptWallet = password is not null; + vm.PasswordInput = vm.ConfirmPasswordInput = password ?? ""; + vm.WalletNameInput = name ?? ""; + vm.CreateOrRestoreCommand.Execute(null); + } + + protected static Mnemonic ParseMnemonic() + { + Assert.True(Bip39.TryParse(Mnemonic, out var m)); + return m!; + } + + protected (Transaction Funding, string TxHex) WireFundedChain(BitcoinAddress to, long sats) + { + var net = PalladiumNetworks.Mainnet; + var tx = net.CreateTransaction(); + tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0))); + tx.Outputs.Add(Money.Satoshis(sats), to); + var txid = tx.GetHash(); + var scripthash = Scripthash.FromAddress(to); + + string Header(uint256 root, int h) + { + var header = net.Consensus.ConsensusFactory.CreateBlockHeader(); + header.HashPrevBlock = uint256.Zero; + header.HashMerkleRoot = root; + header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + h); + header.Bits = new Target(0x1d00ffffu); + header.Nonce = (uint)h; + return Convert.ToHexString(header.ToBytes()).ToLowerInvariant(); + } + + _server.Handle("blockchain.headers.subscribe", _ => new { height = 200, hex = Header(uint256.One, 200) }); + _server.Handle("blockchain.scripthash.subscribe", _ => null); + _server.Handle("blockchain.scripthash.get_history", p => + p[0].GetString() == scripthash ? new[] { new { tx_hash = txid.ToString(), height = 100 } } : Array.Empty()); + _server.Handle("blockchain.transaction.get", _ => tx.ToHex()); + _server.Handle("blockchain.transaction.get_merkle", + _ => new { block_height = 100, pos = 0, merkle = Array.Empty() }); + _server.Handle("blockchain.block.header", _ => Header(txid, 100)); + _server.Handle("blockchain.block.headers", _ => new { count = 0, hex = "" }); + return (tx, tx.ToHex()); + } +} diff --git a/tests/PalladiumWallet.Tests/Net/LiveServerTests.cs b/tests/PalladiumWallet.Tests/Net/LiveServerTests.cs index 2b6c0ed..9cd9949 100644 --- a/tests/PalladiumWallet.Tests/Net/LiveServerTests.cs +++ b/tests/PalladiumWallet.Tests/Net/LiveServerTests.cs @@ -13,7 +13,7 @@ namespace PalladiumWallet.Tests.Net; /// /// Example: PALLADIUM_TEST_SERVERS=1.2.3.4:50002 PALLADIUM_TEST_SSL=1 dotnet test --filter LiveServer /// -[Collection("Console")] // the probe writes to Console, which is process-global +[Collection("AppPaths")] // serialized with other tests touching process-wide state (Console, AppPaths) public class LiveServerTests { [Fact] diff --git a/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj b/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj index 8ba7e72..b115357 100644 --- a/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj +++ b/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj @@ -10,6 +10,7 @@ + @@ -24,7 +25,9 @@ - + + +