using QVPN.Core; internal static class NodeSelectionTests { private static readonly VpnNode First = Node("first"); private static readonly VpnNode Second = Node("second"); private static readonly VpnNode Third = Node("third"); private static readonly VpnNode[] Nodes = [First, Second, Third]; public static async Task RunAsync(Func test) { await test("Selected Zurich survives server and credential rotation in a reordered subscription", () => { var zurich = First with { Id = "zurich-old", Name = "Цюрих", Host = "203.0.113.10" }; var replacement = zurich with { Id = "zurich-new", Name = " ЦЮРИХ\t", Host = "203.0.113.20", Port = 8443, Uuid = "00000000-0000-4000-8000-000000000003", PublicKey = "rotated-fixture-key", ShortId = "1234" }; var amsterdam = Second with { Name = "Амстердам", Host = "203.0.113.30" }; VpnNode[] refreshed = [amsterdam, replacement]; var selected = NodeSelection.FindReplacement(zurich, refreshed); Check(selected == replacement); Check(NodeSelection.Choose(refreshed, selected!.Id, true, selected.Id, new HashSet(), new Dictionary { [amsterdam.Id] = 1 }) == replacement); // Preserving the location must still restart the engine when its transport changed. Check(!NodeSelection.ConnectionEquals(zurich, replacement)); }); await test("Replacement prefers exact ID before matching a location name", () => { var exact = First with { Name = "Updated presentation" }; var sameName = Second with { Name = First.Name }; Check(NodeSelection.FindReplacement(First, [sameName, exact]) == exact); }); await test("Saved transport survives ID migration among identical location names and endpoints", () => { var unchanged = First with { Id = "new-id" }; var alternateFlow = unchanged with { Id = "other-flow", Flow = "xtls-rprx-vision-udp443" }; var alternateFingerprint = unchanged with { Id = "other-fingerprint", Fingerprint = "firefox" }; Check(NodeSelection.FindReplacement(First, [alternateFlow, alternateFingerprint, unchanged]) == unchanged); }); await test("Unchanged subscription refresh retains failures and tries a fresh server", () => { var rejected = First with { Id = "new-id", Name = "Renamed" }; var fresh = Second with { Host = "203.0.113.2" }; var failed = NodeSelection.RemapFailedConnections([First, fresh], [rejected, fresh], new HashSet { First.Id }); Check(failed.SetEquals([rejected.Id])); Check(NodeSelection.Choose([rejected, fresh], rejected.Id, true, rejected.Id, failed, new Dictionary()) == fresh); }); await test("Changed credentials or transport allow retry after subscription refresh", () => { var rotated = First with { Id = "rotated", ShortId = "1234" }; var flowChanged = First with { Id = "flow", Flow = "xtls-rprx-vision-udp443" }; var fingerprintChanged = First with { Id = "fingerprint", Fingerprint = "firefox" }; var failed = NodeSelection.RemapFailedConnections([First], [rotated, flowChanged, fingerprintChanged], new HashSet { First.Id }); Check(failed.Count == 0); }); await test("Replacement normalizes Unicode and whitespace in a unique location name", () => { var old = First with { Name = " Zurich\u00a0\u00a0Cafe\u0301 " }; var replacement = Second with { Name = "zurich CAFÉ", Host = "203.0.113.20" }; Check(NodeSelection.FindReplacement(old, [Third, replacement]) == replacement); }); await test("A unique unchanged endpoint disambiguates duplicated location names", () => { var old = First with { Name = "Цюрих", Host = "203.0.113.10" }; var replacement = Second with { Name = old.Name, Host = old.Host, Port = old.Port }; var duplicateName = Third with { Name = old.Name, Host = "203.0.113.20" }; Check(NodeSelection.FindReplacement(old, [duplicateName, replacement]) == replacement); Check(NodeSelection.FindReplacement(old, [replacement with { Name = "Renamed location" }]) is not null); }); await test("Unmatched or ambiguous rotated locations have no guessed replacement", () => { var old = First with { Name = "Цюрих", Host = "203.0.113.10" }; var one = Second with { Name = old.Name, Host = "203.0.113.20" }; var two = Third with { Name = " ЦЮРИХ ", Host = "203.0.113.30" }; Check(NodeSelection.FindReplacement(old, [one, two]) is null); Check(NodeSelection.FindReplacement(old, [Second with { Name = "Амстердам", Host = "203.0.113.40" }]) is null); Check(NodeSelection.FindReplacement(old, [one with { Host = old.Host }, two with { Host = old.Host }]) is null); Check(NodeSelection.FindReplacement(old, []) is null); }); await test("Manual location remains selected after failure and a negative TCP probe", () => { var failed = new HashSet { Second.Id }; var latencies = new Dictionary { [First.Id] = 10, [Second.Id] = null }; Check(NodeSelection.Choose(Nodes, Second.Id, false, First.Id, failed, latencies) == Second); }); await test("Disabling failover preserves the working server when no location is selected", () => { Check(NodeSelection.Choose(Nodes, "missing", false, Second.Id, new HashSet { First.Id }, new Dictionary { [Third.Id] = 1 }) == Second); Check(NodeSelection.Choose(Nodes, "missing", false, "removed", new HashSet(), new Dictionary()) == First); }); await test("Automatic switching tries every fresh location before any failed location", () => { var failed = new HashSet { First.Id, Second.Id }; var latencies = new Dictionary { [First.Id] = 1, [Second.Id] = 2, [Third.Id] = 300 }; Check(NodeSelection.Choose(Nodes, First.Id, true, Second.Id, failed, latencies) == Third); failed.Add(Third.Id); Check(NodeSelection.Choose(Nodes, First.Id, true, Second.Id, failed, latencies) is null); failed.Clear(); Check(NodeSelection.Choose(Nodes, "", true, null, failed, latencies) == First); }); await test("Explicit location is attempted despite a negative TCP measurement", () => { Check(NodeSelection.Choose(Nodes, Second.Id, true, Third.Id, new HashSet(), new Dictionary { [First.Id] = 1, [Second.Id] = null, [Third.Id] = 2 }) == Second); }); await test("Last working location remains sticky across subscription order and latency changes", () => { var latencies = new Dictionary { [First.Id] = 1, [Second.Id] = 80, [Third.Id] = 2 }; Check(NodeSelection.Choose(Nodes, "", true, Second.Id, new HashSet(), latencies) == Second); Check(NodeSelection.Choose([Third, First, Second], "", true, Second.Id, new HashSet(), latencies) == Second); Check(NodeSelection.Choose(Nodes, "", true, Second.Id, new HashSet { Second.Id }, latencies) == First); }); await test("Fresh reachable locations rank by latency ahead of missing and negative measurements", () => { var latencies = new Dictionary { [First.Id] = null, [Second.Id] = 3000 }; Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet(), latencies) == Second); latencies[Third.Id] = 20; Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet(), latencies) == Third); }); await test("TCP timeout does not discard a verified server; failed VPN checks do", () => { Check(NodeSelection.Choose(Nodes, "", true, First.Id, new HashSet(), new Dictionary { [First.Id] = null, [Third.Id] = null }) == First); Check(NodeSelection.Choose(Nodes, "", true, First.Id, new HashSet { First.Id }, new Dictionary { [First.Id] = null, [Third.Id] = null }) == Second); }); await test("All negative TCP measurements still allow an untried VPN attempt", () => { var latencies = Nodes.ToDictionary(n => n.Id, _ => (int?)null); Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet(), latencies) == First); Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet { First.Id }, latencies) == Second); Check(NodeSelection.Choose(Nodes, "", true, Third.Id, new HashSet(), latencies) == Third); }); await test("Removed last working location and latency ties use stable fresh candidates", () => { var latencies = Nodes.ToDictionary(n => n.Id, _ => (int?)25); Check(NodeSelection.Choose(Nodes, "removed", true, "removed", new HashSet(), latencies) == First); Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet { First.Id }, latencies) == Second); Check(NodeSelection.Choose([], "", true, null, new HashSet(), latencies) is null); }); await test("Subscription presentation changes do not alter VPN connection identity", () => { Check(NodeSelection.ConnectionEquals(First, First with { Name = "Renamed location", Id = "new presentation id" })); Check(NodeSelection.ConnectionEquals(null, null)); Check(!NodeSelection.ConnectionEquals(First, null)); Check(!NodeSelection.ConnectionEquals(null, First)); }); await test("Every changed connection field requires a new VPN connection", () => { VpnNode[] changed = [ First with { Host = "203.0.113.2" }, First with { Port = 8443 }, First with { Uuid = "00000000-0000-4000-8000-000000000002" }, First with { ServerName = "other.invalid" }, First with { PublicKey = "other-test-key" }, First with { ShortId = "abce" }, First with { Fingerprint = "firefox" }, First with { Flow = "" } ]; foreach (var node in changed) Check(!NodeSelection.ConnectionEquals(First, node)); }); } private static VpnNode Node(string id) => new(id, "Test " + id, "203.0.113.1", 443, "00000000-0000-4000-8000-000000000001", "test.invalid", "fixture-key", "abcd", "chrome", "xtls-rprx-vision"); private static void Check(bool condition) { if (!condition) throw new InvalidOperationException("Node selection regression."); } }