using System.Diagnostics; using System.Text.RegularExpressions; using VoiceCat.Interop; namespace VoiceCat.Interop.Tests; /// /// Exercises the full connect -> TOFU event -> confirm -> guest auth -> list channels flow /// purely through the P/Invoke layer (NativeMethods/VoiceCatClient), against a real /// `voicecat-server.exe` (the same binary the C++ ctest suite uses) — this is the same flow /// tests/test_tofu_flow.cpp already proves at the C++ level, now proven reachable through /// P/Invoke specifically: marshaling bugs, callback-lifetime bugs, and calling-convention /// mistakes are all P/Invoke-specific failure modes ctest alone cannot catch. /// public sealed class VoiceCatClientSmokeTests : IDisposable { private readonly string _tempDir; private readonly Process _server; private readonly ushort _port; public VoiceCatClientSmokeTests() { _tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); string serverExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-server.exe"); Assert.True(File.Exists(serverExe), $"voicecat-server.exe not found at '{serverExe}' — build the dev preset first " + "(cmake --preset dev && cmake --build --preset dev)."); var psi = new ProcessStartInfo(serverExe) { Arguments = $"--port 0 --data-dir \"{_tempDir}\" --name CSharpSmokeTest", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; _server = Process.Start(psi) ?? throw new InvalidOperationException("failed to start voicecat-server.exe"); // "[voicecat-server] — TCP : UDP :" — see server/src/server.cpp. // ReadLineAsync()+timeout (not a bare blocking ReadLine() in a deadline loop) so a // server that never prints anything (crash, hang) can't hang this constructor forever // — the deadline check must apply to the read itself, not just the loop around it. ushort? port = null; var deadline = DateTime.UtcNow.AddSeconds(10); while (port is null && DateTime.UtcNow < deadline) { var readTask = _server.StandardOutput.ReadLineAsync(); var remaining = deadline - DateTime.UtcNow; if (remaining <= TimeSpan.Zero || !readTask.Wait(remaining)) break; string? line = readTask.Result; if (line is null) break; var m = Regex.Match(line, @"TCP :(\d+)"); if (m.Success) port = ushort.Parse(m.Groups[1].Value); } Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s."); _port = port!.Value; // Provision a known admin account so we can exercise moderation wrappers end-to-end. string adminExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-admin.exe"); Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the dev preset."); var adminPsi = new ProcessStartInfo(adminExe) { Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; using (var adminProc = Process.Start(adminPsi) ?? throw new InvalidOperationException("failed to start voicecat-admin.exe")) { Assert.True(adminProc.WaitForExit(10000), "voicecat-admin.exe did not exit within 10s."); Assert.Equal(0, adminProc.ExitCode); } } public void Dispose() { try { if (!_server.HasExited) _server.Kill(entireProcessTree: true); } catch { /* best effort */ } try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ } } private static string FindRepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "CMakePresets.json"))) dir = dir.Parent; return dir?.FullName ?? throw new InvalidOperationException("Could not find repo root (CMakePresets.json) above " + AppContext.BaseDirectory); } private static bool PumpUntil(VoiceCatClient client, Func predicate, int timeoutMs) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (DateTime.UtcNow < deadline) { client.PumpEvents(); if (predicate()) return true; Thread.Sleep(20); } client.PumpEvents(); return predicate(); } [Fact] public void VersionString_IsNonEmpty() { Assert.False(string.IsNullOrEmpty(VoiceCatClient.VersionString)); } [Fact] public void Connect_Tofu_Auth_ListChannels_RoundTrips() { var events = new List(); using var client = new VoiceCatClient("vc-csharp-smoke", "0.1", VcLogLevel.Off, tofuStorePath: Path.Combine(_tempDir, "tofu_pins.txt")); client.EventReceived += events.Add; Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port)); Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpSmoke")); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000), "did not receive VC_EVENT_SERVER_IDENTITY"); var identityEvent = events.First(e => e.Type == VcEventType.ServerIdentity); Assert.Equal((uint)VcTofuStatus.FirstConnect, identityEvent.U32a); Assert.NotNull(identityEvent.Text); Assert.Equal(64, identityEvent.Text!.Length); // SHA-256 hex, no separators // Auth must NOT complete before the identity is confirmed. Assert.False(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 800)); Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true)); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000), "did not receive VC_EVENT_AUTH_RESULT after confirming identity"); var authEvent = events.First(e => e.Type == VcEventType.AuthResult); Assert.Equal(VcResult.Ok, authEvent.Result); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000), "did not receive VC_EVENT_CHANNEL_LIST"); var channels = client.ListChannels(); Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby"); // Permissions getter round-trip. var perms = client.GetPermissions(); Assert.False(perms.IsAdmin); Assert.False(perms.CanKick); // Moderation request wrappers queue without error. As a guest, account listing // is rejected by the server with a GenericResult, which proves the wrapper path works // end-to-end and that the event type is delivered through P/Invoke. Assert.Equal(VcResult.Ok, client.RequestAccountList()); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.GenericResult), 3000), "did not receive VC_EVENT_GENERIC_RESULT for guest ListAccounts"); var generic = events.First(e => e.Type == VcEventType.GenericResult); Assert.Equal(VcResult.PermissionDenied, generic.Result); client.Disconnect(); } [Fact] public void Admin_ChannelCrud_AccountCrud_RoundTrips() { var events = new List(); using var client = new VoiceCatClient("vc-csharp-admin", "0.1", VcLogLevel.Off, tofuStorePath: Path.Combine(_tempDir, "tofu_pins_admin.txt")); client.EventReceived += events.Add; Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port)); Assert.Equal(VcResult.Ok, client.AuthenticateUser("admin2", "testpassword123")); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000)); Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true)); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000)); Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000)); var perms = client.GetPermissions(); Assert.True(perms.IsAdmin || perms.CanAdminAccounts); // Channel CRUD Assert.Equal(VcResult.Ok, client.CreateChannel(new ChannelEditInfo( Id: 0, ParentId: 0, Name: "CSharp Test Channel", Topic: "Created by C# smoke test", PasswordProtected: false, Password: null, MaxUsers: 42, SortOrder: 0, Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false)))); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), "CreateChannel did not succeed"); var channels = client.ListChannels(); var created = channels.FirstOrDefault(c => c.Name == "CSharp Test Channel"); Assert.NotNull(created); Assert.Equal("Created by C# smoke test", created.Topic); Assert.True(created.PasswordProtected == false); Assert.Equal(VcResult.Ok, client.EditChannel(new ChannelEditInfo( created.Id, created.ParentId, created.Name, "Updated topic", created.PasswordProtected, null, 100, 0, new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false)))); events.Clear(); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), "EditChannel did not succeed"); Assert.Equal(VcResult.Ok, client.DeleteChannel(created.Id)); events.Clear(); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), "DeleteChannel did not succeed"); // Account CRUD Assert.Equal(VcResult.Ok, client.CreateAccount("csharp_smoke_user", "initialpw")); events.Clear(); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), "CreateAccount did not succeed"); Assert.Equal(VcResult.Ok, client.RequestAccountList()); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AccountList), 3000)); var accounts = client.ListAccounts(); Assert.Contains(accounts, a => a.Username == "csharp_smoke_user"); Assert.Equal(VcResult.Ok, client.ResetPassword("csharp_smoke_user", "newpw123")); events.Clear(); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), "ResetPassword did not succeed"); Assert.Equal(VcResult.Ok, client.DeleteAccount("csharp_smoke_user")); events.Clear(); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), "DeleteAccount did not succeed"); client.Disconnect(); } /// /// Screen-audio (SCREEN_AUDIO) stream start/stop through the P/Invoke layer. The core's /// WASAPI loopback path (VOICECAT_HAS_LOOPBACK) captures the default render endpoint; the /// StreamAnnounce succeeds regardless of whether the loopback device actually initializes /// on a headless box, so this test passes in CI while still exercising the full /// StartStream -> StreamStarted -> StopStream -> StreamStopped path through P/Invoke. /// See docs/voice.md §9 and MainForm's BtnScreenShareToggle_Click. /// [Fact] public void ScreenAudioStream_Starts_And_Stops() { var events = new List(); using var client = new VoiceCatClient("vc-csharp-screen", "0.1", VcLogLevel.Off, tofuStorePath: Path.Combine(_tempDir, "tofu_pins_screen.txt")); client.EventReceived += events.Add; Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port)); Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpScreen")); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000)); Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true)); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000)); Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000)); // Give the async UDP binding handshake a moment to land before announcing a stream // (mirrors vccli's 500ms sleep after auth). Thread.Sleep(500); var (startResult, streamId) = client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio"); Assert.Equal(VcResult.Ok, startResult); Assert.True(streamId != 0, "StreamId should be non-zero on success"); // The core emits VC_EVENT_STREAM_STARTED for the local client too (client.cpp // handle_stream_announce_result), so we see our own screen-audio stream start. Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId), 5000), "did not receive VC_EVENT_STREAM_STARTED for screen-audio stream"); Assert.Equal(VcResult.Ok, client.StopStream(streamId)); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.StreamStopped && e.StreamId == streamId), 5000), "did not receive VC_EVENT_STREAM_STOPPED for screen-audio stream"); client.Disconnect(); } /// /// Per-stream receive-side controls (gain/mute/NR) round-trip through P/Invoke: two /// clients in a channel, one publishes a MIC stream, the other SetRemoteStream's it then /// GetRemoteStream's it back. Catches P/Invoke-specific marshaling bugs in /// VcRemoteStreamStateNative (field order, bool-from-int, float precision) that the C++ /// ctest (test_m3_multistream) cannot. See docs/voice.md §1, §10. /// [Fact] public void PerStream_RecvControls_RoundTrip_Through_PInvoke() { var eventsA = new List(); var eventsB = new List(); using var a = new VoiceCatClient("vc-csharp-mix-a", "0.1", VcLogLevel.Off, tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_a.txt")); using var b = new VoiceCatClient("vc-csharp-mix-b", "0.1", VcLogLevel.Off, tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_b.txt")); a.EventReceived += eventsA.Add; b.EventReceived += eventsB.Add; // Connect + auth A first, then B — staggering avoids concurrent TLS handshakes against // the same server (mirrors the C++ test_m3_multistream harness, which connects A to // completion before starting B). Assert.Equal(VcResult.Ok, a.Connect("127.0.0.1", _port)); Assert.Equal(VcResult.Ok, a.AuthenticateGuest("CSharpMixA")); Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ServerIdentity), 5000)); Assert.Equal(VcResult.Ok, a.ConfirmServerIdentity(accept: true)); Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.AuthResult), 5000)); Assert.Equal(VcResult.Ok, eventsA.First(e => e.Type == VcEventType.AuthResult).Result); Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ChannelList), 3000)); Assert.Equal(VcResult.Ok, b.Connect("127.0.0.1", _port)); Assert.Equal(VcResult.Ok, b.AuthenticateGuest("CSharpMixB")); Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ServerIdentity), 5000)); Assert.Equal(VcResult.Ok, b.ConfirmServerIdentity(accept: true)); Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.AuthResult), 5000)); Assert.Equal(VcResult.Ok, eventsB.First(e => e.Type == VcEventType.AuthResult).Result); Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ChannelList), 3000)); // Both join Lobby (channel 1) so voice relays between them. Assert.Equal(VcResult.Ok, a.JoinChannel(1, null)); Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.JoinResult), 5000), "A did not receive VC_EVENT_JOIN_RESULT"); Assert.Equal(VcResult.Ok, b.JoinChannel(1, null)); Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.JoinResult), 5000), "B did not receive VC_EVENT_JOIN_RESULT"); // UDP binding handshake is async; give it a moment (mirrors ScreenAudio test). Thread.Sleep(500); // A publishes a MIC stream. var (startResult, streamId) = a.StartStream(VcStreamKind.Mic, "mix-test-mic"); Assert.Equal(VcResult.Ok, startResult); Assert.True(streamId != 0); // B sees A's stream and can enumerate it. uint aUid = 0; Assert.True(PumpUntil(b, () => { return eventsB.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId); }, 5000), "B did not see A's STREAM_STARTED"); // Resolve A's user id from B's user list. Assert.True(PumpUntil(b, () => { aUid = b.ListUsers().FirstOrDefault(u => u.Nickname == "CSharpMixA")?.Id ?? 0; return aUid != 0; }, 3000), "could not resolve A's user id on B"); Assert.True(aUid != 0); Assert.True(PumpUntil(b, () => b.ListUserStreams(aUid).Any(s => s.StreamId == streamId), 3000), "B could not enumerate A's stream"); var bStreams = b.ListUserStreams(aUid); Assert.Contains(bStreams, s => s.StreamId == streamId && s.Kind == VcStreamKind.Mic); // Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off). var (r0, st0) = b.GetRemoteStream(aUid, streamId); Assert.Equal(VcResult.Ok, r0); Assert.NotNull(st0); Assert.Equal(1.0f, st0!.Gain); Assert.False(st0.Muted); Assert.False(st0.NoiseReduction); // B turns A down to 0.4×, mutes, and enables NR — then reads it back. Assert.Equal(VcResult.Ok, b.SetRemoteStream(aUid, streamId, 0.4f, muted: true, noiseReduction: true)); var (r1, st1) = b.GetRemoteStream(aUid, streamId); Assert.Equal(VcResult.Ok, r1); Assert.NotNull(st1); Assert.Equal(0.4f, st1!.Gain); Assert.True(st1.Muted); Assert.True(st1.NoiseReduction); // Unknown stream id on a known user -> INVALID_ARG. var (rBad, stBad) = b.GetRemoteStream(aUid, 0xDEADBEEF); Assert.Equal(VcResult.InvalidArg, rBad); Assert.Null(stBad); a.Disconnect(); b.Disconnect(); } }