test(runtime): reach the runtime and the store through one harness (#39)

This commit was merged in pull request #39.
This commit is contained in:
james.maes committed 2026-11-11 01:05:59 +00:00
1 parent c3efed46cc
commit 6f0237dde8
51 files changed
+1362 -515

No files matched your search

+4 -2
View File
@@ -37,12 +37,14 @@ This performs locked restore, Release solution build, core tests, Windows/CLI te
- `RealtimeRuntimeFixture` runs a runtime on a temporary store, signs it in through a real `QqqSessionClient` whose HTTP goes to a scripted handler, and connects it over a loopback WebSocket to a scripted peer. A test sends the server's frames (WELCOME, DESIRED_STATE, SETTINGS, COMMAND, JOB_AVAILABLE, PRINTERS), reads what the agent sent, talks to the runtime over its management pipe, and can restart the runtime on the same store. The peer can acknowledge a message, refuse it with an ERROR frame, or drop the connection without acknowledging it; the fake printer can hold a submission open, count entries into it and script a spooler cancellation. The scripted server refuses, and counts with the forbidden polls, a claim made before it acknowledged the agent's applied state.
- `SessionFixture` supplies a fake session with no network and a manual clock, for decisions about sign-in, pairing and the protected files. `EvaluateAsync` lets the renewal interval pass and evaluates authorization once, the check the reconnect loop and the heartbeat make; `RunOnePassAsync` runs the service loop for one pass, after which `AssertSentNothing` shows that nothing was sent.
- `SingleServerHarness` is the runtime as tests hold it, and the only type in the test project that names the runtime class. Both fixtures build it, and a test that needs a runtime of its own constructs the harness, never the runtime. It makes the management pipe on a private name and sends a command over it with `PipeAsync`. The constants and pure functions tests read from the runtime (the update limits, the reconnect delay, the job list merge) are read from the harness too.
- `ScriptedPrintServer` is the server `RealtimeRuntimeFixture` runs against: the realtime peer, the HTTP handler, the job queue and the accepted connections. It knows nothing about the runtime and shares nothing between instances, so several can stand beside one service; the fixture repeats the members of its one server under their old names.
Wait for the event (a recorded message, a log line, a signal from a fake) with `fixture.WaitAsync`, never for a length of time; its 30-second limit applies to that one wait, and the runtime lives until the fixture is disposed. The runtime reads these from its time provider: the heartbeat interval, session renewal, the reconnect backoff, the update pause and its preparation limit, the catalog refresh interval, command expiry and download pacing. Pass a `ManualTimeProvider` and let a heartbeat interval pass with `fixture.HeartbeatAsync`, or a backoff with `fixture.BackoffPassAsync`. The WebSocket handshake timeout, the acknowledgement timeout and pipe command timeouts still use real time. To show that something did not happen, find an event that can only follow it: commands are handled in arrival order, claims are made one at a time, and frames on one socket arrive in the order they were sent. Where no such event exists (a claim wave or a synchronization pass that decides to do nothing leaves no trace), say so in the test and give the wrong action heartbeat rounds in which to show.
A test that created a store deletes its folder with `JournalFiles.Delete`, which empties the SQLite connection pool of that journal only. Do not call `SqliteConnection.ClearAllPools()` in this suite: the classes run in parallel, and clearing every pool disposes a connection that a store in another test has just taken from its pool, which that test sees as `ObjectDisposedException: SQLitePCL.sqlite3`.
A test opens a store with `TestState.OpenStore`, the only place the suite constructs one, declares it as `JournalStore` (the test project's alias for the store's type, declared in `TestState.cs`), and deletes its folder with `JournalFiles.Delete`, which empties the SQLite connection pool of that journal only. Do not call `SqliteConnection.ClearAllPools()` in this suite: the classes run in parallel, and clearing every pool disposes a connection that a store in another test has just taken from its pool, which that test sees as `ObjectDisposedException: SQLitePCL.sqlite3`.
`AgentStore` opens its SQLite connection inside the journal's schema lock, and the open must stay there. Microsoft.Data.Sqlite 10.0.0 takes a pooled connection under the pool's lock but records which `SqliteConnection` owns it only afterwards (`SqliteConnectionFactory.GetConnection`, then `SqliteConnectionInternal.Activate`). A second open of the same journal that finds the pool empty in that gap takes the connection for abandoned (`SqliteConnectionPool.ReclaimLeakedConnections`) and is handed it too, so two stores share one native handle and one store's transaction can swallow or roll back the other's writes. Every open of a journal goes through the store's constructor, so the lock keeps any opener out of that gap while another asks the pool. `AgentStoreOpenRaceTests` fails when the open is moved out of the lock. Read those three library methods again before changing the package version or the pooling of the journal's connection.
`AgentStore` opens its SQLite connection inside the journal's schema lock, and the open must stay there. Microsoft.Data.Sqlite 10.0.0 takes a pooled connection under the pool's lock but records which `SqliteConnection` owns it only afterwards (`SqliteConnectionFactory.GetConnection`, then `SqliteConnectionInternal.Activate`). A second open of the same journal that finds the pool empty in that gap takes the connection for abandoned (`SqliteConnectionPool.ReclaimLeakedConnections`) and is handed it too, so two stores share one native handle and one store's transaction can swallow or roll back the other's writes. Every pooled open of a journal goes through the store's constructor, so the lock keeps any opener out of that gap while another asks the pool. A test that needs SQL of its own against a journal takes its connection from `TestState.OpenJournal`, which opens it with pooling off: a pooled connection opened anywhere else shares the journal's pool without that lock and can fall into the same gap. `AgentStoreOpenRaceTests` fails when the open is moved out of the lock. Read those three library methods again before changing the package version or the pooling of the journal's connection.
## Local server and CLI
@@ -27,7 +27,7 @@ public sealed class AgentControlConcurrencyTests
var clock = new ManualTimeProvider();
await using RealtimeRuntimeFixture fixture = new(downloads: 2, timeProvider: clock);
fixture.Printer.BlockPrint = true;
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
fixture.AddJob("job");
await connection.SendAsync("JOB_AVAILABLE", new { });
await fixture.WaitAsync(fixture.Printer.Entered.Task);
@@ -65,7 +65,7 @@ public sealed class AgentControlConcurrencyTests
{
var clock = new ManualTimeProvider();
await using RealtimeRuntimeFixture fixture = new(timeProvider: clock);
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
fixture.AddJob("first");
await connection.SendAsync("JOB_AVAILABLE", new { });
// The claim after the printed job found the queue empty: the wave that printed it claims nothing more.
@@ -113,7 +113,7 @@ public sealed class AgentControlConcurrencyTests
try
{
DeviceDesiredState arriving = new(2, !resumeArrives, "CONNECTED");
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(1, resumeArrives, "CONNECTED"));
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(1, resumeArrives, "CONNECTED"));
await connection.SendAsync("COMMAND", RealtimeRuntimeFixture.Command("REFRESH_INVENTORY"));
await fixture.WaitAsync(() => saving.IsSet);
int reportedBefore = fixture.Count("STATE_APPLIED");
@@ -127,7 +127,7 @@ public sealed class AgentControlConcurrencyTests
Assert.Equal(arriving, live.DesiredState);
Assert.Equal("REFRESH_INVENTORY", live.LastCommand);
Assert.Equal("INVENTORY_REFRESHED", live.LastOutcome);
using(AgentStore store = new(fixture.DirectoryPath))
using(JournalStore store = TestState.OpenStore(fixture.DirectoryPath))
{
AgentControlState saved = store.LoadControlState();
Assert.Equal(arriving, saved.DesiredState);
@@ -157,7 +157,7 @@ public sealed class AgentControlConcurrencyTests
/// the transport ping went out in both and that no heartbeat did. Frames are handled in arrival order, so the receipt of
/// the expired command sent after the announcement proves the announcement was handled before the intervals begin.
/// </summary>
private static async Task AnnounceAndPassTwoSilentHeartbeatRoundsAsync(RealtimeRuntimeFixture fixture, RealtimeRuntimeFixture.Connection connection, ManualTimeProvider clock, string jobId, string printerId)
private static async Task AnnounceAndPassTwoSilentHeartbeatRoundsAsync(RealtimeRuntimeFixture fixture, ScriptedPrintServer.Connection connection, ManualTimeProvider clock, string jobId, string printerId)
{
int heartbeats = fixture.Count("HEARTBEAT");
int pings = fixture.Count("PING");
@@ -22,20 +22,20 @@ public sealed class AgentJobPageTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-page-queue-" + Guid.NewGuid().ToString("N"));
try
{
using AgentStore store = new(directory);
using JournalStore store = TestState.OpenStore(directory);
store.Record("local", new PrintEvent("local-event", "SPOOLED"));
store.Record("duplicate", new PrintEvent("duplicate-event", "SPOOLED"));
JobSnapshot[] queued = [new("cloud", "QUEUED", "now", null, null, "laser", "Warehouse", "Packing slip", CreatedAt: "2099-01-01T00:00:00.0000000+00:00"), new("duplicate", "QUEUED", "now", null, null)];
JobPage first = AgentRuntime.QueryJobsWithQueue(store, new JobQuery(PageSize: 1), queued);
JobPage first = SingleServerHarness.QueryJobsWithQueue(store, new JobQuery(PageSize: 1), queued);
Assert.Equal("cloud", Assert.Single(first.Jobs).JobId);
Assert.Equal(3, first.TotalCount);
Assert.False(first.ServerQueueLimited);
JobPage next = AgentRuntime.QueryJobsWithQueue(store, new JobQuery(Offset: 1, PageSize: 2), queued);
JobPage next = SingleServerHarness.QueryJobsWithQueue(store, new JobQuery(Offset: 1, PageSize: 2), queued);
Assert.Equal(2, next.Jobs.Count);
Assert.DoesNotContain(next.Jobs, job => string.Equals(job.JobId, "cloud", StringComparison.Ordinal));
Assert.Equal("cloud", Assert.Single(AgentRuntime.QueryJobsWithQueue(store, new JobQuery(Search: "packing", Status: "QUEUED", PrinterId: "laser"), queued).Jobs).JobId);
Assert.Empty(AgentRuntime.QueryJobsWithQueue(store, new JobQuery(NeedsAttentionOnly: true), queued).Jobs);
Assert.True(AgentRuntime.QueryJobsWithQueue(store, new JobQuery(), Enumerable.Repeat(queued[0], 50).ToArray()).ServerQueueLimited);
Assert.Equal("cloud", Assert.Single(SingleServerHarness.QueryJobsWithQueue(store, new JobQuery(Search: "packing", Status: "QUEUED", PrinterId: "laser"), queued).Jobs).JobId);
Assert.Empty(SingleServerHarness.QueryJobsWithQueue(store, new JobQuery(NeedsAttentionOnly: true), queued).Jobs);
Assert.True(SingleServerHarness.QueryJobsWithQueue(store, new JobQuery(), Enumerable.Repeat(queued[0], 50).ToArray()).ServerQueueLimited);
}
finally
{
@@ -35,13 +35,13 @@ public sealed class AgentNewPairingTests
byte[] retained = File.ReadAllBytes(path);
fixture.NextSession = UnboundSession();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
using(AgentRuntime runtime = fixture.Runtime())
using(SingleServerHarness runtime = fixture.Runtime())
{
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.NextSession.ExportCredential(), StartNewPairing: true), timeout.Token);
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
}
fixture.NextSession = UnboundSession();
using(AgentRuntime restarted = fixture.Runtime())
using(SingleServerHarness restarted = fixture.Runtime())
{
await fixture.RunOnePassAsync(restarted);
Assert.True(restarted.GetSnapshot().Authentication!.SignedIn);
@@ -52,14 +52,14 @@ public sealed class AgentNewPairingTests
Assert.True(restarted.GetSnapshot().Authentication!.Authorized);
Assert.Equal(new[] { "devices/enroll" }, fixture.NextSession.Paths, StringComparer.Ordinal);
}
using(AgentStore store = new(fixture.Local.DirectoryPath))
using(JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath))
{
Assert.Equal("replacement-device", store.LoadConfiguration()!.DeviceId);
Assert.Null(store.GetState("job"));
Assert.Contains("printer", store.GetExposedPrinterIds());
}
File.WriteAllBytes(path, original);
using AgentStore originalStore = new(fixture.Local.DirectoryPath);
using JournalStore originalStore = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("SPOOLED", originalStore.GetState("job"));
}
@@ -76,7 +76,7 @@ public sealed class AgentNewPairingTests
ChangeOldConfiguration(fixture, unreadable);
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
byte[] retained = File.ReadAllBytes(path);
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
await fixture.RunOnePassAsync(runtime);
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
@@ -96,7 +96,7 @@ public sealed class AgentNewPairingTests
}
else
{
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
store.SaveConfiguration(new AgentConfiguration("https://old-server.invalid", "old-device", "synthetic-old-proof"));
}
}
@@ -120,7 +120,7 @@ public sealed class AgentNewPairingTests
byte[] originalAuth = File.ReadAllBytes(authPath);
fixture.NextSession = UnboundSession();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
using(AgentRuntime runtime = fixture.Runtime())
using(SingleServerHarness runtime = fixture.Runtime())
{
await Assert.ThrowsAsync<QqqSessionException>(() => runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.NextSession.ExportCredential()), timeout.Token));
Assert.Equal(original, File.ReadAllBytes(path));
@@ -132,7 +132,7 @@ public sealed class AgentNewPairingTests
Assert.Empty(fixture.NextSession.Paths);
}
fixture.NextSession = UnboundSession();
using(AgentRuntime restarted = fixture.Runtime())
using(SingleServerHarness restarted = fixture.Runtime())
{
await fixture.RunOnePassAsync(restarted);
Assert.True(restarted.GetSnapshot().Authentication!.SignedIn);
@@ -148,14 +148,14 @@ public sealed class AgentNewPairingTests
Assert.True(restarted.GetSnapshot().Authentication!.Authorized);
Assert.Equal(new[] { "devices/enroll", "devices/enroll" }, fixture.NextSession.Paths, StringComparer.Ordinal);
}
using(AgentStore store = new(fixture.Local.DirectoryPath))
using(JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath))
{
Assert.Equal("replacement-device", store.LoadConfiguration()!.DeviceId);
Assert.Null(store.GetState("job"));
Assert.Contains("printer", store.GetExposedPrinterIds());
}
File.WriteAllBytes(path, original);
using AgentStore originalStore = new(fixture.Local.DirectoryPath);
using JournalStore originalStore = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("SPOOLED", originalStore.GetState("job"));
}
@@ -185,7 +185,7 @@ public sealed class AgentNewPairingTests
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
fixture.NextSession = UnboundSession();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
using(AgentRuntime runtime = fixture.Runtime())
using(SingleServerHarness runtime = fixture.Runtime())
{
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.NextSession.ExportCredential(), StartNewPairing: true), timeout.Token);
byte[] signedIn = File.ReadAllBytes(authPath);
@@ -207,7 +207,7 @@ public sealed class AgentNewPairingTests
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
}
Assert.Equal(unreadable ? 1 : 0, Directory.GetFiles(fixture.Local.DirectoryPath, "connection.recovery-*.dpapi").Length);
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("replacement-device", store.LoadConfiguration()!.DeviceId);
Assert.Null(store.GetState("job"));
}
@@ -228,7 +228,7 @@ public sealed class AgentNewPairingTests
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
byte[] original = File.ReadAllBytes(path);
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
_ = await fixture.EvaluateAsync(runtime, timeout.Token);
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
@@ -266,7 +266,7 @@ public sealed class AgentNewPairingTests
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
byte[] original = File.ReadAllBytes(path);
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
_ = await fixture.EvaluateAsync(runtime, timeout.Token);
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
@@ -313,7 +313,7 @@ public sealed class AgentNewPairingTests
byte[] original = File.ReadAllBytes(path);
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
AgentRuntime runtime = fixture.Runtime();
SingleServerHarness runtime = fixture.Runtime();
try
{
_ = await fixture.EvaluateAsync(runtime, timeout.Token);
@@ -363,7 +363,7 @@ public sealed class AgentNewPairingTests
using SessionFixture fixture = new();
fixture.SaveSession();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
using(AgentRuntime runtime = fixture.Runtime())
using(SingleServerHarness runtime = fixture.Runtime())
{
_ = await fixture.EvaluateAsync(runtime, timeout.Token);
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
@@ -376,7 +376,7 @@ public sealed class AgentNewPairingTests
Assert.False(File.Exists(StagedSession(fixture)));
}
fixture.Session.ExportsBeforeFailure = null;
using AgentRuntime restarted = fixture.Runtime();
using SingleServerHarness restarted = fixture.Runtime();
_ = await fixture.EvaluateAsync(restarted, timeout.Token);
AgentSnapshot recovered = restarted.GetSnapshot();
Assert.True(recovered.Authentication!.Authorized);
@@ -397,7 +397,7 @@ public sealed class AgentNewPairingTests
fixture.SaveSession();
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
_ = await fixture.EvaluateAsync(runtime, timeout.Token);
using(FileStream held = new(authPath, FileMode.Open, FileAccess.Read, FileShare.Read))
{
@@ -432,7 +432,7 @@ public sealed class AgentNewPairingTests
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
_ = await fixture.EvaluateAsync(runtime, timeout.Token);
using(FileStream held = new(authPath, FileMode.Open, FileAccess.Read, FileShare.Read))
{
@@ -475,7 +475,7 @@ public sealed class AgentNewPairingTests
Stage(fixture, "device", "https://synthetic.invalid/");
}
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
string reply = await runtime.SignOutAsync(timeout.Token);
Assert.Contains("revoke the previous session", reply, StringComparison.Ordinal);
Assert.Contains(runtime.GetSnapshot().Logs, entry => entry.Message.Contains(
@@ -499,12 +499,12 @@ public sealed class AgentNewPairingTests
Stage(fixture, "replacement-device", "https://synthetic.invalid/");
using(FileStream held = new(authPath, FileMode.Open, FileAccess.Read, FileShare.Read))
{
using AgentStore blocked = new(fixture.Local.DirectoryPath);
using JournalStore blocked = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("replacement-device", blocked.LoadAuthentication()!.Binding.DeviceId);
Assert.Equal(StagedPairingOutcome.Deferred, blocked.StagedPairing);
Assert.True(File.Exists(StagedSession(fixture)));
}
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("replacement-device", store.LoadAuthentication()!.Binding.DeviceId);
Assert.Equal(StagedPairingOutcome.Completed, store.StagedPairing);
Assert.False(File.Exists(StagedSession(fixture)));
@@ -522,7 +522,7 @@ public sealed class AgentNewPairingTests
byte[] unreadable = [9, 8, 7];
File.WriteAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi"), unreadable);
Stage(fixture, "replacement-device", "https://synthetic.invalid/");
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("replacement-device", store.LoadAuthentication()!.Binding.DeviceId);
Assert.Equal(StagedPairingOutcome.CompletedAfterQuarantine, store.StagedPairing);
Assert.Equal(unreadable, File.ReadAllBytes(Assert.Single(Directory.GetFiles(fixture.Local.DirectoryPath, "qqq-session.recovery-*.dpapi"))));
@@ -548,7 +548,7 @@ public sealed class AgentNewPairingTests
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
Stage(fixture, leftover is "another-device" ? "interrupted-device" : "device", leftover is "another-server" ? "https://elsewhere.invalid/" : "https://synthetic.invalid/");
using(AgentStore setup = new(fixture.Local.DirectoryPath))
using(JournalStore setup = TestState.OpenStore(fixture.Local.DirectoryPath))
{
setup.SaveConfiguration(new AgentConfiguration("https://synthetic.invalid", "device", "synthetic-token"));
}
@@ -560,7 +560,7 @@ public sealed class AgentNewPairingTests
File.Delete(authPath);
}
byte[]? stored = File.Exists(authPath) ? File.ReadAllBytes(authPath) : null;
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
AgentAuthentication? loaded = store.LoadAuthentication();
Assert.Equal(StagedPairingOutcome.Discarded, store.StagedPairing);
Assert.False(File.Exists(StagedSession(fixture)));
@@ -578,7 +578,7 @@ public sealed class AgentNewPairingTests
fixture.SaveSession();
byte[] unreadable = [2, 4, 6];
File.WriteAllBytes(StagedSession(fixture), unreadable);
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("device", store.LoadAuthentication()!.Binding.DeviceId);
Assert.Equal(StagedPairingOutcome.SetAside, store.StagedPairing);
Assert.False(File.Exists(StagedSession(fixture)));
@@ -597,14 +597,14 @@ public sealed class AgentNewPairingTests
using SessionFixture fixture = new();
fixture.SaveSession();
Stage(fixture, "first-device", "https://synthetic.invalid/");
using(AgentStore store = new(fixture.Local.DirectoryPath))
using(JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath))
{
store.SaveAuthentication(new AgentAuthentication(fixture.Session.Binding with { DeviceId = "first-device" }, fixture.Session.ExportCredential()));
}
Assert.False(File.Exists(StagedSession(fixture)));
Stage(fixture, "second-device", "https://synthetic.invalid/");
Stage(fixture, "third-device", "https://synthetic.invalid/");
using AgentStore reader = new(fixture.Local.DirectoryPath);
using JournalStore reader = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("third-device", reader.LoadAuthentication()!.Binding.DeviceId);
Assert.Equal(StagedPairingOutcome.Completed, reader.StagedPairing);
Assert.Equal("third-device", reader.LoadConfiguration()!.DeviceId);
@@ -630,13 +630,13 @@ public sealed class AgentNewPairingTests
{ File.Copy(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi"), StagedSession(fixture)); }
using(FileStream held = new(StagedSession(fixture), FileMode.Open, FileAccess.Read, FileShare.Read))
{
using AgentStore blocked = new(fixture.Local.DirectoryPath);
using JournalStore blocked = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("device", blocked.LoadAuthentication()!.Binding.DeviceId);
Assert.Equal(StagedPairingOutcome.None, blocked.StagedPairing);
Assert.True(File.Exists(StagedSession(fixture)));
Assert.Empty(Directory.GetFiles(fixture.Local.DirectoryPath, "qqq-session.recovery-*.dpapi"));
}
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("device", store.LoadAuthentication()!.Binding.DeviceId);
Assert.Equal(unreadable ? StagedPairingOutcome.SetAside : StagedPairingOutcome.Discarded, store.StagedPairing);
Assert.False(File.Exists(StagedSession(fixture)));
@@ -660,13 +660,13 @@ public sealed class AgentNewPairingTests
File.SetLastWriteTimeUtc(StagedSession(fixture), DateTime.UtcNow.AddHours(1));
using(FileStream held = new(StagedSession(fixture), FileMode.Open, FileAccess.Read, FileShare.Read))
{
using AgentStore saving = new(fixture.Local.DirectoryPath);
using JournalStore saving = TestState.OpenStore(fixture.Local.DirectoryPath);
saving.SaveAuthentication(new AgentAuthentication(fixture.Session.Binding with { DeviceId = null }, fixture.Session.ExportCredential()));
}
Assert.True(File.Exists(StagedSession(fixture)));
Assert.True(File.GetLastWriteTimeUtc(authPath) > File.GetLastWriteTimeUtc(StagedSession(fixture)));
byte[] saved = File.ReadAllBytes(authPath);
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Null(store.LoadAuthentication()!.Binding.DeviceId);
Assert.Equal(StagedPairingOutcome.Discarded, store.StagedPairing);
Assert.Equal(saved, File.ReadAllBytes(authPath));
@@ -689,7 +689,7 @@ public sealed class AgentNewPairingTests
File.SetLastWriteTimeUtc(authPath, DateTime.UtcNow.AddHours(1));
Stage(fixture, "replacement-device", "https://synthetic.invalid/");
Assert.Equal(File.GetLastWriteTimeUtc(authPath), File.GetLastWriteTimeUtc(StagedSession(fixture)));
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("replacement-device", store.LoadAuthentication()!.Binding.DeviceId);
Assert.Equal(StagedPairingOutcome.Completed, store.StagedPairing);
Assert.False(File.Exists(StagedSession(fixture)));
@@ -701,7 +701,7 @@ public sealed class AgentNewPairingTests
private static void Stage(SessionFixture fixture, string deviceId, string sessionOrigin)
{
Uri origin = new(sessionOrigin);
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
store.StagePairing(new AgentConfiguration("https://synthetic.invalid", deviceId, "synthetic-" + deviceId),
new AgentAuthentication(new QqqSessionBinding(origin, "principal", "tenant", deviceId), new QqqSessionCredential(origin, "synthetic-qqq-cookie")));
}
@@ -22,7 +22,7 @@ public sealed class AgentPipeSecurityTests
public async Task ExplicitClientRightsConnectWithoutPermissionToCreateServerInstances()
{
string name = "QqqPrintAgent-acl-test-" + Guid.NewGuid().ToString("N");
PipeSecurity security = AgentRuntime.CreatePipeSecurity();
PipeSecurity security = SingleServerHarness.CreatePipeSecurity();
Assert.All(security.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast<PipeAccessRule>()
.Where(rule => string.Equals(rule.IdentityReference.Value, "S-1-5-32-544", StringComparison.Ordinal)),
rule => Assert.False(rule.PipeAccessRights.HasFlag(PipeAccessRights.CreateNewInstance)));
@@ -21,7 +21,7 @@ public sealed class AgentPrinterCatalogTests
{
await using RealtimeRuntimeFixture fixture = new();
fixture.ApprovedPrinters = ["printer"];
await using RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
await using ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.Reported, StringComparison.Ordinal));
AgentSnapshot snapshot = fixture.Runtime.GetSnapshot();
Assert.Equal(PrinterServerState.Approved, snapshot.Printers.Single(printer => string.Equals(printer.PrinterId, "printer", StringComparison.Ordinal)).ServerState);
@@ -39,9 +39,9 @@ public sealed class AgentPrinterCatalogTests
public async Task UnsharedPrinter_IsNotShared_NotAwaitingApproval()
{
await using RealtimeRuntimeFixture fixture = new();
using(AgentStore store = new(fixture.DirectoryPath))
using(JournalStore store = TestState.OpenStore(fixture.DirectoryPath))
{ store.SetExposure("other", false, fixture.Printer.Discover()); }
await using RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
await using ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.Reported, StringComparison.Ordinal));
AgentSnapshot snapshot = fixture.Runtime.GetSnapshot();
Assert.Equal(PrinterServerState.NotShared, snapshot.Printers.Single(printer => string.Equals(printer.PrinterId, "other", StringComparison.Ordinal)).ServerState);
@@ -58,7 +58,7 @@ public sealed class AgentPrinterCatalogTests
{
await using RealtimeRuntimeFixture fixture = new();
fixture.CatalogStatus = status;
await using RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
await using ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.NotReported, StringComparison.Ordinal));
AgentSnapshot snapshot = fixture.Runtime.GetSnapshot();
Assert.All(snapshot.Printers, printer => Assert.Equal(PrinterServerState.NotReported, printer.ServerState));
@@ -83,10 +83,10 @@ public sealed class AgentPrinterCatalogTests
{
var clock = new ManualTimeProvider();
await using RealtimeRuntimeFixture fixture = new(timeProvider: clock);
await using RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
await using ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.Reported, StringComparison.Ordinal));
int reads = fixture.CatalogRequests;
int rounds = (int)Math.Ceiling(AgentRuntime.CatalogRefreshInterval / TimeSpan.FromSeconds(fixture.Settings.HeartbeatIntervalSeconds));
int rounds = (int)Math.Ceiling(SingleServerHarness.CatalogRefreshInterval / TimeSpan.FromSeconds(fixture.Settings.HeartbeatIntervalSeconds));
Assert.True(rounds >= 3);
for(int round = 1; round < rounds; round++)
{
@@ -111,7 +111,7 @@ public sealed class AgentPrinterCatalogTests
public async Task PrintersFrame_IsAuthoritativeOnA24Server()
{
await using RealtimeRuntimeFixture fixture = new(printingVersion: "2.4.0");
await using RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
await using ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
Assert.Contains("printer-approval-v1", fixture.LastHelloCapabilities, StringComparer.Ordinal);
await fixture.WaitAsync(() => fixture.Messages.Any(message => message.Type is "INVENTORY"));
await connection.SendAsync("PRINTERS", new { printers = new object[] { new { printerId = "printer", enabled = true } } });
@@ -136,10 +136,10 @@ public sealed class AgentPrinterCatalogTests
public async Task MalformedPrintersFrame_EndsTheConnection()
{
await using RealtimeRuntimeFixture fixture = new(printingVersion: "2.4.0");
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
ScriptedPrintServer.Connection first = await fixture.ConnectAsync();
await first.SendAsync("PRINTERS", new { nothing = true });
await fixture.WaitAsync(() => !fixture.Runtime.GetSnapshot().Connected);
await using RealtimeRuntimeFixture.Connection second = await fixture.ConnectAsync();
await using ScriptedPrintServer.Connection second = await fixture.ConnectAsync();
Assert.Equal(0, fixture.CatalogRequests);
Assert.Contains(fixture.Logs, line => line.Contains("PRINTERS frame has no printers list", StringComparison.Ordinal));
}
@@ -168,7 +168,7 @@ public sealed class AgentPrinterCatalogTests
};
try
{
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
ScriptedPrintServer.Connection first = await fixture.ConnectAsync();
await fixture.WaitAsync(() => inventoryPending.Task.IsCompleted);
await first.SendAsync("PRINTERS", new { nothing = true });
await fixture.WaitAsync(() => fixture.Logs.Any(line => line.Contains("Realtime connection stopped", StringComparison.Ordinal)));
@@ -186,13 +186,13 @@ public sealed class AgentPrinterCatalogTests
public async Task Reconnect_StartsFromNotCheckedAndAsksAgain()
{
await using RealtimeRuntimeFixture fixture = new();
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
ScriptedPrintServer.Connection first = await fixture.ConnectAsync();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.Reported, StringComparison.Ordinal));
int reads = fixture.CatalogRequests;
first.Abort();
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.NotChecked, StringComparison.Ordinal));
Assert.All(fixture.Runtime.GetSnapshot().Printers.Where(printer => printer.Exposed), printer => Assert.Equal(PrinterServerState.NotChecked, printer.ServerState));
await using RealtimeRuntimeFixture.Connection second = await fixture.ConnectAsync();
await using ScriptedPrintServer.Connection second = await fixture.ConnectAsync();
await fixture.WaitAsync(() => fixture.CatalogRequests > reads);
await fixture.WaitAsync(() => string.Equals(fixture.Runtime.GetSnapshot().PrinterSync?.Outcome, PrinterServerSync.Reported, StringComparison.Ordinal));
}
@@ -25,7 +25,7 @@ public sealed class AgentRealtimeBandwidthTests
const int aggregateBytesPerSecond = 128 * 1024;
var settings = new RealtimeSettings(Revision: 1, MaxConcurrentPrints: 2, MaxDownloadBytesPerSecond: aggregateBytesPerSecond);
await using var fixture = new RealtimeRuntimeFixture(downloads: 2, settings: settings);
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
byte[] bytes = new byte[documentBytes];
Encoding.ASCII.GetBytes("%PDF-1.7 synthetic bandwidth fixture").CopyTo(bytes, 0);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -4,7 +4,6 @@
*/
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
@@ -27,7 +26,7 @@ public sealed class AgentRealtimeFailureReasonTests
var worker = new TaskCompletionSource();
var idle = new TaskCompletionSource();
List<Exception> detail = [];
Task completion = AgentRuntime.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task, idle.Task);
Task completion = SingleServerHarness.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task, idle.Task);
reader.SetException(new IOException("Server closed the realtime connection."));
idle.SetCanceled();
Assert.False(completion.IsCompleted);
@@ -51,7 +50,7 @@ public sealed class AgentRealtimeFailureReasonTests
var reader = new TaskCompletionSource();
var sender = new TaskCompletionSource();
List<Exception> detail = [];
Task completion = AgentRuntime.CompleteConnectionAsync(detail.Add, reader.Task, sender.Task);
Task completion = SingleServerHarness.CompleteConnectionAsync(detail.Add, reader.Task, sender.Task);
sender.SetException(new RealtimeChannelClosedException("Realtime connection closed before acknowledgement; durable receipts will be replayed.", frame));
Assert.False(completion.IsCompleted);
reader.SetException(frame);
@@ -72,7 +71,7 @@ public sealed class AgentRealtimeFailureReasonTests
var reader = new TaskCompletionSource();
var worker = new TaskCompletionSource();
List<Exception> detail = [];
Task completion = AgentRuntime.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
Task completion = SingleServerHarness.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
reader.SetException(new System.Net.WebSockets.WebSocketException("The remote party closed the WebSocket connection without completing the close handshake."));
Assert.False(completion.IsCompleted);
var full = new IOException("Realtime output queue is full; reconnect is required.");
@@ -90,7 +89,7 @@ public sealed class AgentRealtimeFailureReasonTests
var reader = new TaskCompletionSource();
var worker = new TaskCompletionSource();
List<Exception> detail = [];
Task completion = AgentRuntime.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
Task completion = SingleServerHarness.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
worker.SetException(new RealtimeChannelClosedException("The realtime connection is unavailable.", null));
var regressed = new InvalidDataException("Desired state revision regressed.");
reader.SetException(regressed);
@@ -107,7 +106,7 @@ public sealed class AgentRealtimeFailureReasonTests
var reader = new TaskCompletionSource();
var worker = new TaskCompletionSource();
List<Exception> detail = [];
Task completion = AgentRuntime.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
Task completion = SingleServerHarness.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
reader.SetException(new IOException("Server closed the realtime connection."));
worker.SetException(new Qqq.PrintAgent.Core.Authentication.QqqSessionException(Qqq.PrintAgent.Core.Authentication.QqqSessionException.FailureKind.AuthenticationRequired, "Synthetic session ended."));
_ = await Assert.ThrowsAsync<Qqq.PrintAgent.Core.Authentication.QqqSessionException>(() => completion);
@@ -125,7 +124,7 @@ public sealed class AgentRealtimeFailureReasonTests
var reader = new TaskCompletionSource();
var worker = new TaskCompletionSource();
List<Exception> detail = [];
Task completion = AgentRuntime.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
Task completion = SingleServerHarness.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
Exception failure = plainIoError ? new IOException("Realtime output queue is full; reconnect is required.") : new TimeoutException("Synthetic acknowledgement timeout.");
worker.SetException(failure);
reader.SetCanceled();
@@ -142,7 +141,7 @@ public sealed class AgentRealtimeFailureReasonTests
var reader = new TaskCompletionSource();
var worker = new TaskCompletionSource();
List<Exception> detail = [];
Task completion = AgentRuntime.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
Task completion = SingleServerHarness.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
reader.SetCanceled();
worker.SetCanceled();
_ = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => completion);
@@ -163,7 +162,7 @@ public sealed class AgentRealtimeFailureReasonTests
var worker = new TaskCompletionSource();
var watchdog = new TaskCompletionSource();
List<Exception> detail = [];
Task completion = AgentRuntime.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task, watchdog.Task);
Task completion = SingleServerHarness.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task, watchdog.Task);
reader.SetResult();
worker.SetCanceled();
watchdog.SetCanceled();
@@ -180,7 +179,7 @@ public sealed class AgentRealtimeFailureReasonTests
var reader = new TaskCompletionSource();
var worker = new TaskCompletionSource();
List<Exception> detail = [];
Task completion = AgentRuntime.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
Task completion = SingleServerHarness.CompleteConnectionAsync(detail.Add, reader.Task, worker.Task);
var failure = new TimeoutException("Synthetic acknowledgement timeout.");
worker.SetException(failure);
reader.SetResult();
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: MIT
*/
using System.IO.Pipes;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
@@ -20,7 +19,7 @@ public sealed class AgentRealtimeGenerationTests
[InlineData(12, 0, 24000)]
[InlineData(12, 1, 30000)]
public void ReconnectJitterRespectsConfiguredHardDelayBounds(int attempt, double sample, int expected)
=> Assert.Equal(TimeSpan.FromMilliseconds(expected), AgentRuntime.ReconnectDelay(new RealtimeSettings(), attempt, sample));
=> Assert.Equal(TimeSpan.FromMilliseconds(expected), SingleServerHarness.ReconnectDelay(new RealtimeSettings(), attempt, sample));
@@ -54,9 +53,7 @@ public sealed class AgentRealtimeGenerationTests
},
};
int sessions = 0;
string pipe = "qqq-generation-" + Guid.NewGuid().ToString("N");
using var runtime = new AgentRuntime(fixture.Local.DirectoryPath, _ => { }, fixture.Local.Printer, pipeName: pipe,
pipeFactory: () => new NamedPipeServerStream(pipe, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly),
using var runtime = new SingleServerHarness(fixture.Local.DirectoryPath, _ => { }, fixture.Local.Printer,
sessionFactory: _ => Interlocked.Increment(ref sessions) == 1 ? fixture.Session : replacement);
Task running = runtime.RunAsync(timeout.Token);
try
@@ -24,7 +24,7 @@ public sealed class AgentRealtimeMediaCapabilitiesTests
public async Task HelloAdvertisesMediaOnlyAfterStandardDiscoveryConfirmsSupport(string version, bool media, bool approval)
{
await using var fixture = new RealtimeRuntimeFixture(printingVersion: version);
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
ScriptedPrintServer.Connection first = await fixture.ConnectAsync();
string[] expected = approval ? ["realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1"]
: media ? ["realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1"]
: ["realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL"];
@@ -50,7 +50,7 @@ public sealed class AgentRealtimeMediaCapabilitiesTests
public async Task SameSessionReconnectTracksServerUpgradeAndDowngrade(string initial, string replacement, bool mediaAfter)
{
await using var fixture = new RealtimeRuntimeFixture(printingVersion: initial);
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
ScriptedPrintServer.Connection first = await fixture.ConnectAsync();
Assert.Equal(!mediaAfter, fixture.LastHelloCapabilities.Contains("printer-media-v1", StringComparer.Ordinal));
Assert.Equal(2, fixture.DiscoveryRequests);
fixture.PrintingVersion = replacement;
@@ -23,7 +23,7 @@ public sealed class AgentRealtimeOrderingTests
public async Task OlderStateAckCannotReleaseSubmissionBeforeNewSettingsAreAcknowledged()
{
await using var fixture = new RealtimeRuntimeFixture();
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
byte[] bytes = Encoding.ASCII.GetBytes("%PDF-synthetic-slow");
using var slow = new GatedDocument(bytes);
fixture.AddJob("slow", () => slow, length: bytes.Length);
@@ -65,7 +65,7 @@ public sealed class AgentRealtimeOrderingTests
public async Task StickyClaimIsNotRequestedAgainBeforeReceivedAcknowledgement()
{
await using var fixture = new RealtimeRuntimeFixture(downloads: 2);
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
fixture.ReceivedRelease = new(TaskCreationOptions.RunContinuationsAsynchronously);
fixture.AddJob("first");
fixture.AddJob("second", printerId: "other");
@@ -93,7 +93,7 @@ public sealed class AgentRealtimeOrderingTests
public async Task SubmissionUsesCurrentPolicyAfterTransferAndPauseOnlyStopsNewClaims(bool rejectSize)
{
await using var fixture = new RealtimeRuntimeFixture();
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
byte[] bytes = Encoding.ASCII.GetBytes("%PDF-synthetic-slow");
using var slow = new GatedDocument(bytes);
fixture.AddJob("slow", () => slow, length: bytes.Length);
@@ -117,7 +117,7 @@ public sealed class AgentRealtimeOrderingTests
public async Task IncreasingConcurrencyStartsWaitingOtherPrinterDuringAnActiveDownload()
{
await using var fixture = new RealtimeRuntimeFixture(downloads: 1);
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
byte[] bytes = Encoding.ASCII.GetBytes("%PDF-synthetic-slow");
using var slow = new GatedDocument(bytes);
fixture.AddJob("slow", () => slow, length: bytes.Length);
@@ -148,7 +148,7 @@ public sealed class AgentRealtimeOrderingTests
public async Task ParallelDownloadsPreserveQueueOrderWithoutBlockingDifferentPrinters(bool samePrinter)
{
await using var fixture = new RealtimeRuntimeFixture(downloads: 2);
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
byte[] slowBytes = Encoding.ASCII.GetBytes("%PDF-synthetic-slow");
byte[] fastBytes = Encoding.ASCII.GetBytes("%PDF-synthetic-fast");
using var slow = new GatedDocument(slowBytes);
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: MIT
*/
using System.IO.Pipes;
using System.Text.Json;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
@@ -23,17 +22,12 @@ public sealed class AgentRealtimeRecoveryTests
byte[] original = File.ReadAllBytes(path);
byte[] unreadable = [1, 3, 5];
File.WriteAllBytes(path, unreadable);
string name = "qqq-recovery-" + Guid.NewGuid().ToString("N");
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var runtime = new AgentRuntime(fixture.DirectoryPath, _ => { }, fixture.Printer, pipeName: name,
pipeFactory: () => new NamedPipeServerStream(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly));
using var runtime = new SingleServerHarness(fixture.DirectoryPath, _ => { }, fixture.Printer);
Task running = runtime.RunAsync(stop.Token);
try
{
await using var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
await pipe.ConnectAsync(stop.Token);
await AgentPipeFraming.WriteAsync(pipe, JsonSerializer.Serialize(new PipeRequest("snapshot"), PrintApiClient.JsonOptions), AgentPipeFraming.MaximumCommandCharacters, stop.Token);
string response = await AgentPipeFraming.ReadAsync(pipe, AgentPipeFraming.MaximumResponseCharacters, stop.Token);
string response = await runtime.PipeAsync(new PipeRequest("snapshot"), stop.Token);
AgentSnapshot snapshot = JsonSerializer.Deserialize<AgentSnapshot>(response, PrintApiClient.JsonOptions)!;
Assert.False(snapshot.Enrolled);
Assert.False(snapshot.Connected);
@@ -46,7 +40,7 @@ public sealed class AgentRealtimeRecoveryTests
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => running);
File.WriteAllBytes(path, original);
}
using var store = new AgentStore(fixture.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.DirectoryPath);
Assert.Equal("SPOOLED", store.GetState("job"));
Assert.Contains("printer", store.GetExposedPrinterIds());
}
@@ -4,7 +4,6 @@
*/
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
@@ -31,7 +30,7 @@ public sealed class AgentRealtimeRestartTests
Assert.Equal("OUTCOME_UNKNOWN", fixture.State("submitting"));
Assert.Equal("FAILED", fixture.State("received"));
fixture.AddJob("submitting");
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => Reported(fixture, "submitting", "OUTCOME_UNKNOWN") is not null && Reported(fixture, "received", "FAILED") is not null);
Assert.Contains("inspect the printer", Reported(fixture, "submitting", "OUTCOME_UNKNOWN"), StringComparison.Ordinal);
Assert.Contains("before native submission", Reported(fixture, "received", "FAILED"), StringComparison.Ordinal);
@@ -59,7 +58,7 @@ public sealed class AgentRealtimeRestartTests
{
await using RealtimeRuntimeFixture fixture = new();
fixture.Printer.BlockPrint = true;
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
ScriptedPrintServer.Connection first = await fixture.ConnectAsync();
fixture.AddJob("job");
await first.SendAsync("JOB_AVAILABLE", new { });
await fixture.WaitAsync(fixture.Printer.Entered.Task);
@@ -70,7 +69,7 @@ public sealed class AgentRealtimeRestartTests
Assert.Equal("OUTCOME_UNKNOWN", fixture.State("job"));
int claims = fixture.Claims;
fixture.AddJob("job");
RealtimeRuntimeFixture.Connection second = await fixture.ConnectAsync();
ScriptedPrintServer.Connection second = await fixture.ConnectAsync();
await fixture.WaitAsync(() => Reported(fixture, "job", "OUTCOME_UNKNOWN") is not null);
await fixture.WaitAsync(() => fixture.Claims > claims);
await second.SendAsync("JOB_AVAILABLE", new { });
@@ -86,7 +85,7 @@ public sealed class AgentRealtimeRestartTests
/// <summary>Writes the journal a crashed service leaves behind for one job: it was claimed and the listed states were recorded and delivered, the last of them being where the service stopped.</summary>
private static void SeedInterrupted(AgentStore store, string jobId, params string[] states)
private static void SeedInterrupted(JournalStore store, string jobId, params string[] states)
{
store.TrackJob(new PrintJob(jobId, "printer", "Synthetic " + jobId, "PDF", "unused", 1, new PrintOptions(), RealtimeRuntimeFixture.Now(), "CLAIMED"), "Synthetic");
foreach(string state in states)
@@ -15,7 +15,7 @@ public sealed class AgentRealtimeStatusTests
public async Task PausedQueuePreviewRefreshesOnlyOnSignalsIncludingCancellation()
{
await using var fixture = new RealtimeRuntimeFixture();
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await connection.SendAsync("DESIRED_STATE", new DeviceDesiredState(2, true, "CONNECTED"));
await fixture.WaitAsync(() => fixture.Runtime.GetSnapshot().Control!.DesiredState.DispatchPaused);
fixture.AddJob("waiting");
@@ -24,7 +24,7 @@ public sealed class AgentRealtimeTests
public async Task PauseAndCancelRemainResponsiveDuringLargeDownloadWithoutHttpPolling()
{
await using var fixture = new RealtimeRuntimeFixture();
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
using var stalled = new StalledDocument();
fixture.AddJob("large", () => stalled);
await connection.SendAsync("JOB_AVAILABLE", new { });
@@ -80,7 +80,7 @@ public sealed class AgentRealtimeTests
public async Task ReconnectReplaysLostSpoolReceiptAndDrainsBufferedJobsWithoutPrintingAgain()
{
await using var fixture = new RealtimeRuntimeFixture();
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync(loseSpoolAck: true);
ScriptedPrintServer.Connection first = await fixture.ConnectAsync(loseSpoolAck: true);
fixture.AddJob("first");
await first.SendAsync("JOB_AVAILABLE", new { });
await first.LostSpoolAck.Task.WaitAsync(TimeSpan.FromSeconds(5));
@@ -23,7 +23,7 @@ public sealed class AgentRealtimeTransitionTests
_ = await fixture.ConnectAsync();
Assert.Equal(0, fixture.LastHelloSettingsRevision);
Assert.True(fixture.Runtime.GetSnapshot().Authentication!.Authorized);
using var store = new AgentStore(fixture.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.DirectoryPath);
Assert.Equal("replacement-device", store.LoadConfiguration()!.DeviceId);
Assert.Equal(0, store.LoadRealtimeSettings().Revision);
}
@@ -35,7 +35,7 @@ public sealed class AgentRealtimeTransitionTests
public async Task CommandWaitingForStateAcknowledgementRunsAsSoonAsNegotiationCompletes()
{
await using var fixture = new RealtimeRuntimeFixture();
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
fixture.StateAppliedRelease = new(TaskCreationOptions.RunContinuationsAsynchronously);
await connection.SendAsync("DESIRED_STATE", new DeviceDesiredState(2, true, "CONNECTED"));
await fixture.WaitAsync(() => fixture.Messages.Any(message => message.Type is "STATE_APPLIED" && message.Payload.GetProperty("lastAppliedRevision").GetInt64() == 2));
@@ -34,7 +34,7 @@ public sealed class AgentRemoteControlTests
public async Task MissingRequiredStateCannotResumeDispatch(string state)
{
await using RealtimeRuntimeFixture fixture = new();
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(5, true, "CONNECTED"));
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(5, true, "CONNECTED"));
using var malformed = JsonDocument.Parse(state);
await connection.SendAsync("DESIRED_STATE", malformed.RootElement);
await fixture.WaitAsync(() => fixture.Logs.Any(line => line.Contains(Stopped, StringComparison.Ordinal)));
@@ -57,7 +57,7 @@ public sealed class AgentRemoteControlTests
if(interrupted)
{ Assert.True(store.BeginControlCommand(command)); }
});
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
if(!interrupted)
{ await connection.SendAsync("COMMAND", command); }
await fixture.WaitAsync(() => fixture.Count("COMMAND_RECEIPT") > 0);
@@ -79,12 +79,12 @@ public sealed class AgentRemoteControlTests
DeviceControlCommand command = RealtimeRuntimeFixture.Command();
await using RealtimeRuntimeFixture fixture = new(seed: store => RealtimeRuntimeFixture.SeedSpooledJob(store));
fixture.Printer.LoseCancellationResult = true;
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await connection.SendAsync("COMMAND", command);
await fixture.WaitAsync(() => fixture.Count("COMMAND_RECEIPT") == 1);
await connection.SendAsync("COMMAND", command);
await fixture.WaitAsync(() => fixture.Count("COMMAND_RECEIPT") == 2);
using AgentStore store = new(fixture.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.DirectoryPath);
Assert.Equal("OUTCOME_UNKNOWN", store.GetControlReceipt("command")!.Outcome);
Assert.All(Receipts(fixture), receipt => Assert.Equal("OUTCOME_UNKNOWN", receipt.GetProperty("outcome").GetString()));
Assert.Equal(1, fixture.Printer.Cancellations);
@@ -112,9 +112,9 @@ public sealed class AgentRemoteControlTests
store.SaveControlReceipt(old.CommandId, receipt);
});
fixture.RefuseWith = message => message.Type is "COMMAND_RECEIPT" ? "NOT_FOUND" : null;
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => PendingReceipts(fixture) == 0);
using(AgentStore inspected = new(fixture.DirectoryPath))
using(JournalStore inspected = TestState.OpenStore(fixture.DirectoryPath))
{ Assert.Equal(receipt, inspected.GetControlReceipt(old.CommandId)); }
Assert.Equal(0, fixture.Printer.Cancellations);
fixture.RefuseWith = null;
@@ -137,7 +137,7 @@ public sealed class AgentRemoteControlTests
{
await using RealtimeRuntimeFixture fixture = new(seed: store => RealtimeRuntimeFixture.SeedSpooledJob(store));
fixture.RefuseWith = message => message.Type is "COMMAND_RECEIPT" ? "NOT_FOUND" : null;
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
ScriptedPrintServer.Connection first = await fixture.ConnectAsync();
await first.SendAsync("COMMAND", RealtimeRuntimeFixture.Command());
await fixture.WaitAsync(() => fixture.Logs.Any(line => line.Contains(Stopped, StringComparison.Ordinal) && line.Contains("NOT_FOUND", StringComparison.Ordinal)));
Assert.Equal(1, PendingReceipts(fixture));
@@ -162,7 +162,7 @@ public sealed class AgentRemoteControlTests
await using RealtimeRuntimeFixture fixture = new(seed: store => RealtimeRuntimeFixture.SeedSpooledJob(store));
fixture.RefuseWith = message => message.Type is "COMMAND_RECEIPT" ? "CONFLICT" : null;
fixture.CloseAfterRefusal = true;
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await connection.SendAsync("COMMAND", RealtimeRuntimeFixture.Command());
await fixture.WaitAsync(() => fixture.Logs.Any(line => line.Contains(Stopped, StringComparison.Ordinal)));
string stopped = fixture.Logs.First(line => line.Contains(Stopped, StringComparison.Ordinal));
@@ -180,11 +180,11 @@ public sealed class AgentRemoteControlTests
{
const string regressed = "Desired state revision regressed";
await using RealtimeRuntimeFixture fixture = new();
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(5, true, "CONNECTED"));
ScriptedPrintServer.Connection first = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(5, true, "CONNECTED"));
await first.SendAsync("DESIRED_STATE", new DeviceDesiredState(4, false, "CONNECTED"));
await fixture.WaitAsync(() => fixture.Logs.Count(line => line.Contains(regressed, StringComparison.Ordinal)) == 1);
AssertPausedAtRevisionFive(fixture);
RealtimeRuntimeFixture.Connection second = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(5, true, "CONNECTED"));
ScriptedPrintServer.Connection second = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(5, true, "CONNECTED"));
await second.SendAsync("DESIRED_STATE", new DeviceDesiredState(5, false, "CONNECTED"));
await fixture.WaitAsync(() => fixture.Logs.Count(line => line.Contains(regressed, StringComparison.Ordinal)) == 2);
AssertPausedAtRevisionFive(fixture);
@@ -203,12 +203,12 @@ public sealed class AgentRemoteControlTests
DeviceControlCommand command = RealtimeRuntimeFixture.Command();
await using RealtimeRuntimeFixture fixture = new(seed: store => RealtimeRuntimeFixture.SeedSpooledJob(store));
fixture.LoseAcknowledgementOf = message => message.Type is "COMMAND_RECEIPT";
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
ScriptedPrintServer.Connection first = await fixture.ConnectAsync();
await first.SendAsync("COMMAND", command);
await fixture.WaitAsync(first.LostAcknowledgement.Task);
fixture.LoseAcknowledgementOf = null;
await fixture.RestartAsync();
RealtimeRuntimeFixture.Connection second = await fixture.ConnectAsync();
ScriptedPrintServer.Connection second = await fixture.ConnectAsync();
await fixture.WaitAsync(() => fixture.Count("COMMAND_RECEIPT") >= 2 && PendingReceipts(fixture) == 0);
await second.SendAsync("COMMAND", command);
await fixture.WaitAsync(() => fixture.Count("COMMAND_RECEIPT") >= 3);
@@ -231,13 +231,13 @@ public sealed class AgentRemoteControlTests
RealtimeRuntimeFixture.SeedSpooledJob(store);
Assert.True(store.BeginControlCommand(command));
});
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => HasReceipt(fixture, "command", "OUTCOME_UNKNOWN"));
await connection.SendAsync("COMMAND", command);
await fixture.WaitAsync(() => fixture.Count("COMMAND_RECEIPT") >= 2);
Assert.Equal(0, fixture.Printer.Cancellations);
Assert.Single(ReceiptMessageIds(fixture).Distinct(StringComparer.Ordinal));
using AgentStore inspected = new(fixture.DirectoryPath);
using JournalStore inspected = TestState.OpenStore(fixture.DirectoryPath);
Assert.Equal("OUTCOME_UNKNOWN", inspected.GetControlReceipt("command")!.Outcome);
Assert.Equal("SPOOLED", inspected.GetState("job"));
}
@@ -256,20 +256,20 @@ public sealed class AgentRemoteControlTests
DeviceControlCommand command = RealtimeRuntimeFixture.Command();
await using RealtimeRuntimeFixture fixture = new(seed: store => RealtimeRuntimeFixture.SeedSpooledJob(store));
fixture.Printer.CancellationFailure = new NotSupportedException("Synthetic unexpected spooler failure.");
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync();
ScriptedPrintServer.Connection first = await fixture.ConnectAsync();
await first.SendAsync("COMMAND", command);
await fixture.WaitAsync(() => fixture.Logs.Any(line => line.Contains(Stopped, StringComparison.Ordinal) && line.Contains("Synthetic unexpected spooler failure", StringComparison.Ordinal)));
Assert.Equal(1, fixture.Printer.Cancellations);
Assert.Equal(0, fixture.Count("COMMAND_RECEIPT"));
using(AgentStore interrupted = new(fixture.DirectoryPath))
using(JournalStore interrupted = TestState.OpenStore(fixture.DirectoryPath))
{ Assert.Null(interrupted.GetControlReceipt("command")); }
fixture.Printer.CancellationFailure = null;
RealtimeRuntimeFixture.Connection second = await fixture.ConnectAsync();
ScriptedPrintServer.Connection second = await fixture.ConnectAsync();
await second.SendAsync("COMMAND", command);
await fixture.WaitAsync(() => HasReceipt(fixture, "command", "OUTCOME_UNKNOWN") && PendingReceipts(fixture) == 0);
Assert.Equal(1, fixture.Printer.Cancellations);
Assert.Equal(1, fixture.Count("COMMAND_RECEIPT"));
using AgentStore store = new(fixture.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.DirectoryPath);
Assert.Contains("No command was automatically repeated", store.GetControlReceipt("command")!.Message, StringComparison.Ordinal);
Assert.Equal("SPOOLED", store.GetState("job"));
Assert.False(fixture.Running.IsCompleted);
@@ -284,11 +284,11 @@ public sealed class AgentRemoteControlTests
public async Task ExpiryAndMissingOwnershipNeverTouchWindows(bool expired, string expected)
{
await using RealtimeRuntimeFixture fixture = new();
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await connection.SendAsync("COMMAND", RealtimeRuntimeFixture.Command(expired: expired));
await fixture.WaitAsync(() => fixture.Count("COMMAND_RECEIPT") > 0);
Assert.True(HasReceipt(fixture, "command", expected));
using AgentStore store = new(fixture.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.DirectoryPath);
Assert.Equal(expected, store.GetControlReceipt("command")!.Outcome);
Assert.Equal(0, fixture.Printer.Cancellations);
}
@@ -303,12 +303,12 @@ public sealed class AgentRemoteControlTests
{
await using RealtimeRuntimeFixture fixture = new(seed: store => RealtimeRuntimeFixture.SeedSpooledJob(store));
fixture.Printer.CancellationOutcome = outcome;
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await connection.SendAsync("COMMAND", RealtimeRuntimeFixture.Command());
await fixture.WaitAsync(() => fixture.Count("COMMAND_RECEIPT") > 0);
Assert.True(HasReceipt(fixture, "command", outcome));
Assert.Equal(1, fixture.Printer.Cancellations);
using AgentStore store = new(fixture.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.DirectoryPath);
Assert.Equal(outcome, store.GetControlReceipt("command")!.Outcome);
}
@@ -324,7 +324,7 @@ public sealed class AgentRemoteControlTests
{
DeviceControlCommand refresh = RealtimeRuntimeFixture.Command("REFRESH_INVENTORY");
await using RealtimeRuntimeFixture fixture = new();
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(3, true, "DISCONNECTED"));
ScriptedPrintServer.Connection first = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(3, true, "DISCONNECTED"));
await first.SendAsync("COMMAND", refresh);
// Commands are handled one at a time in arrival order, so the receipt of this later one proves the refresh was handled.
await first.SendAsync("COMMAND", RealtimeRuntimeFixture.Command(id: "later", expired: true));
@@ -336,7 +336,7 @@ public sealed class AgentRemoteControlTests
await fixture.WaitAsync(() => fixture.Logs.Any(line => line.Contains(Stopped, StringComparison.Ordinal) && line.Contains("Synthetic outage", StringComparison.Ordinal)));
Assert.Equal("DISCONNECTED", fixture.Runtime.GetSnapshot().Control!.DesiredState.ConnectionState);
fixture.HttpFault = null;
RealtimeRuntimeFixture.Connection second = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(4, false, "CONNECTED"));
ScriptedPrintServer.Connection second = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(4, false, "CONNECTED"));
await fixture.WaitAsync(() => fixture.Count("INVENTORY") == 1);
await second.SendAsync("COMMAND", refresh);
await fixture.WaitAsync(() => HasReceipt(fixture, "command", "INVENTORY_REFRESHED"));
@@ -358,7 +358,7 @@ public sealed class AgentRemoteControlTests
{
var clock = new ManualTimeProvider();
await using RealtimeRuntimeFixture fixture = new(timeProvider: clock, seed: store => RealtimeRuntimeFixture.SeedSpooledJob(store));
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(1, true, "CONNECTED"));
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(1, true, "CONNECTED"));
await connection.SendAsync("ERROR", new { code = "AUTHENTICATION_REQUIRED", message = "Synthetic session ended." });
await fixture.WaitAsync(() => fixture.Logs.Any(line => line.Contains(Stopped, StringComparison.Ordinal)));
await fixture.BackoffPassAsync(clock);
@@ -396,11 +396,10 @@ public sealed class AgentRemoteControlTests
public async Task ControlStateSavedByThePollingPrereleaseIsReadButNotHonored()
{
await using RealtimeRuntimeFixture fixture = new();
using(AgentStore store = new(fixture.DirectoryPath))
using(JournalStore store = TestState.OpenStore(fixture.DirectoryPath))
{ store.SaveControlState(AgentControlState.Initial); }
using(var journal = new Microsoft.Data.Sqlite.SqliteConnection(new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder { DataSource = Path.Combine(fixture.DirectoryPath, "journal.db") }.ToString()))
using(Microsoft.Data.Sqlite.SqliteConnection journal = TestState.OpenJournal(fixture.DirectoryPath))
{
journal.Open();
using Microsoft.Data.Sqlite.SqliteCommand overwrite = journal.CreateCommand();
overwrite.CommandText = """UPDATE control_state SET json='{"support":"LEGACY","desiredState":{"revision":0,"dispatchPaused":false,"connectionState":"CONNECTED"},"authorizationRequired":true}'""";
Assert.Equal(1, overwrite.ExecuteNonQuery());
@@ -425,7 +424,7 @@ public sealed class AgentRemoteControlTests
DeviceDesiredState live = fixture.Runtime.GetSnapshot().Control!.DesiredState;
Assert.True(live.DispatchPaused);
Assert.Equal(5, live.Revision);
using AgentStore store = new(fixture.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.DirectoryPath);
DeviceDesiredState saved = store.LoadControlState().DesiredState;
Assert.True(saved.DispatchPaused);
Assert.Equal(5, saved.Revision);
@@ -456,7 +455,7 @@ public sealed class AgentRemoteControlTests
/// <summary>How many receipts the store still holds for delivery.</summary>
private static int PendingReceipts(RealtimeRuntimeFixture fixture)
{
using AgentStore store = new(fixture.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.DirectoryPath);
return store.PendingControlReceipts().Count;
}
}
@@ -34,7 +34,7 @@ public sealed class AgentRuntimeBacklogTests
await using RealtimeRuntimeFixture fixture = new();
fixture.ClaimHold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
fixture.FailEmptyClaims = failWhenEmpty;
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(fixture.ClaimArrived.Task);
await connection.SendAsync("DESIRED_STATE", new DeviceDesiredState(2, false, "CONNECTED"));
await connection.SendAsync("SETTINGS", fixture.Settings with { Revision = 2 });
@@ -22,7 +22,7 @@ public sealed class AgentRuntimeHeartbeatTests
var clock = new ManualTimeProvider();
await using RealtimeRuntimeFixture fixture = new(timeProvider: clock);
fixture.Printer.BlockPrint = true;
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
fixture.AddJob("job");
await connection.SendAsync("JOB_AVAILABLE", new { });
await fixture.WaitAsync(fixture.Printer.Entered.Task);
@@ -53,7 +53,7 @@ public sealed class AgentRuntimeHeartbeatTests
{
var clock = new ManualTimeProvider();
await using RealtimeRuntimeFixture fixture = new(timeProvider: clock);
await using RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
await using ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await fixture.HeartbeatAsync(clock);
await fixture.WaitAsync(() => fixture.Count("HEARTBEAT") == 1);
DateTimeOffset afterFirst = clock.GetUtcNow();
@@ -150,8 +150,8 @@ public sealed class AgentRuntimePipeResponseTests
private sealed class Fixture : IAsyncDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "qqq-pipe-response-" + Guid.NewGuid().ToString("N"));
private readonly AgentRuntime _runtime;
public string PipeName { get; } = "QqqPrintAgent-test-response-" + Guid.NewGuid().ToString("N");
private readonly SingleServerHarness _runtime;
public string PipeName => _runtime.PipeName;
public CancellationTokenSource Stop { get; } = new(TimeSpan.FromSeconds(15));
public Task Running { get; }
@@ -160,9 +160,7 @@ public sealed class AgentRuntimePipeResponseTests
/// <summary>Starts the runtime on a uniquely named private pipe with small buffers, so tests never touch the installed service's pipe.</summary>
public Fixture()
{
_runtime = new(_directory, _ => { }, new NoPrinters(), pipeName: PipeName,
pipeFactory: () => new NamedPipeServerStream(PipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, 4096, 4096));
_runtime = new(_directory, _ => { }, new NoPrinters(), pipeBufferSize: 4096);
Running = _runtime.RunAsync(Stop.Token);
}
@@ -50,7 +50,7 @@ public sealed class AgentRuntimeProfileTests
await fixture.HeartbeatAsync(clock);
await fixture.WaitAsync(() => DescribedDevice(fixture) is not null);
Assert.Equal(device, DescribedDevice(fixture));
using AgentStore persisted = new(fixture.DirectoryPath);
using JournalStore persisted = TestState.OpenStore(fixture.DirectoryPath);
Assert.Equal(device, persisted.GetDeviceProfile());
Assert.Equal(printer, persisted.GetPrinterProfile("printer"));
Assert.Equal("device", persisted.LoadConfiguration()!.DeviceId);
@@ -21,7 +21,7 @@ public sealed class AgentRuntimeQueuedJobsTests
public async Task QueuedJobAppearsAndCanBeCanceledBeforeAnyLocalReceipt()
{
await using RealtimeRuntimeFixture fixture = new(seed: store => store.SavePrinterProfile("printer", new PrinterProfile("Dispatch labels")));
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(1, true, "CONNECTED"));
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync(desiredState: new DeviceDesiredState(1, true, "CONNECTED"));
fixture.AddJob("queued-job");
await connection.SendAsync("JOB_AVAILABLE", new { });
await fixture.WaitAsync(() => fixture.Runtime.GetSnapshot().Jobs.Count > 0);
@@ -53,7 +53,7 @@ public sealed class AgentRuntimeQueuedJobsTests
/// <summary>What the local journal holds for a job; null means the job was never received by this computer.</summary>
private static JobDetailsSnapshot? Details(RealtimeRuntimeFixture fixture, string jobId)
{
using AgentStore store = new(fixture.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.DirectoryPath);
return store.GetJobDetails(jobId);
}
}
@@ -55,7 +55,7 @@ public sealed class AgentRuntimeTests
string denied = await fixture.PipeAsync(new PipeRequest("test-print", PrinterId: "other", Format: "PDF"));
Assert.StartsWith("Error: Share the printer", denied, StringComparison.Ordinal);
Assert.Empty(fixture.OperatorActions);
using(AgentStore store = new(fixture.DirectoryPath))
using(JournalStore store = TestState.OpenStore(fixture.DirectoryPath))
{
store.SetExposure("other", true, fixture.Printer.Discover());
store.Record("failed-job", new PrintEvent("test-failed", "FAILED", "Synthetic failure", null));
@@ -33,7 +33,7 @@ public sealed class AgentRuntimeUpdateTests
var clock = new ManualTimeProvider();
await using RealtimeRuntimeFixture fixture = new(timeProvider: clock);
fixture.Printer.BlockPrint = true;
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
fixture.AddJob("active");
await connection.SendAsync("JOB_AVAILABLE", new { });
await fixture.WaitAsync(fixture.Printer.Entered.Task);
@@ -85,7 +85,7 @@ public sealed class AgentRuntimeUpdateTests
public async Task UpdatePreparationLetsAJobThatIsStillDownloadingFinishBeforeItAnswers()
{
await using RealtimeRuntimeFixture fixture = new(timeProvider: new ManualTimeProvider());
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
byte[] bytes = Encoding.ASCII.GetBytes("%PDF-synthetic-downloading");
using var document = new GatedDocument(bytes);
fixture.AddJob("downloading", () => document, length: bytes.Length);
@@ -126,7 +126,7 @@ public sealed class AgentRuntimeUpdateTests
public async Task UpdatePreparationWaitsForAClaimInFlightAndPrintsItsJob()
{
await using RealtimeRuntimeFixture fixture = new(timeProvider: new ManualTimeProvider());
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => fixture.LastClaimWasEmpty);
int claims = fixture.Claims;
fixture.ClaimHold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -156,7 +156,7 @@ public sealed class AgentRuntimeUpdateTests
{
var clock = new ManualTimeProvider();
await using RealtimeRuntimeFixture fixture = new(timeProvider: clock);
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
byte[] bytes = Encoding.ASCII.GetBytes("%PDF-synthetic-slow");
using var document = new GatedDocument(bytes);
fixture.AddJob("slow", () => document, length: bytes.Length);
@@ -165,9 +165,9 @@ public sealed class AgentRuntimeUpdateTests
try
{
Task<string> preparation = fixture.PipeAsync(new PipeRequest("prepare-update"));
await fixture.WaitAsync(() => clock.HasTimerDueIn(AgentRuntime.UpdatePreparationLimit));
await fixture.WaitAsync(() => clock.HasTimerDueIn(SingleServerHarness.UpdatePreparationLimit));
Assert.False(preparation.IsCompleted);
clock.Advance(AgentRuntime.UpdatePreparationLimit);
clock.Advance(SingleServerHarness.UpdatePreparationLimit);
string reply = await preparation;
Assert.StartsWith("Not ready for update: 1 job(s) were still in progress", reply, StringComparison.Ordinal);
Assert.Equal("The update was not started, and the current version remains installed. " + reply, MsiUpdateService.PreparationNotCompleted(reply));
@@ -195,7 +195,7 @@ public sealed class AgentRuntimeUpdateTests
{
var clock = new ManualTimeProvider();
await using RealtimeRuntimeFixture fixture = new(timeProvider: clock);
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
await fixture.WaitAsync(() => fixture.LastClaimWasEmpty);
int claims = fixture.Claims;
fixture.ClaimHold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -203,8 +203,8 @@ public sealed class AgentRuntimeUpdateTests
await connection.SendAsync("JOB_AVAILABLE", new { });
await fixture.WaitAsync(() => fixture.Claims > claims);
Task<string> preparation = fixture.PipeAsync(new PipeRequest("prepare-update"));
await fixture.WaitAsync(() => clock.HasTimerDueIn(AgentRuntime.UpdatePreparationLimit));
clock.Advance(AgentRuntime.UpdatePreparationLimit);
await fixture.WaitAsync(() => clock.HasTimerDueIn(SingleServerHarness.UpdatePreparationLimit));
clock.Advance(SingleServerHarness.UpdatePreparationLimit);
string reply = await preparation;
Assert.StartsWith("Not ready for update: a claim was still in progress after 25 seconds.", reply, StringComparison.Ordinal);
Assert.DoesNotContain("job(s)", reply, StringComparison.Ordinal);
@@ -232,7 +232,7 @@ public sealed class AgentRuntimeUpdateTests
public async Task ServerStateArrivingDuringUpdatePreparationIsAppliedAndTheClaimedJobFinishes(string frame)
{
await using RealtimeRuntimeFixture fixture = new(timeProvider: new ManualTimeProvider());
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
byte[] bytes = Encoding.ASCII.GetBytes("%PDF-synthetic-during");
using var document = new GatedDocument(bytes);
fixture.AddJob("during", () => document, length: bytes.Length);
@@ -274,7 +274,7 @@ public sealed class AgentRuntimeUpdateTests
public async Task UpdatePreparationInterruptedByAResumeDoesNotAnswerReady()
{
await using RealtimeRuntimeFixture fixture = new(timeProvider: new ManualTimeProvider());
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
byte[] bytes = Encoding.ASCII.GetBytes("%PDF-synthetic-resumed");
using var document = new GatedDocument(bytes);
fixture.AddJob("resumed", () => document, length: bytes.Length);
@@ -314,7 +314,7 @@ public sealed class AgentRuntimeUpdateTests
using var signInReachedTheServer = new ManualResetEventSlim();
using var letSignInContinue = new ManualResetEventSlim();
await using RealtimeRuntimeFixture fixture = new(timeProvider: new ManualTimeProvider());
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
byte[] bytes = Encoding.ASCII.GetBytes("%PDF-synthetic-sign-in");
using var document = new GatedDocument(bytes);
fixture.AddJob("cancelled", () => document, length: bytes.Length);
@@ -363,7 +363,7 @@ public sealed class AgentRuntimeUpdateTests
{
var clock = new ManualTimeProvider();
await using RealtimeRuntimeFixture fixture = new(timeProvider: clock);
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
Assert.Equal("Ready for update.", await fixture.PipeAsync(new PipeRequest("prepare-update")));
int heartbeats = fixture.Count("HEARTBEAT");
fixture.AddJob("waiting");
@@ -373,7 +373,7 @@ public sealed class AgentRuntimeUpdateTests
await fixture.WaitAsync(() => fixture.Count("HEARTBEAT") > heartbeats);
Assert.StartsWith(Paused, fixture.Runtime.GetSnapshot().Status, StringComparison.Ordinal);
Assert.Null(fixture.State("waiting"));
clock.Advance(AgentRuntime.UpdatePauseDuration);
clock.Advance(SingleServerHarness.UpdatePauseDuration);
await fixture.WaitAsync(() => fixture.State("waiting") is "SPOOLED");
Assert.Contains(fixture.Logs, line => line.StartsWith("The update pause ran out", StringComparison.Ordinal));
Assert.False(fixture.Runtime.GetSnapshot().Status.StartsWith(Paused, StringComparison.Ordinal));
@@ -17,12 +17,12 @@ public sealed class AgentServerTrustTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-trust-" + Guid.NewGuid().ToString("N"));
try
{
using(AgentStore store = new(directory))
using(JournalStore store = TestState.OpenStore(directory))
{
Assert.False(store.LoadServerTrust().AllowUntrustedCertificate);
store.SaveServerTrust(new ServerTrust(AllowUntrustedCertificate: true, Origin: "https://127.0.0.1:18581"));
}
using(AgentStore store = new(directory, recoverUnreadableConfiguration: true))
using(JournalStore store = TestState.OpenStore(directory, recoverUnreadableConfiguration: true))
{
ServerTrust trust = store.LoadServerTrust();
Assert.True(trust.AllowUntrustedCertificate);
@@ -26,7 +26,7 @@ public sealed class AgentSessionIntegrationTests
{
using SessionFixture fixture = new();
fixture.SaveSession();
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
Assert.True(await runtime.EnsureAuthorizationAsync(CancellationToken.None));
fixture.Session.ResumeFailure = QqqSessionException.FailureKind.TransportRefused;
await Assert.ThrowsAsync<QqqSessionException>(() => fixture.EvaluateAsync(runtime));
@@ -49,7 +49,7 @@ public sealed class AgentSessionIntegrationTests
File.Delete(Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi"));
fixture.Session.Binding = fixture.Session.Binding with { DeviceId = null };
fixture.SaveSession();
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
await fixture.RunOnePassAsync(runtime);
Assert.True(runtime.GetSnapshot().Authentication!.SignedIn);
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
@@ -66,7 +66,7 @@ public sealed class AgentSessionIntegrationTests
using SessionFixture fixture = new();
fixture.Factory = origin => string.Equals(origin.AbsolutePath, "/", StringComparison.Ordinal)
? fixture.Session : new QqqSessionClient(origin);
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
QqqSessionCredential invalid = new(new Uri("https://synthetic.invalid/not-an-origin"), "synthetic-cookie");
await Assert.ThrowsAsync<ArgumentException>(() => runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: invalid), CancellationToken.None));
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
@@ -82,13 +82,13 @@ public sealed class AgentSessionIntegrationTests
{
using SessionFixture fixture = new();
fixture.Local.SeedSpooledJob();
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
await fixture.RunOnePassAsync(runtime);
Assert.Equal(0, fixture.Created);
fixture.AssertSentNothing(fixture.Session);
Assert.Contains(fixture.Logs, line => line.StartsWith("No saved QQQ session", StringComparison.Ordinal));
Assert.False(runtime.GetSnapshot().Connected);
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("device", store.LoadConfiguration()!.DeviceId);
Assert.Equal("SPOOLED", store.GetState("job"));
}
@@ -103,17 +103,14 @@ public sealed class AgentSessionIntegrationTests
public async Task ReconnectWithoutAUsableSignInSaysASignInIsRequired()
{
using SessionFixture fixture = new();
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(30));
Task running = runtime.RunAsync(stop.Token);
try
{
while(fixture.Clock.PendingTimers == 0)
{ await Task.Delay(10, stop.Token); }
await using var pipe = new System.IO.Pipes.NamedPipeClientStream(".", fixture.PipeName, System.IO.Pipes.PipeDirection.InOut, System.IO.Pipes.PipeOptions.Asynchronous);
await pipe.ConnectAsync(stop.Token);
await AgentPipeFraming.WriteAsync(pipe, JsonSerializer.Serialize(new PipeRequest("reconnect"), PrintApiClient.JsonOptions), AgentPipeFraming.MaximumCommandCharacters, stop.Token);
string reply = await AgentPipeFraming.ReadAsync(pipe, AgentPipeFraming.MaximumResponseCharacters, stop.Token);
string reply = await runtime.PipeAsync(new PipeRequest("reconnect"), stop.Token);
Assert.StartsWith("Sign-in is required", reply, StringComparison.Ordinal);
Assert.DoesNotContain("Connection retry requested.", fixture.Logs, StringComparer.Ordinal);
fixture.AssertSentNothing(fixture.Session);
@@ -146,7 +143,7 @@ public sealed class AgentSessionIntegrationTests
throw new OperationCanceledException(token);
};
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(30));
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
Task running = runtime.RunAsync(stop.Token);
try
{
@@ -183,12 +180,12 @@ public sealed class AgentSessionIntegrationTests
using SessionFixture fixture = new();
fixture.SaveSession();
fixture.Session.Binding = fixture.Session.Binding with { PrincipalId = "different-user" };
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
await fixture.RunOnePassAsync(runtime);
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
Assert.Equal(1, fixture.Created);
fixture.AssertSentNothing(fixture.Session);
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("principal", store.LoadAuthentication()!.Binding.PrincipalId);
}
@@ -204,7 +201,7 @@ public sealed class AgentSessionIntegrationTests
{
using SessionFixture fixture = new();
fixture.Local.SeedSpooledJob();
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
PipeRequest request = new("sign-in", SessionCredential: fixture.Session.ExportCredential(), ClaimLegacyDevice: true);
Assert.DoesNotContain("synthetic-qqq-cookie", request.ToString(), StringComparison.Ordinal);
_ = await runtime.SignInAsync(request, CancellationToken.None);
@@ -213,7 +210,7 @@ public sealed class AgentSessionIntegrationTests
await fixture.RunOnePassAsync(runtime);
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
fixture.AssertSentNothing(fixture.Session);
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Null(store.LoadAuthentication()!.Credential);
Assert.Equal("principal", store.LoadAuthentication()!.Binding.PrincipalId);
Assert.Equal("device", store.LoadConfiguration()!.DeviceId);
@@ -34,16 +34,16 @@ public sealed class AgentSessionRecoveryTests
fixture.Local.SeedSpooledJob();
byte[] corrupt = ProtectedData.Protect(Encoding.UTF8.GetBytes(json), null, DataProtectionScope.CurrentUser);
File.WriteAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi"), corrupt);
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using(AgentStore unreadable = new(fixture.Local.DirectoryPath))
using(JournalStore unreadable = TestState.OpenStore(fixture.Local.DirectoryPath))
{
Assert.Throws<InvalidDataException>(() => unreadable.LoadAuthentication());
}
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token);
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
Assert.Equal(corrupt, File.ReadAllBytes(Assert.Single(Directory.GetFiles(fixture.Local.DirectoryPath, "qqq-session.recovery-*.dpapi"))));
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("SPOOLED", store.GetState("job"));
Assert.NotNull(store.LoadAuthentication()!.Binding);
}
@@ -59,7 +59,7 @@ public sealed class AgentSessionRecoveryTests
fixture.Local.SeedSpooledJob();
byte[] corrupt = [1, 2, 3, 4];
File.WriteAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi"), corrupt);
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
fixture.Session.DenyBinding = true;
await Assert.ThrowsAsync<Qqq.PrintAgent.Core.Authentication.QqqSessionException>(() => runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token));
@@ -68,7 +68,7 @@ public sealed class AgentSessionRecoveryTests
fixture.Session.DenyBinding = false;
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token);
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("device", store.LoadConfiguration()!.DeviceId);
Assert.Equal("SPOOLED", store.GetState("job"));
Assert.Contains("printer", store.GetExposedPrinterIds());
@@ -93,7 +93,7 @@ public sealed class AgentSessionRecoveryTests
byte[] corrupt = [5, 6, 7, 8];
File.WriteAllBytes(path, corrupt);
fixture.Session.Binding = fixture.Session.Binding with { DeviceId = null };
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
Assert.False(runtime.GetSnapshot().Enrolled);
Assert.Equal(corrupt, File.ReadAllBytes(path));
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
@@ -104,7 +104,7 @@ public sealed class AgentSessionRecoveryTests
_ = await runtime.EnrollWithGatesAsync(new PipeRequest("enroll", "https://synthetic.invalid", "synthetic-code"), timeout.Token);
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
Assert.Equal(corrupt, File.ReadAllBytes(Assert.Single(Directory.GetFiles(fixture.Local.DirectoryPath, "connection.recovery-*.dpapi"))));
using(AgentStore store = new(fixture.Local.DirectoryPath))
using(JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath))
{
Assert.Equal("replacement-device", store.LoadConfiguration()!.DeviceId);
Assert.Null(store.GetState("job"));
@@ -112,7 +112,7 @@ public sealed class AgentSessionRecoveryTests
}
// Restore only the fixture's old proof to verify its journal was retained, not reassigned.
File.WriteAllBytes(path, original);
using AgentStore originalStore = new(fixture.Local.DirectoryPath);
using JournalStore originalStore = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("SPOOLED", originalStore.GetState("job"));
}
@@ -127,13 +127,13 @@ public sealed class AgentSessionRecoveryTests
byte[] corrupt = [9, 10, 11];
string path = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
File.WriteAllBytes(path, corrupt);
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
Assert.Contains("revoke the previous session in QQQ", await runtime.SignOutAsync(timeout.Token), StringComparison.Ordinal);
Assert.False(File.Exists(path));
Assert.Equal(corrupt, File.ReadAllBytes(Assert.Single(Directory.GetFiles(fixture.Local.DirectoryPath, "qqq-session.recovery-*.dpapi"))));
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
using AgentStore store = new(fixture.Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(fixture.Local.DirectoryPath);
Assert.Equal("device", store.LoadConfiguration()!.DeviceId);
Assert.Equal("SPOOLED", store.GetState("job"));
Assert.Contains("printer", store.GetExposedPrinterIds());
@@ -43,14 +43,14 @@ public sealed class AgentStoreOpenRaceTests
try
{
// Loads the SQLite provider and the store's types, so the thread below has nothing else to wait for.
using(AgentStore warm = new(Path.Combine(root, "warm")))
using(JournalStore warm = TestState.OpenStore(Path.Combine(root, "warm")))
{ Assert.Null(warm.GetState("none")); }
Exception? failure = null;
var opening = new Thread(() =>
{
try
{
using AgentStore store = new(folder);
using JournalStore store = TestState.OpenStore(folder);
_ = store.GetState("none");
}
catch(Exception exception) when(exception is SqliteException or IOException or InvalidOperationException)
@@ -59,7 +59,7 @@ public sealed class AgentStoreOpenRaceTests
bool reachedTheLock;
bool finishedWhileHeld;
bool openedWhileHeld;
lock(AgentStore.SchemaLockFor(folder))
lock(TestState.SchemaLockFor(folder))
{
opening.Start();
reachedTheLock = SpinWait.SpinUntil(() => Directory.Exists(folder) && opening.ThreadState.HasFlag(ThreadState.WaitSleepJoin), TimeSpan.FromSeconds(30));
@@ -101,7 +101,7 @@ public sealed class AgentStoreOpenRaceTests
try
{
// Kept open so that closing the round's stores never closes the journal, which would checkpoint it every round.
using AgentStore keeper = new(directory);
using JournalStore keeper = TestState.OpenStore(directory);
using var writing = new SemaphoreSlim(1, 1);
using var together = new Barrier(Workers, _ => JournalFiles.Release(directory));
Thread[] threads = Enumerable.Range(0, Workers).Select(worker => new Thread(() => RunRounds(directory, worker, together, writing, failures))).ToArray();
@@ -135,7 +135,7 @@ public sealed class AgentStoreOpenRaceTests
{ throw new TimeoutException("The threads did not all become ready to open."); }
if(!failures.IsEmpty)
{ continue; }
using AgentStore store = new(directory);
using JournalStore store = TestState.OpenStore(directory);
for(int query = 0; query < 4; query++)
{ _ = store.QueryJobs(new JobQuery()); }
if(round % WriteEvery == worker)
@@ -151,7 +151,7 @@ public sealed class AgentStoreOpenRaceTests
/// <summary>Records an event and saves a profile through the store, then reads both back; one thread writes at a time.</summary>
private static void WriteAndReadBack(AgentStore store, int worker, int round, SemaphoreSlim writing, ConcurrentQueue<string> failures)
private static void WriteAndReadBack(JournalStore store, int worker, int round, SemaphoreSlim writing, ConcurrentQueue<string> failures)
{
writing.Wait();
try
@@ -32,7 +32,7 @@ public sealed class AgentStoreScaleTests(ITestOutputHelper output)
string directory = Path.Combine(Path.GetTempPath(), "qqq-scale-" + Guid.NewGuid().ToString("N"));
try
{
using AgentStore store = new(directory);
using JournalStore store = TestState.OpenStore(directory);
long start = Stopwatch.GetTimestamp();
Seed(directory, count, pending: false);
TimeSpan seedElapsed = Stopwatch.GetElapsedTime(start);
@@ -87,7 +87,7 @@ public sealed class AgentStoreScaleTests(ITestOutputHelper output)
Task acknowledging = server.RunAsync(limit.Token);
try
{
using AgentStore store = new(directory);
using JournalStore store = TestState.OpenStore(directory);
Seed(directory, 10000, pending: true);
using SessionFixture.FakeSession session = new();
using PrintApiClient api = PrintApiClient.ForSession(session).WithRealtime(channel, new RealtimeSettings());
@@ -144,7 +144,7 @@ public sealed class AgentStoreScaleTests(ITestOutputHelper output)
string directory = Path.Combine(Path.GetTempPath(), "qqq-query-bound-" + Guid.NewGuid().ToString("N"));
try
{
using(AgentStore store = new(directory))
using(JournalStore store = TestState.OpenStore(directory))
{
Seed(directory, 10000, pending: false);
}
@@ -154,7 +154,7 @@ public sealed class AgentStoreScaleTests(ITestOutputHelper output)
command.CommandText = "UPDATE local_jobs SET last_event_json='not-json' WHERE job_id='job000000'; INSERT INTO events(job_id,event_id,state,json,connection_id) VALUES('legacy-only','legacy-only','SPOOLED','not-json','unenrolled');";
command.ExecuteNonQuery();
}
using AgentStore reopened = new(directory);
using JournalStore reopened = TestState.OpenStore(directory);
Assert.Null(reopened.GetState("legacy-only")); // Existing summary migration is not replayed on every open.
Assert.Single(reopened.QueryJobs(new JobQuery(Search: "Packing 000001")).Jobs);
Assert.Equal(10000, reopened.QueryJobs(new JobQuery(PageSize: 1)).TotalCount);
@@ -168,12 +168,7 @@ public sealed class AgentStoreScaleTests(ITestOutputHelper output)
/// <summary>Opens a direct connection to the store's journal file for seeding and tampering.</summary>
private static SqliteConnection Open(string directory)
{
SqliteConnection database = new(new SqliteConnectionStringBuilder { DataSource = Path.Combine(directory, "journal.db") }.ToString());
database.Open();
return database;
}
private static SqliteConnection Open(string directory) => TestState.OpenJournal(directory);
@@ -26,19 +26,18 @@ public sealed class AgentStoreTests
try
{
// Initialize the Windows SQLite provider through a separate valid store.
using(AgentStore valid = new(Path.Combine(directory, "valid")))
using(JournalStore valid = TestState.OpenStore(Path.Combine(directory, "valid")))
{
Assert.Null(valid.GetState("none"));
}
string path = Path.Combine(directory, "journal.db");
using(var database = new Microsoft.Data.Sqlite.SqliteConnection(new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder { DataSource = path }.ToString()))
using(Microsoft.Data.Sqlite.SqliteConnection database = TestState.OpenJournal(directory))
{
database.Open();
using Microsoft.Data.Sqlite.SqliteCommand command = database.CreateCommand();
command.CommandText = "CREATE TABLE events(unexpected TEXT)";
command.ExecuteNonQuery();
}
Assert.Throws<Microsoft.Data.Sqlite.SqliteException>(() => new AgentStore(directory));
Assert.Throws<Microsoft.Data.Sqlite.SqliteException>(() => TestState.OpenStore(directory));
JournalFiles.Release(directory);
using FileStream exclusive = new(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
Assert.True(exclusive.Length > 0);
@@ -75,7 +74,7 @@ public sealed class AgentStoreTests
{
if(!together.SignalAndWait(TimeSpan.FromSeconds(30)))
{ throw new TimeoutException("The stores did not all become ready to open."); }
using AgentStore store = new(spellings[index % spellings.Length]);
using JournalStore store = TestState.OpenStore(spellings[index % spellings.Length]);
store.SavePrinterProfile("printer-" + index.ToString(System.Globalization.CultureInfo.InvariantCulture), new PrinterProfile("Queue " + index.ToString(System.Globalization.CultureInfo.InvariantCulture)));
}
catch(Exception exception) when(exception is SqliteException or IOException or InvalidOperationException or TimeoutException or BarrierPostPhaseException)
@@ -86,7 +85,7 @@ public sealed class AgentStoreTests
foreach(Thread open in opens)
{ open.Join(); }
Assert.Empty(failures);
using AgentStore reopened = new(directory);
using JournalStore reopened = TestState.OpenStore(directory);
Assert.All(Enumerable.Range(0, stores), index => Assert.Equal("Queue " + index.ToString(System.Globalization.CultureInfo.InvariantCulture),
reopened.GetPrinterProfile("printer-" + index.ToString(System.Globalization.CultureInfo.InvariantCulture)).DisplayName));
}
@@ -113,7 +112,7 @@ public sealed class AgentStoreTests
string free = Path.Combine(root, "free");
try
{
using(AgentStore first = new(held))
using(JournalStore first = TestState.OpenStore(held))
{ Assert.Null(first.GetState("none")); }
Exception? failure = null;
using var finished = new ManualResetEventSlim();
@@ -121,14 +120,14 @@ public sealed class AgentStoreTests
{
try
{
using AgentStore other = new(free);
using JournalStore other = TestState.OpenStore(free);
other.SavePrinterProfile("printer", new PrinterProfile("Free queue"));
}
catch(Exception exception) when(exception is SqliteException or IOException or InvalidOperationException)
{ failure = exception; }
finally { finished.Set(); }
});
Lock schemaLock = AgentStore.SchemaLockFor(held);
Lock schemaLock = TestState.SchemaLockFor(held);
bool finishedWhileHeld;
lock(schemaLock)
{
@@ -139,10 +138,10 @@ public sealed class AgentStoreTests
Assert.True(finishedWhileHeld, "A store on another folder waited for this journal's schema lock.");
Assert.Null(failure);
// Compared as locks, not as objects: a lock passed as an object would lose its meaning to the compiler.
Assert.True(schemaLock == AgentStore.SchemaLockFor(held.ToUpperInvariant()));
Assert.True(schemaLock == AgentStore.SchemaLockFor(held + Path.DirectorySeparatorChar));
Assert.True(schemaLock != AgentStore.SchemaLockFor(free));
using AgentStore reopened = new(free);
Assert.True(schemaLock == TestState.SchemaLockFor(held.ToUpperInvariant()));
Assert.True(schemaLock == TestState.SchemaLockFor(held + Path.DirectorySeparatorChar));
Assert.True(schemaLock != TestState.SchemaLockFor(free));
using JournalStore reopened = TestState.OpenStore(free);
Assert.Equal("Free queue", reopened.GetPrinterProfile("printer").DisplayName);
}
finally
@@ -162,12 +161,12 @@ public sealed class AgentStoreTests
string printerName = new string('p', 1024);
try
{
using(AgentStore store = new(directory))
using(JournalStore store = TestState.OpenStore(directory))
{
store.TrackJob(new PrintJob("long-job", "printer", title, "PDF", "00", 1, new PrintOptions(), "", "CLAIMED"), printerName);
store.Record("long-job", new PrintEvent("received", "RECEIVED"));
}
using AgentStore restored = new(directory);
using JournalStore restored = TestState.OpenStore(directory);
JobDetailsSnapshot details = Assert.IsType<JobDetailsSnapshot>(restored.GetJobDetails("long-job"));
Assert.Equal(title, details.Job.Title);
Assert.Equal(printerName, details.Job.PrinterName);
@@ -188,7 +187,7 @@ public sealed class AgentStoreTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-job-attention-" + Guid.NewGuid().ToString("N"));
try
{
using AgentStore store = new(directory);
using JournalStore store = TestState.OpenStore(directory);
store.Record("old-failure", new PrintEvent("old-event", "OUTCOME_UNKNOWN"));
for(int index = 0; index < 55; index++)
{
@@ -220,7 +219,7 @@ public sealed class AgentStoreTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-job-details-" + Guid.NewGuid().ToString("N"));
try
{
using(AgentStore store = new(directory))
using(JournalStore store = TestState.OpenStore(directory))
{
PrintJob job = new("job", "printer", "Packing slip", "PDF", "00", 1, new PrintOptions(), "", "CLAIMED");
store.TrackJob(job, "Warehouse Laser");
@@ -232,7 +231,7 @@ public sealed class AgentStoreTests
Assert.Equal(0, store.NeedsAttentionCount());
Assert.Single(store.Pending()); // Operator review cannot acknowledge server delivery.
}
using(AgentStore restored = new(directory))
using(JournalStore restored = TestState.OpenStore(directory))
{
JobDetailsSnapshot details = Assert.IsType<JobDetailsSnapshot>(restored.GetJobDetails("job"));
Assert.Equal("Packing slip", details.Job.Title);
@@ -264,7 +263,7 @@ public sealed class AgentStoreTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-job-retention-" + Guid.NewGuid().ToString("N"));
try
{
using AgentStore store = new(directory);
using JournalStore store = TestState.OpenStore(directory);
store.Record("completed", new PrintEvent("completed-event", "SPOOLED", SpoolerJobId: "123"));
store.Acknowledge("completed-event");
store.Record("unreviewed", new PrintEvent("unreviewed-event", "OUTCOME_UNKNOWN"));
@@ -311,7 +310,7 @@ public sealed class AgentStoreTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-print-recovery-" + Guid.NewGuid().ToString("N"));
try
{
using(AgentStore original = new(directory))
using(JournalStore original = TestState.OpenStore(directory))
{
original.SaveConfiguration(new AgentConfiguration("https://example.com", "old-device", "old-token"));
original.Record("old-job", new PrintEvent("old-event", "SUBMITTING"));
@@ -319,9 +318,9 @@ public sealed class AgentStoreTests
string path = Path.Combine(directory, "connection.dpapi");
byte[] unreadable = [1, 2, 3, 4];
File.WriteAllBytes(path, unreadable);
Assert.Throws<System.Security.Cryptography.CryptographicException>(() => new AgentStore(directory));
Assert.Throws<System.Security.Cryptography.CryptographicException>(() => TestState.OpenStore(directory));
Assert.Equal(unreadable, File.ReadAllBytes(path));
using(AgentStore recovery = new(directory, recoverUnreadableConfiguration: true))
using(JournalStore recovery = TestState.OpenStore(directory, recoverUnreadableConfiguration: true))
{
Assert.Empty(recovery.Pending());
Assert.Empty(recovery.Incomplete());
@@ -351,11 +350,11 @@ public sealed class AgentStoreTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-print-test-" + Guid.NewGuid().ToString("N"));
try
{
using(AgentStore store = new(directory))
using(JournalStore store = TestState.OpenStore(directory))
{
store.Record("j1", new PrintEvent("e1", "SUBMITTING"));
}
using(AgentStore restored = new(directory))
using(JournalStore restored = TestState.OpenStore(directory))
{
Assert.Equal("SUBMITTING", restored.GetState("j1"));
Assert.Single(restored.Pending());
@@ -378,7 +377,7 @@ public sealed class AgentStoreTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-print-test-" + Guid.NewGuid().ToString("N"));
try
{
using AgentStore store = new(directory);
using JournalStore store = TestState.OpenStore(directory);
AgentConfiguration configuration = new("https://example.com", "test-device", "not-a-real-secret");
store.SaveConfiguration(configuration);
Assert.Equal(configuration, store.LoadConfiguration());
@@ -406,10 +405,10 @@ public sealed class AgentStoreTests
{
AgentConfiguration original = new("https://example.com", "device-a", "old-token");
AgentConfiguration replacement = new("https://other.example.com", "device-b", "new-token");
using AgentStore oldWorker = new(directory);
using JournalStore oldWorker = TestState.OpenStore(directory);
oldWorker.SaveConfiguration(original);
oldWorker.Record("j1", new PrintEvent("e1", "SUBMITTING"));
using(AgentStore management = new(directory))
using(JournalStore management = TestState.OpenStore(directory))
{
management.SaveConfiguration(replacement);
Assert.Empty(management.Pending());
@@ -420,7 +419,7 @@ public sealed class AgentStoreTests
// An in-flight old worker retains its original scope rather than the new credentials.
Assert.Single(oldWorker.Pending());
oldWorker.Record("j1", new PrintEvent("e2", "OUTCOME_UNKNOWN"));
using(AgentStore newWorker = new(directory))
using(JournalStore newWorker = TestState.OpenStore(directory))
{
Assert.Equal(replacement, newWorker.LoadConfiguration());
Assert.Empty(newWorker.Pending());
@@ -451,20 +450,19 @@ public sealed class AgentStoreTests
try
{
AgentConfiguration original = new("https://example.com", "device-a", "old-token");
using(AgentStore initial = new(directory))
using(JournalStore initial = TestState.OpenStore(directory))
{
initial.SaveConfiguration(original);
initial.Record("legacy", new PrintEvent("legacy-event", "RECEIVED"));
initial.Acknowledge("legacy-event");
}
using(var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={Path.Combine(directory, "journal.db")}"))
using(Microsoft.Data.Sqlite.SqliteConnection connection = TestState.OpenJournal(directory))
{
connection.Open();
using Microsoft.Data.Sqlite.SqliteCommand command = connection.CreateCommand();
command.CommandText = "DROP INDEX IF EXISTS events_outbox; DROP INDEX IF EXISTS events_job; DROP TABLE local_jobs; ALTER TABLE events DROP COLUMN connection_id";
command.ExecuteNonQuery();
}
using(AgentStore migrated = new(directory))
using(JournalStore migrated = TestState.OpenStore(directory))
{
Assert.Equal("RECEIVED", Assert.Single(migrated.Incomplete()).State);
Assert.Empty(migrated.Pending());
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: MIT
*/
using System.IO.Pipes;
using System.Text.Json;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
@@ -22,7 +21,7 @@ public sealed class AgentTransportTests
public async Task DispatchDisconnectKeepsWebSocketConnectedAndReconnectPreservesTrafficTotals()
{
await using var fixture = new RealtimeRuntimeFixture();
RealtimeRuntimeFixture.Connection connection = await fixture.ConnectAsync();
ScriptedPrintServer.Connection connection = await fixture.ConnectAsync();
AgentTransportSnapshot before = fixture.Runtime.GetTransportSnapshot();
Assert.Equal("WS", before.Protocol);
Assert.Equal("CONNECTED", before.State);
@@ -52,17 +51,12 @@ public sealed class AgentTransportTests
public async Task TransportPipeOperationDoesNotDiscoverPrintersAndReturnsOnlyTrafficStatus()
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-transport-pipe-" + Guid.NewGuid().ToString("N"));
string name = "qqq-transport-" + Guid.NewGuid().ToString("N");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var runtime = new AgentRuntime(directory, _ => { }, new ForbiddenPrinters(), pipeName: name,
pipeFactory: () => new NamedPipeServerStream(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly));
var runtime = new SingleServerHarness(directory, _ => { }, new ForbiddenPrinters());
Task run = runtime.RunAsync(timeout.Token);
try
{
using var client = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
await client.ConnectAsync(timeout.Token);
await AgentPipeFraming.WriteAsync(client, """{"operation":"transport"}""", AgentPipeFraming.MaximumCommandCharacters, timeout.Token);
string json = await AgentPipeFraming.ReadAsync(client, AgentPipeFraming.MaximumResponseCharacters, timeout.Token);
string json = await runtime.PipeAsync("""{"operation":"transport"}""", timeout.Token);
AgentTransportSnapshot status = JsonSerializer.Deserialize<AgentTransportSnapshot>(json, PrintApiClient.JsonOptions)!;
Assert.Equal("NONE", status.Protocol);
Assert.Equal("DISCONNECTED", status.State);
@@ -6,7 +6,6 @@
using System.Globalization;
using System.Security.Cryptography;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
@@ -30,7 +29,7 @@ internal sealed class EnrolledStateFixture : IDisposable
/// <summary>Saves an enrolled configuration and shares the synthetic queue.</summary>
internal EnrolledStateFixture()
{
using AgentStore store = new(DirectoryPath);
using JournalStore store = TestState.OpenStore(DirectoryPath);
store.SaveConfiguration(new AgentConfiguration("https://synthetic.invalid", "device", "synthetic-token"));
store.SetExposure("printer", true, Printer.Discover());
}
@@ -40,7 +39,7 @@ internal sealed class EnrolledStateFixture : IDisposable
/// <summary>Records a job that has already been handed to the Windows spooler (spooler id 123) with its receipt acknowledged, so a test can show the journal was kept.</summary>
internal void SeedSpooledJob()
{
using AgentStore store = new(DirectoryPath);
using JournalStore store = TestState.OpenStore(DirectoryPath);
store.TrackJob(new PrintJob("job", "printer", "Synthetic", "PDF", Convert.ToHexString(SHA256.HashData([1, 2, 3])), 3,
new PrintOptions(), DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), "CLAIMED"), "Synthetic printer");
PrintEvent receipt = new("spooled", "SPOOLED", SpoolerJobId: "123");
@@ -19,7 +19,7 @@ public sealed class JobCreationTimeTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-created-" + Guid.NewGuid().ToString("N"));
try
{
using AgentStore store = new(directory);
using JournalStore store = TestState.OpenStore(directory);
PrintJob older = new("older", "printer", "Older job", "PDF", "00", 1, new PrintOptions(), "2026-01-01T05:00:00-05:00", "CLAIMED");
store.TrackJob(older, "Laser");
store.Record(older.JobId, new PrintEvent("received", "RECEIVED"));
@@ -53,7 +53,7 @@ public sealed class JobCreationTimeTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-created-migration-" + Guid.NewGuid().ToString("N"));
try
{
using(AgentStore store = new(directory))
using(JournalStore store = TestState.OpenStore(directory))
{
store.Record("events", new PrintEvent("first", "RECEIVED"));
store.Record("events", new PrintEvent("last", "SPOOLED"));
@@ -67,14 +67,14 @@ public sealed class JobCreationTimeTests
DROP INDEX jobs_display_createdAt;
ALTER TABLE local_jobs DROP COLUMN created_at;
""");
using(AgentStore migrated = new(directory))
using(JournalStore migrated = TestState.OpenStore(directory))
{
Assert.Equal("2020-01-01T00:00:00.0000000+00:00", migrated.GetJobDetails("events")!.Job.CreatedAt);
Assert.Equal("2021-01-01T00:00:00.0000000+00:00", migrated.GetJobDetails("pruned")!.Job.CreatedAt);
migrated.Record("events", new PrintEvent("late", "SPOOLER_COMPLETED"));
}
Execute(directory, "UPDATE events SET recorded_at='2099-01-01T00:00:00.0000000+00:00'; UPDATE local_jobs SET updated_at='2099-01-01T00:00:00.0000000+00:00'");
using AgentStore reopened = new(directory);
using JournalStore reopened = TestState.OpenStore(directory);
Assert.Equal("2020-01-01T00:00:00.0000000+00:00", reopened.GetJobDetails("events")!.Job.CreatedAt);
Assert.Equal("2021-01-01T00:00:00.0000000+00:00", reopened.GetJobDetails("pruned")!.Job.CreatedAt);
}
@@ -89,8 +89,7 @@ public sealed class JobCreationTimeTests
/// <summary>Runs raw SQL against the journal file directly, bypassing the store, to simulate an older schema or tampered timestamps.</summary>
private static void Execute(string directory, string sql)
{
using SqliteConnection connection = new(new SqliteConnectionStringBuilder { DataSource = Path.Combine(directory, "journal.db") }.ToString());
connection.Open();
using SqliteConnection connection = TestState.OpenJournal(directory);
using SqliteCommand command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
@@ -37,7 +37,7 @@ public sealed class JobSortingTests
Assert.Throws<ArgumentException>(() => fixture.Store.QueryJobs(new JobQuery(SortBy: "title; DELETE FROM local_jobs")));
// A distant corrupt payload would expose any accidental whole-history materialization.
fixture.Execute("UPDATE local_jobs SET last_event_json='invalid-json' WHERE job_id='job09999'");
Assert.Equal(100, AgentRuntime.QueryJobsWithQueue(fixture.Store, new JobQuery(Offset: 5000, PageSize: 100), cloud).Jobs.Count);
Assert.Equal(100, SingleServerHarness.QueryJobsWithQueue(fixture.Store, new JobQuery(Offset: 5000, PageSize: 100), cloud).Jobs.Count);
}
@@ -46,7 +46,7 @@ public sealed class JobSortingTests
/// Compares database pages and merged database-plus-queue pages with an independent in-memory sort of the same jobs. The merged
/// total must exclude queue entries already stored locally and must report that the server queue was limited.
/// </summary>
private static void VerifyPages(AgentStore store, JobSnapshot[] local, JobSnapshot[] cloud, JobQuery query)
private static void VerifyPages(JournalStore store, JobSnapshot[] local, JobSnapshot[] cloud, JobQuery query)
{
var comparer = Comparer<JobSnapshot>.Create((left, right) => JobSorting.Compare(left, right, query));
JobSnapshot[] expectedLocal = local.Order(comparer).ToArray();
@@ -56,7 +56,7 @@ public sealed class JobSortingTests
JobQuery pageQuery = query with { Offset = offset, PageSize = 100 };
JobPage page = store.QueryJobs(pageQuery);
Assert.Equal(expectedLocal.Skip(offset).Take(100).Select(job => job.JobId), page.Jobs.Select(job => job.JobId), StringComparer.Ordinal);
JobPage merged = AgentRuntime.QueryJobsWithQueue(store, pageQuery, cloud);
JobPage merged = SingleServerHarness.QueryJobsWithQueue(store, pageQuery, cloud);
Assert.Equal(expectedAll.Skip(offset).Take(100).Select(job => job.JobId), merged.Jobs.Select(job => job.JobId), StringComparer.Ordinal);
Assert.Equal(expectedAll.Length, merged.TotalCount);
Assert.True(merged.ServerQueueLimited);
@@ -113,16 +113,15 @@ public sealed class JobSortingTests
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "qqq-sort-" + Guid.NewGuid().ToString("N"));
private readonly SqliteConnection _database;
public AgentStore Store { get; }
public JournalStore Store { get; }
/// <summary>Creates the store (and with it the journal schema) in a fresh temporary directory, then opens the journal for direct access.</summary>
public Fixture()
{
Store = new AgentStore(_directory);
_database = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(_directory, "journal.db") }.ToString());
_database.Open();
Store = TestState.OpenStore(_directory);
_database = TestState.OpenJournal(_directory);
}
@@ -4,7 +4,6 @@
*/
using Microsoft.Data.Sqlite;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
@@ -22,7 +21,7 @@ internal static class JournalFiles
{
foreach(string directory in directories)
{
using var journal = new SqliteConnection(AgentStore.JournalConnectionString(directory));
using var journal = new SqliteConnection(TestState.JournalConnectionString(directory));
SqliteConnection.ClearPool(journal);
}
}
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
@@ -29,7 +28,7 @@ public sealed class JournalFilesTests
string root = Path.Combine(Path.GetTempPath(), "qqq-journal-files-" + Guid.NewGuid().ToString("N"));
string released = Path.Combine(root, "released");
string opened = Path.Combine(root, "opened");
using(AgentStore seeded = new(released))
using(JournalStore seeded = TestState.OpenStore(released))
{ Assert.Null(seeded.GetState("none")); }
using var stop = new CancellationTokenSource();
var releasing = new Thread(() =>
@@ -42,7 +41,7 @@ public sealed class JournalFilesTests
{
for(int attempt = 0; attempt < 2500; attempt++)
{
using AgentStore store = new(opened);
using JournalStore store = TestState.OpenStore(opened);
Assert.Null(store.GetState("none"));
}
}
@@ -19,12 +19,12 @@ public sealed class MetadataTests : IDisposable
[Fact]
public void Profiles_SurviveReopenAndRemainSeparateFromTechnicalQueueIdentity()
{
using(AgentStore store = new(_directory))
using(JournalStore store = TestState.OpenStore(_directory))
{
store.SaveDeviceProfile(new DeviceProfile(" Packing station 7 ", "First line\nSecond line", "West warehouse"));
store.SavePrinterProfile("stable-queue-id", new PrinterProfile("Shipping labels", "Thermal printer", "Dock 2"));
}
using AgentStore reopened = new(_directory);
using JournalStore reopened = TestState.OpenStore(_directory);
Assert.Equal(new DeviceProfile("Packing station 7", "First line\nSecond line", "West warehouse"), reopened.GetDeviceProfile());
Assert.Equal("Shipping labels", reopened.GetPrinterProfile("stable-queue-id").DisplayName);
Assert.Null(reopened.GetPrinterProfile("different-queue-id").DisplayName);
@@ -36,7 +36,7 @@ public sealed class MetadataTests : IDisposable
[Fact]
public void ExplicitClear_DiffersFromUnconfiguredMetadata()
{
using AgentStore store = new(_directory);
using JournalStore store = TestState.OpenStore(_directory);
Assert.Null(store.GetDeviceProfile().Name);
store.SaveDeviceProfile(new DeviceProfile("Old name", "Old description", "Old location"));
store.SaveDeviceProfile(new DeviceProfile("", "", ""));
@@ -52,7 +52,7 @@ public sealed class MetadataTests : IDisposable
[InlineData("description")]
public void OversizedMetadata_IsRejectedWithoutChangingStoredProfile(string field)
{
using AgentStore store = new(_directory);
using JournalStore store = TestState.OpenStore(_directory);
DeviceProfile previous = new("Original", "Description", "Location");
store.SaveDeviceProfile(previous);
DeviceProfile invalid = field switch
@@ -71,7 +71,7 @@ public sealed class MetadataTests : IDisposable
[Fact]
public void NamesAndLocations_RejectEmbeddedLineBreaks()
{
using AgentStore store = new(_directory);
using JournalStore store = TestState.OpenStore(_directory);
Assert.Throws<ArgumentException>(() => store.SaveDeviceProfile(new DeviceProfile("First\nSecond")));
Assert.Throws<ArgumentException>(() => store.SavePrinterProfile("queue", new PrinterProfile(Location: "First\tSecond")));
}
@@ -19,14 +19,14 @@ public sealed class PrinterExposureTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-exposure-" + Guid.NewGuid().ToString("N"));
try
{
using(AgentStore store = new(directory))
using(JournalStore store = TestState.OpenStore(directory))
{
Assert.Empty(store.GetExposedPrinterIds());
Assert.Throws<InvalidOperationException>(() => store.SetExposure("missing", true, []));
Assert.Throws<InvalidOperationException>(() => store.SetExposure("interactive", true, [new PrinterInfo("interactive", "Print to File", "Driver", "FILE:", "UNKNOWN", [])]));
store.SetExposure("p1", true, new FakePrinter().Discover());
}
using(AgentStore restored = new(directory))
using(JournalStore restored = TestState.OpenStore(directory))
{
Assert.Contains("p1", restored.GetExposedPrinterIds());
restored.SetExposure("p1", false, []);
@@ -65,13 +65,13 @@ public sealed class PrinterExposureTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-snapshot-" + Guid.NewGuid().ToString("N"));
try
{
using(AgentStore store = new(directory))
using(JournalStore store = TestState.OpenStore(directory))
{
store.SaveConfiguration(new AgentConfiguration("https://example.com", "d1", "secret-never-in-snapshot"));
store.SetExposure("p1", true, new FakePrinter().Discover());
store.Record("j1", new PrintEvent("e1", "SPOOLED", SpoolerJobId: "123"));
}
using AgentRuntime runtime = new(directory, _ => { }, new FakePrinter());
using SingleServerHarness runtime = new(directory, _ => { }, new FakePrinter());
AgentSnapshot snapshot = runtime.GetSnapshot();
Assert.True(snapshot.Enrolled);
Assert.False(snapshot.Connected);
@@ -199,7 +199,7 @@ public sealed class ProviderSignOutTests
using SessionFixture fixture = new();
QqqProviderLogout logout = new(new Uri(EndSession + "?id_token_hint=" + Hint + "&state=s"), Redirect, "s");
fixture.Session.ProviderLogout = logout;
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential(), ClaimLegacyDevice: true), CancellationToken.None);
string reply = await runtime.SignOutAsync(QqqBrowserCallback.RedirectUri, CancellationToken.None);
PipeSignOutResult result = JsonSerializer.Deserialize<PipeSignOutResult>(reply, PrintApiClient.JsonOptions)!;
@@ -221,7 +221,7 @@ public sealed class ProviderSignOutTests
public async Task ServiceRefusesAnyOtherCallback()
{
using SessionFixture fixture = new();
using AgentRuntime runtime = fixture.Runtime();
using SingleServerHarness runtime = fixture.Runtime();
_ = await Assert.ThrowsAsync<InvalidOperationException>(() => runtime.SignOutAsync("https://evil.example.invalid/cb", CancellationToken.None));
}
@@ -34,6 +34,16 @@ internal sealed partial class RealtimeRuntimeFixture
/// <summary>
/// When set to a printer identifier, a submission to that queue stays inside the printer until <see cref="Release"/>
/// completes, as one busy device does, while submissions to the other queue pass straight through; <see cref="BlockPrint"/>
/// holds both. A held submission is counted in <see cref="Submissions"/> at once and in <see cref="Prints"/> only when it
/// is let go. When this is not set, no job is held on its account, a job that names no printer included.
/// </summary>
internal string? BlockPrinter { get; set; }
/// <summary>Completed when a submission has reached the printer.</summary>
internal TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -71,7 +81,7 @@ internal sealed partial class RealtimeRuntimeFixture
Assert.Equal(job.ContentLength, new FileInfo(path).Length);
Interlocked.Increment(ref Submissions);
Entered.TrySetResult();
if(BlockPrint)
if(BlockPrint || (BlockPrinter is string held && string.Equals(held, job.PrinterId, StringComparison.Ordinal)))
{ await Release.Task.WaitAsync(cancellationToken); }
Interlocked.Increment(ref Prints);
Order.Enqueue(job.JobId);
@@ -0,0 +1,175 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Collections.Concurrent;
using System.Net;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Testing;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The fixture's one scripted server, under the names tests have always used for it. Each member here only passes through to
/// <see cref="Server"/>: with one server, "the fixture's queue" and "the server's queue" are the same thing, and a test reads
/// better without the extra word. A fixture with several servers has no such members; its tests name the server.
/// </summary>
internal sealed partial class RealtimeRuntimeFixture
{
/// <summary>The loopback listener the agent's WebSocket connects to; its origin is the server address saved in the store.</summary>
internal LoopbackWebSocketPeer Peer => Server.Peer;
/// <summary>The realtime settings the server sends in WELCOME: short reconnect delays without jitter, everything else at its default.</summary>
internal RealtimeSettings Settings { get => Server.Settings; set => Server.Settings = value; }
/// <summary>The server's queue of jobs waiting to be claimed, oldest first.</summary>
internal ConcurrentQueue<PrintJob> Queue => Server.Queue;
/// <summary>How each job's document is served when the agent downloads it, by job id.</summary>
internal ConcurrentDictionary<string, Func<Stream>> Documents => Server.Documents;
/// <summary>Every message the agent sent on any connection, in the order the server received them.</summary>
internal ConcurrentQueue<RealtimeEnvelope> Messages => Server.Messages;
/// <summary>When set, the server holds a RECEIVED event (and so the claimed job) until the test completes it.</summary>
internal TaskCompletionSource? ReceivedRelease { get => Server.ReceivedRelease; set => Server.ReceivedRelease = value; }
/// <summary>When set, the server holds its acknowledgement of STATE_APPLIED until the test completes it, which keeps the agent from becoming ready.</summary>
internal TaskCompletionSource? StateAppliedRelease { get => Server.StateAppliedRelease; set => Server.StateAppliedRelease = value; }
/// <summary>Runs for every agent message before the server answers it; a test awaits inside it to hold one acknowledgement back.</summary>
internal Func<RealtimeEnvelope, Task>? BeforeAcknowledgement { get => Server.BeforeAcknowledgement; set => Server.BeforeAcknowledgement = value; }
/// <summary>The settings revision the agent reported in its latest HELLO.</summary>
internal long LastHelloSettingsRevision => Server.LastHelloSettingsRevision;
/// <summary>Returns an error code to answer an agent message with a realtime ERROR frame instead of an acknowledgement (NOT_FOUND models a server that no longer knows the record), or null to acknowledge it.</summary>
internal Func<RealtimeEnvelope, string?>? RefuseWith { get => Server.RefuseWith; set => Server.RefuseWith = value; }
/// <summary>When true, the server closes the socket with a close frame straight after refusing a message, as a server that gives up on a misbehaving client does.</summary>
internal bool CloseAfterRefusal { get => Server.CloseAfterRefusal; set => Server.CloseAfterRefusal = value; }
/// <summary>Returns true for an agent message the server must receive and then drop the connection without acknowledging, as a network failure at that exact moment would.</summary>
internal Func<RealtimeEnvelope, bool>? LoseAcknowledgementOf { get => Server.LoseAcknowledgementOf; set => Server.LoseAcknowledgementOf = value; }
/// <summary>When true the server sends a desired state again when the agent reports an older revision as applied; when false it stays silent, which shows what a lost frame costs when nothing repairs it.</summary>
internal bool ResendUnappliedState { get => Server.ResendUnappliedState; set => Server.ResendUnappliedState = value; }
/// <summary>Captures the actual HELLO payload so compatibility tests observe the wire rather than duplicating capability selection.</summary>
internal string[] LastHelloCapabilities => Server.LastHelloCapabilities;
/// <summary>Controls advertised discovery across a server upgrade or downgrade while retaining the same authenticated client session.</summary>
internal string PrintingVersion { get => Server.PrintingVersion; set => Server.PrintingVersion = value; }
/// <summary>Printer identifiers the fake server's operator catalog lists for this device; both shared queues by default.</summary>
internal string[] ApprovedPrinters { get => Server.ApprovedPrinters; set => Server.ApprovedPrinters = value; }
/// <summary>When set, the operator catalog answers with this status instead of a page, to model an older server (404) or a user without catalog access (403).</summary>
internal HttpStatusCode? CatalogStatus { get => Server.CatalogStatus; set => Server.CatalogStatus = value; }
/// <summary>Counts the claims that reached the server, whatever they were answered.</summary>
internal int Claims => Server.Claims;
/// <summary>True when the newest answered claim found the queue empty, which is how a claim drain ends.</summary>
internal bool LastClaimWasEmpty => Server.LastClaimWasEmpty;
/// <summary>
/// Counts what the realtime path must never do: a request to a polling or control route, and a claim made while the
/// agent has no connection whose applied state the server acknowledged. Each is also refused, so it cannot pass unnoticed.
/// </summary>
internal int ForbiddenPolls => Server.ForbiddenPolls;
/// <summary>Counts every HTTP request the agent makes, so a test can show that an evaluation touched no network.</summary>
internal int Requests => Server.Requests;
/// <summary>Counts discovery requests to distinguish reconnect reuse from an added HTTP polling schedule.</summary>
internal int DiscoveryRequests => Server.DiscoveryRequests;
/// <summary>Counts operator catalog reads.</summary>
internal int CatalogRequests => Server.CatalogRequests;
/// <summary>Counts job cancellations the agent asked the server for as an operator action.</summary>
internal int OperatorCancellations => Server.OperatorCancellations;
/// <summary>The operator action routes the agent called, in order.</summary>
internal ConcurrentQueue<string> OperatorActions => Server.OperatorActions;
/// <summary>When set, every claim waits for it before it is answered, which holds a claim drain open at the claim itself.</summary>
internal TaskCompletionSource? ClaimHold { get => Server.ClaimHold; set => Server.ClaimHold = value; }
/// <summary>Completed when the first claim reaches the server, whether or not it is being held.</summary>
internal TaskCompletionSource ClaimArrived => Server.ClaimArrived;
/// <summary>When true, a claim that finds the queue empty is answered 503 instead of "no job", as a failing server would.</summary>
internal bool FailEmptyClaims { get => Server.FailEmptyClaims; set => Server.FailEmptyClaims = value; }
/// <summary>Returns an exception to throw for a request instead of answering it, to model a request that never reaches the server; null answers normally.</summary>
internal Func<HttpRequestMessage, Exception?>? HttpFault { get => Server.HttpFault; set => Server.HttpFault = value; }
/// <summary>Queues a synthetic PDF job on the server and registers how its document is served; see <see cref="ScriptedPrintServer.AddJob"/>.</summary>
internal void AddJob(string id, Func<Stream>? content = null, string printerId = "printer", long? length = null) => Server.AddJob(id, content, printerId, length);
/// <summary>How many messages of one type the agent has sent so far.</summary>
internal int Count(string type) => Server.Count(type);
}
@@ -6,31 +6,27 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO.Pipes;
using System.Net;
using System.Net.WebSockets;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Testing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// Harness for the path the installed service runs: a real agent runtime on a temporary store, signed in through a real session
/// client whose HTTP goes to a scripted server, and connected over a loopback WebSocket to a scripted realtime peer, with a queue
/// of synthetic jobs and a file-based fake printer. Tests drive what the server says (settings, state, commands,
/// acknowledgements), talk to the runtime over its management pipe, restart it on the same store, and read back what it sent.
/// The other parts of this class hold the scripted HTTP server, the realtime peer's connection and the printer.
/// client to one scripted server (its HTTP handler, its loopback realtime peer and its queue of synthetic jobs), with a
/// file-based fake printer. Tests drive what the server says (settings, state, commands, acknowledgements), talk to the
/// runtime over its management pipe, restart it on the same store, and read back what it sent. The server's members that
/// tests use are repeated here, in another part of this class, so a test of one server reads as it always has; the printer
/// is in a third part.
/// </summary>
internal sealed partial class RealtimeRuntimeFixture : IAsyncDisposable
{
/// <summary>The loopback listener the agent's WebSocket connects to; its origin is the server address saved in the store.</summary>
internal LoopbackWebSocketPeer Peer { get; } = new();
/// <summary>The one server this runtime is paired with; its origin is the server address saved in the store.</summary>
internal ScriptedPrintServer Server { get; }
@@ -44,26 +40,6 @@ internal sealed partial class RealtimeRuntimeFixture : IAsyncDisposable
/// <summary>The realtime settings the server sends in WELCOME: short reconnect delays without jitter, everything else at its default.</summary>
internal RealtimeSettings Settings { get; set; } = new(Revision: 1, ReconnectMinDelayMillis: 100, ReconnectMaxDelayMillis: 1000, ReconnectJitterPercent: 0);
/// <summary>The server's queue of jobs waiting to be claimed, oldest first.</summary>
internal ConcurrentQueue<PrintJob> Queue { get; } = new();
/// <summary>How each job's document is served when the agent downloads it, by job id.</summary>
internal ConcurrentDictionary<string, Func<Stream>> Documents { get; } = new(StringComparer.Ordinal);
/// <summary>Every message the agent sent on any connection, in the order the server received them.</summary>
internal ConcurrentQueue<RealtimeEnvelope> Messages { get; } = new();
/// <summary>The runtime's log lines, in order.</summary>
internal ConcurrentQueue<string> Logs { get; } = new();
@@ -75,27 +51,7 @@ internal sealed partial class RealtimeRuntimeFixture : IAsyncDisposable
/// <summary>The runtime under test; replaced by <see cref="RestartAsync"/>.</summary>
internal AgentRuntime Runtime { get; private set; }
/// <summary>When set, the server holds a RECEIVED event (and so the claimed job) until the test completes it.</summary>
internal TaskCompletionSource? ReceivedRelease { get; set; }
/// <summary>When set, the server holds its acknowledgement of STATE_APPLIED until the test completes it, which keeps the agent from becoming ready.</summary>
internal TaskCompletionSource? StateAppliedRelease { get; set; }
/// <summary>Runs for every agent message before the server answers it; a test awaits inside it to hold one acknowledgement back.</summary>
internal Func<RealtimeEnvelope, Task>? BeforeAcknowledgement { get; set; }
/// <summary>The settings revision the agent reported in its latest HELLO.</summary>
internal long LastHelloSettingsRevision;
internal SingleServerHarness Runtime { get; private set; }
@@ -104,58 +60,11 @@ internal sealed partial class RealtimeRuntimeFixture : IAsyncDisposable
/// <summary>Returns an error code to answer an agent message with a realtime ERROR frame instead of an acknowledgement (NOT_FOUND models a server that no longer knows the record), or null to acknowledge it.</summary>
internal Func<RealtimeEnvelope, string?>? RefuseWith { get; set; }
/// <summary>When true, the server closes the socket with a close frame straight after refusing a message, as a server that gives up on a misbehaving client does.</summary>
internal bool CloseAfterRefusal { get; set; }
/// <summary>Returns true for an agent message the server must receive and then drop the connection without acknowledging, as a network failure at that exact moment would.</summary>
internal Func<RealtimeEnvelope, bool>? LoseAcknowledgementOf { get; set; }
/// <summary>
/// When true the server behaves as a real one does when the agent reports an older desired-state revision as applied than
/// the newest one it was sent: it sends that desired state again. When false it stays silent, which shows what a lost
/// frame costs when nothing repairs it.
/// </summary>
internal bool ResendUnappliedState { get; set; }
/// <summary>Runs on the runtime's command thread just before it saves a command's result; a test blocks here to hold that save open.</summary>
internal Action? ControlResultSaving { get; set; }
/// <summary>Captures the actual HELLO payload so compatibility tests observe the wire rather than duplicating capability selection.</summary>
internal string[] LastHelloCapabilities { get; private set; } = [];
/// <summary>Controls advertised discovery across a server upgrade or downgrade while retaining the same authenticated client session.</summary>
internal string PrintingVersion { get; set; }
/// <summary>Printer identifiers the fake server's operator catalog lists for this device; both shared queues by default.</summary>
internal string[] ApprovedPrinters { get; set; } = ["printer", "other"];
/// <summary>When set, the operator catalog answers with this status instead of a page, to model an older server (404) or a user without catalog access (403).</summary>
internal HttpStatusCode? CatalogStatus { get; set; }
private readonly Lock _claimLock = new();
private PrintJob? _claimed;
private static readonly TimeSpan WaitLimit = TimeSpan.FromSeconds(30);
private readonly CancellationTokenSource _stopping = new();
private readonly List<Connection> _connections = [];
private readonly TimeProvider? _timeProvider;
private CancellationTokenSource _running;
@@ -168,13 +77,12 @@ internal sealed partial class RealtimeRuntimeFixture : IAsyncDisposable
/// HTTP fault can be in place before the runtime makes its first request.
/// </summary>
internal RealtimeRuntimeFixture(int downloads = 1, RealtimeSettings? settings = null, TimeProvider? timeProvider = null, bool cacheSettings = false, string printingVersion = "2.0.0",
Action<AgentStore>? seed = null, Func<HttpRequestMessage, Exception?>? httpFault = null)
Action<JournalStore>? seed = null, Func<HttpRequestMessage, Exception?>? httpFault = null)
{
PrintingVersion = printingVersion;
HttpFault = httpFault;
Server = new ScriptedPrintServer { PrintingVersion = printingVersion, HttpFault = httpFault };
_timeProvider = timeProvider;
Settings = (settings ?? Settings) with { MaxConcurrentDownloads = downloads };
using(var store = new AgentStore(DirectoryPath))
using(JournalStore store = TestState.OpenStore(DirectoryPath))
{
store.SaveConfiguration(new AgentConfiguration(Peer.Origin.AbsoluteUri, "device", "synthetic-proof"));
store.SaveAuthentication(new AgentAuthentication(new QqqSessionBinding(Peer.Origin, "user", "tenant", "device"), new QqqSessionCredential(Peer.Origin, "synthetic-session")));
@@ -193,12 +101,9 @@ internal sealed partial class RealtimeRuntimeFixture : IAsyncDisposable
[MemberNotNull(nameof(Runtime), nameof(Running), nameof(_running))]
private void Start()
{
_claimsAllowed = false;
Server.EndClaimSpan();
_running = CancellationTokenSource.CreateLinkedTokenSource(_stopping.Token);
Runtime = new AgentRuntime(DirectoryPath, Logs.Enqueue, Printer, pipeName: PipeName,
pipeFactory: () => new NamedPipeServerStream(PipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly),
sessionFactory: origin => new QqqSessionClient(origin, new Handler(this), allowLoopbackHttp: true), timeProvider: _timeProvider)
{ ControlResultSaving = () => ControlResultSaving?.Invoke() };
Runtime = new SingleServerHarness(DirectoryPath, Logs.Enqueue, Printer, Server.CreateSession, _timeProvider, PipeName, controlResultSaving: () => ControlResultSaving?.Invoke());
Running = Runtime.RunAsync(_running.Token);
}
@@ -230,23 +135,16 @@ internal sealed partial class RealtimeRuntimeFixture : IAsyncDisposable
/// <summary>
/// Accepts the agent's WebSocket, requires HELLO first, records its settings revision and capabilities, answers WELCOME with
/// the desired state and settings, and waits until the agent has applied them. With a connected desired state that means the
/// agent reports itself connected; with a disconnected one, or when the test says traffic stays restricted, it means the
/// transport is up, because ordinary traffic is then deliberately not allowed. A server channel that dies early fails with
/// a redacted diagnostic instead of hanging; the only time limit is the fixture's limit for one wait.
/// Has the server accept the agent's WebSocket and answer its HELLO with WELCOME, the desired state and the settings, then
/// waits until the agent has applied them. With a connected desired state that means the agent reports itself connected;
/// with a disconnected one, or when the test says traffic stays restricted, it means the transport is up, because ordinary
/// traffic is then deliberately not allowed. A server channel that dies early fails with a redacted diagnostic instead of
/// hanging; the only time limit is the fixture's limit for one wait.
/// </summary>
internal async Task<Connection> ConnectAsync(bool loseSpoolAck = false, DeviceDesiredState? desiredState = null, bool restricted = false)
internal async Task<ScriptedPrintServer.Connection> ConnectAsync(bool loseSpoolAck = false, DeviceDesiredState? desiredState = null, bool restricted = false)
{
DeviceDesiredState desired = desiredState ?? new DeviceDesiredState(1, false, "CONNECTED");
(WebSocket socket, RealtimeEnvelope hello) = await AcceptAgentAsync();
var connection = new Connection(socket, this, loseSpoolAck, _stopping.Token);
_connections.Add(connection);
Assert.Equal("HELLO", hello.Type);
LastHelloSettingsRevision = hello.Payload.GetProperty("lastSettingsRevision").GetInt64();
LastHelloCapabilities = hello.Payload.GetProperty("capabilities").EnumerateArray().Select(item => item.GetString()!).ToArray();
await connection.SendAsync("WELCOME", new { connectionId = Guid.NewGuid().ToString("N"), serverTime = Now(), desiredState = desired, settings = Settings });
connection.Start();
ScriptedPrintServer.Connection connection = await AcceptAgentAsync(desired, loseSpoolAck);
bool traffic = !restricted && desired.ConnectionState is "CONNECTED";
await WaitAsync(() =>
{
@@ -259,56 +157,25 @@ internal sealed partial class RealtimeRuntimeFixture : IAsyncDisposable
/// <summary>
/// Accepts the agent's next live socket and reads its first message. An attempt the agent abandoned while the upgrade was
/// in flight (the service was restarted, or told to reconnect) is still in the listener's queue with nobody behind it; such
/// a socket fails before any message arrives, and the live attempt queued after it is taken instead.
/// </summary>
private async Task<(WebSocket Socket, RealtimeEnvelope Hello)> AcceptAgentAsync()
/// <summary>Has the server take the agent's next live socket and welcome it, as one wait: an agent that never arrives fails with the fixture's diagnostic instead of hanging.</summary>
private async Task<ScriptedPrintServer.Connection> AcceptAgentAsync(DeviceDesiredState desired, bool loseSpoolAck)
{
using CancellationTokenSource wait = WaitScope();
while(true)
{
WebSocket? socket = null;
try
{
socket = await Peer.AcceptAsync(wait.Token);
RealtimeEnvelope hello = await LoopbackWebSocketPeer.ReceiveAsync(socket, wait.Token);
// From this HELLO until the agent's report of the applied state is acknowledged, no claim is legitimate.
_claimsAllowed = false;
return (socket, hello);
}
catch(OperationCanceledException exception) when(wait.IsCancellationRequested)
{ throw new InvalidOperationException(Diagnostic("WebSocket handshake"), exception); }
catch(Exception exception) when(exception is IOException or WebSocketException)
{ socket?.Dispose(); }
}
try
{ return await Server.AcceptAsync(desired, loseSpoolAck, wait.Token); }
catch(OperationCanceledException exception) when(wait.IsCancellationRequested)
{ throw new InvalidOperationException(Diagnostic("WebSocket handshake"), exception); }
}
/// <summary>Current UTC time in round-trip format, as the server would stamp it.</summary>
internal static string Now() => DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture);
/// <summary>
/// Queues a synthetic PDF job and registers how its document is served. Without a custom stream the document is a few real
/// bytes with a matching hash; with one, the declared length defaults to 25 MiB (or the given length) so large and failing
/// downloads can be simulated.
/// </summary>
internal void AddJob(string id, Func<Stream>? content = null, string printerId = "printer", long? length = null)
{
byte[] bytes = Encoding.ASCII.GetBytes("%PDF-synthetic-" + id);
Documents[id] = content ?? (() => new MemoryStream(bytes));
Queue.Enqueue(new PrintJob(id, printerId, "Synthetic " + id, "PDF", Convert.ToHexString(SHA256.HashData(bytes)), length ?? (content is null ? bytes.Length : 25 * 1024 * 1024),
new PrintOptions(), Now(), "QUEUED"));
}
internal static string Now() => ScriptedPrintServer.Now();
/// <summary>Records a job that the Windows spooler already accepted (spooler id 123, receipt acknowledged) in the given store: the state a cancel command acts on.</summary>
internal static void SeedSpooledJob(AgentStore store, string id = "job")
internal static void SeedSpooledJob(JournalStore store, string id = "job")
{
store.TrackJob(new PrintJob(id, "printer", "Synthetic " + id, "PDF", Convert.ToHexString(SHA256.HashData([1, 2, 3])), 3, new PrintOptions(), Now(), "CLAIMED"), "Synthetic");
PrintEvent receipt = new("spooled-" + id, "SPOOLED", SpoolerJobId: "123");
@@ -398,19 +265,11 @@ internal sealed partial class RealtimeRuntimeFixture : IAsyncDisposable
/// <summary>How many messages of one type the agent has sent so far.</summary>
internal int Count(string type) => Messages.Count(message => string.Equals(message.Type, type, StringComparison.Ordinal));
/// <summary>Sends one command over the runtime's management pipe, as the companion and command line do, and returns the reply; the exchange is one wait under the fixture's limit.</summary>
internal async Task<string> PipeAsync(PipeRequest request)
{
using CancellationTokenSource wait = WaitScope();
await using NamedPipeClientStream pipe = new(".", PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
await pipe.ConnectAsync(wait.Token);
await AgentPipeFraming.WriteAsync(pipe, JsonSerializer.Serialize(request, PrintApiClient.JsonOptions), AgentPipeFraming.MaximumCommandCharacters, wait.Token);
return await AgentPipeFraming.ReadAsync(pipe, AgentPipeFraming.MaximumResponseCharacters, wait.Token);
return await Runtime.PipeAsync(request, wait.Token);
}
@@ -429,7 +288,7 @@ internal sealed partial class RealtimeRuntimeFixture : IAsyncDisposable
/// <summary>Reads a job's recorded state from the agent's durable store, independent of what the agent reported over the wire.</summary>
internal string? State(string id)
{
using var store = new AgentStore(DirectoryPath);
using JournalStore store = TestState.OpenStore(DirectoryPath);
return store.GetState(id);
}
@@ -439,12 +298,10 @@ internal sealed partial class RealtimeRuntimeFixture : IAsyncDisposable
public async ValueTask DisposeAsync()
{
await _stopping.CancelAsync();
ClaimHold?.TrySetResult();
Printer.Release.TrySetResult();
foreach(Connection connection in _connections)
{ await connection.DisposeAsync(); }
await Server.CloseConnectionsAsync();
await StopAsync();
Peer.Dispose();
await Server.DisposeAsync();
_stopping.Dispose();
JournalFiles.Delete(DirectoryPath);
}
@@ -10,19 +10,32 @@ using Qqq.PrintAgent.Testing;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>The realtime peer's side of the fixture: one accepted WebSocket and the pump that answers the agent on it.</summary>
internal sealed partial class RealtimeRuntimeFixture
/// <summary>The realtime side of the scripted server: one accepted WebSocket and the pump that answers the agent on it.</summary>
internal sealed partial class ScriptedPrintServer
{
/// <summary>
/// The server side of one accepted WebSocket. A background pump records every agent message, acknowledges it (the server's
/// durable-receipt promise), answers pings, releases claimed jobs on RECEIVED, and can refuse a message with an ERROR frame or
/// drop the connection at a chosen message to simulate losing an acknowledgement.
/// </summary>
internal sealed class Connection(WebSocket socket, RealtimeRuntimeFixture fixture, bool loseSpoolAck, CancellationToken token) : IAsyncDisposable
internal sealed class Connection(WebSocket socket, ScriptedPrintServer server, bool loseSpoolAck, CancellationToken token) : IAsyncDisposable
{
/// <summary>Lets one sender at a time write to the socket, so frames from the pump and from a test never interleave.</summary>
private readonly SemaphoreSlim _send = new(1, 1);
/// <summary>The running pump; completed until <see cref="Start"/> is called.</summary>
private Task _pump = Task.CompletedTask;
/// <summary>The newest desired state sent on this connection, kept so it can be sent again when the agent reports an older one as applied.</summary>
private DeviceDesiredState? _newestDesired;
/// <summary>The pump's task: once started, it completes when the connection stops answering, whether it was dropped, refused or stopped, so a wait can tell a dead channel from a slow one.</summary>
internal Task Completion => _pump;
@@ -42,7 +55,7 @@ internal sealed partial class RealtimeRuntimeFixture
/// <summary>Completed when the connection dropped a message the fixture was told to lose, so a test can wait for that exact moment before reconnecting.</summary>
/// <summary>Completed when the connection dropped a message the server was told to lose, so a test can wait for that exact moment before reconnecting.</summary>
internal TaskCompletionSource LostAcknowledgement { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -81,11 +94,11 @@ internal sealed partial class RealtimeRuntimeFixture
while(!token.IsCancellationRequested)
{
RealtimeEnvelope message = await LoopbackWebSocketPeer.ReceiveAsync(socket, token);
fixture.Messages.Enqueue(message);
if(fixture.BeforeAcknowledgement is not null)
{ await fixture.BeforeAcknowledgement(message); }
if(message.Type is "STATE_APPLIED" && fixture.StateAppliedRelease is not null)
{ await fixture.StateAppliedRelease.Task.WaitAsync(token); }
server.Messages.Enqueue(message);
if(server.BeforeAcknowledgement is not null)
{ await server.BeforeAcknowledgement(message); }
if(message.Type is "STATE_APPLIED" && server.StateAppliedRelease is not null)
{ await server.StateAppliedRelease.Task.WaitAsync(token); }
if(message.Type is "JOB_EVENT" && message.Payload.GetProperty("event").GetProperty("status").GetString() is "RECEIVED")
{ await ReleaseClaimAsync(message); }
if(message.Type is "PING")
@@ -96,25 +109,25 @@ internal sealed partial class RealtimeRuntimeFixture
socket.Abort();
return;
}
if(fixture.LoseAcknowledgementOf?.Invoke(message) == true)
if(server.LoseAcknowledgementOf?.Invoke(message) == true)
{
LostAcknowledgement.TrySetResult();
socket.Abort();
return;
}
if(fixture.RefuseWith?.Invoke(message) is string code)
if(server.RefuseWith?.Invoke(message) is string code)
{
await SendErrorAsync(message.MessageId, code);
if(!fixture.CloseAfterRefusal)
if(!server.CloseAfterRefusal)
{ continue; }
await socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "refused", token);
return;
}
// Set before the acknowledgement leaves: the agent may claim as soon as it reads it.
if(message.Type is "STATE_APPLIED")
{ fixture._claimsAllowed = true; }
{ server._claimsAllowed = true; }
await SendAsync("ACK", new { acknowledgedMessageId = message.MessageId });
if(message.Type is "STATE_APPLIED" && fixture.ResendUnappliedState && _newestDesired is DeviceDesiredState unapplied
if(message.Type is "STATE_APPLIED" && server.ResendUnappliedState && _newestDesired is DeviceDesiredState unapplied
&& message.Payload.GetProperty("lastAppliedRevision").GetInt64() < unapplied.Revision)
{ await SendAsync("DESIRED_STATE", unapplied); }
}
@@ -125,12 +138,12 @@ internal sealed partial class RealtimeRuntimeFixture
/// <summary>Frees the server's claimed job once its RECEIVED event arrived, after any hold the test placed on that event.</summary>
private async Task ReleaseClaimAsync(RealtimeEnvelope message)
{
if(fixture.ReceivedRelease is not null)
{ await fixture.ReceivedRelease.Task.WaitAsync(token); }
lock(fixture._claimLock)
if(server.ReceivedRelease is not null)
{ await server.ReceivedRelease.Task.WaitAsync(token); }
lock(server._claimLock)
{
if(string.Equals(fixture._claimed?.JobId, message.Payload.GetProperty("jobId").GetString(), StringComparison.Ordinal))
{ fixture._claimed = null; }
if(string.Equals(server._claimed?.JobId, message.Payload.GetProperty("jobId").GetString(), StringComparison.Ordinal))
{ server._claimed = null; }
}
}
@@ -11,10 +11,10 @@ using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The scripted HTTP server behind the fixture's session client, with the counters and switches tests use to see and steer the
/// agent's HTTPS traffic: claims, the queued-job preview, operator actions, the printer catalog and session routes.
/// The HTTP side of the scripted server, behind the session client, with the counters and switches tests use to see and steer
/// the agent's HTTPS traffic: claims, the queued-job preview, operator actions, the printer catalog and session routes.
/// </summary>
internal sealed partial class RealtimeRuntimeFixture
internal sealed partial class ScriptedPrintServer
{
/// <summary>Counts the claims that reached the server, whatever they were answered.</summary>
internal int Claims;
@@ -36,7 +36,7 @@ internal sealed partial class RealtimeRuntimeFixture
/// <summary>
/// True from the moment the server acknowledges the agent's report of the applied state until the agent's next HELLO
/// or a restart of the runtime. A claim outside that span is a violation. A drop of the socket by the server does not
/// or a restart of the service. A claim outside that span is a violation. A drop of the socket by the server does not
/// end the span, because a claim the agent had already sent when the socket died is not its fault.
/// </summary>
private volatile bool _claimsAllowed;
@@ -48,6 +48,15 @@ internal sealed partial class RealtimeRuntimeFixture
/// <summary>
/// Counts requests that reached this server's handler but were addressed to another origin. Each is refused. A service
/// with several servers gives every session the handler of the server it is for; a request here for another address
/// means a session was built on the wrong server's handler.
/// </summary>
internal int MisroutedRequests;
/// <summary>Counts discovery requests to distinguish reconnect reuse from an added HTTP polling schedule.</summary>
internal int DiscoveryRequests;
@@ -88,35 +97,55 @@ internal sealed partial class RealtimeRuntimeFixture
/// <summary>Controls advertised discovery across a server upgrade or downgrade while retaining the same authenticated client session.</summary>
internal string PrintingVersion { get; set; } = "2.0.0";
/// <summary>Printer identifiers the fake server's operator catalog lists for this device; both shared queues by default.</summary>
internal string[] ApprovedPrinters { get; set; } = ["printer", "other"];
/// <summary>When set, the operator catalog answers with this status instead of a page, to model an older server (404) or a user without catalog access (403).</summary>
internal HttpStatusCode? CatalogStatus { get; set; }
/// <summary>
/// Fake HTTP server for the session client: version discovery (advertising native sign-in and the configured printing
/// version), document content, claims, the queued list, operator actions, enrollment and session identity. Any other route
/// is a forbidden polling or control call and is counted and thrown, which proves the realtime path adds no HTTP polling.
/// </summary>
private sealed class Handler(RealtimeRuntimeFixture fixture) : HttpMessageHandler
private sealed class Handler(ScriptedPrintServer server) : HttpMessageHandler
{
/// <summary>Counts the request, applies a scripted fault, and routes by path.</summary>
/// <summary>Counts the request, refuses and counts one addressed to another server, applies a scripted fault, and routes by path.</summary>
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref fixture.Requests);
if(fixture.HttpFault?.Invoke(request) is Exception fault)
Interlocked.Increment(ref server.Requests);
if(!string.Equals(request.RequestUri!.Authority, server.Origin.Authority, StringComparison.OrdinalIgnoreCase))
{
Interlocked.Increment(ref server.MisroutedRequests);
throw new InvalidOperationException("Request for " + request.RequestUri.Authority + " reached the scripted server at " + server.Origin.Authority + ".");
}
if(server.HttpFault?.Invoke(request) is Exception fault)
{ throw fault; }
string path = request.RequestUri!.AbsolutePath;
string path = request.RequestUri.AbsolutePath;
if(string.Equals(path, "/qqq/v1/version", StringComparison.Ordinal))
{
Interlocked.Increment(ref fixture.DiscoveryRequests);
Interlocked.Increment(ref server.DiscoveryRequests);
return Response(JsonSerializer.Serialize(new
{
apiVersion = "1.9.0",
features = new[] { "version", "metaData", "processes", "processCancel", "extensions", "widgets", "nativeSessionLogin" },
clientChannels = new[] { "api" },
extensions = new[] { new { name = "printing", version = fixture.PrintingVersion, pathPrefix = "/qbit/printing" } },
extensions = new[] { new { name = "printing", version = server.PrintingVersion, pathPrefix = "/qbit/printing" } },
}));
}
if(path.EndsWith("/content", StringComparison.Ordinal))
{
string id = path.Split('/')[^2];
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(fixture.Documents[id]()) });
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(server.Documents[id]()) });
}
if(path.EndsWith("/claim", StringComparison.Ordinal))
{ return ClaimAsync(cancellationToken); }
@@ -131,23 +160,23 @@ internal sealed partial class RealtimeRuntimeFixture
/// </summary>
private async Task<HttpResponseMessage> ClaimAsync(CancellationToken cancellationToken)
{
if(!fixture._claimsAllowed)
if(!server._claimsAllowed)
{
Interlocked.Increment(ref fixture.ForbiddenPolls);
Interlocked.Increment(ref server.ForbiddenPolls);
throw new InvalidOperationException("Claim without a realtime connection whose applied state was acknowledged.");
}
Interlocked.Increment(ref fixture.Claims);
fixture.ClaimArrived.TrySetResult();
if(fixture.ClaimHold is TaskCompletionSource hold)
Interlocked.Increment(ref server.Claims);
server.ClaimArrived.TrySetResult();
if(server.ClaimHold is TaskCompletionSource hold)
{ await hold.Task.WaitAsync(cancellationToken); }
lock(fixture._claimLock)
lock(server._claimLock)
{
if(fixture._claimed is null)
{ fixture.Queue.TryDequeue(out fixture._claimed); }
fixture.LastClaimWasEmpty = fixture._claimed is null;
if(fixture._claimed is null && fixture.FailEmptyClaims)
if(server._claimed is null)
{ server.Queue.TryDequeue(out server._claimed); }
server.LastClaimWasEmpty = server._claimed is null;
if(server._claimed is null && server.FailEmptyClaims)
{ return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) { Content = new StringContent("{}") }; }
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(new { job = fixture._claimed }, PrintApiClient.JsonOptions)) };
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(new { job = server._claimed }, PrintApiClient.JsonOptions)) };
}
}
@@ -159,28 +188,28 @@ internal sealed partial class RealtimeRuntimeFixture
response = null;
if(path.EndsWith("/queued", StringComparison.Ordinal))
{
response = Response(JsonSerializer.Serialize(new { jobs = fixture.Queue.ToArray() }, PrintApiClient.JsonOptions));
response = Response(JsonSerializer.Serialize(new { jobs = server.Queue.ToArray() }, PrintApiClient.JsonOptions));
}
else if(path.EndsWith("/device/test-print", StringComparison.Ordinal))
{
fixture.OperatorActions.Enqueue(path);
server.OperatorActions.Enqueue(path);
response = Response("""{"jobId":"test-queued"}""");
}
else if(path.EndsWith("/cancel", StringComparison.Ordinal))
{
fixture.OperatorActions.Enqueue(path);
Interlocked.Increment(ref fixture.OperatorCancellations);
fixture.RemoveQueued(path.Split('/')[^2]);
server.OperatorActions.Enqueue(path);
Interlocked.Increment(ref server.OperatorCancellations);
server.RemoveQueued(path.Split('/')[^2]);
response = Response("""{"accepted":true}""");
}
else if(path.EndsWith("/operator/printers", StringComparison.Ordinal))
{
Interlocked.Increment(ref fixture.CatalogRequests);
response = fixture.CatalogStatus is HttpStatusCode status
Interlocked.Increment(ref server.CatalogRequests);
response = server.CatalogStatus is HttpStatusCode status
? Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent("{}") })
: Response(JsonSerializer.Serialize(new
{
printers = fixture.ApprovedPrinters.Select(id => new { printerRowId = "row-" + id, deviceId = "device", printerId = id, name = id, deviceName = "Synthetic workstation", status = "READY", formats = new[] { "PDF" }, mediaProfiles = Array.Empty<object>() }),
printers = server.ApprovedPrinters.Select(id => new { printerRowId = "row-" + id, deviceId = "device", printerId = id, name = id, deviceName = "Synthetic workstation", status = "READY", formats = new[] { "PDF" }, mediaProfiles = Array.Empty<object>() }),
nextCursor = (string?)null,
}, PrintApiClient.JsonOptions));
}
@@ -212,7 +241,7 @@ internal sealed partial class RealtimeRuntimeFixture
/// <summary>Counts and rejects a route the realtime path must never call.</summary>
private string Unexpected(string path)
{
Interlocked.Increment(ref fixture.ForbiddenPolls);
Interlocked.Increment(ref server.ForbiddenPolls);
throw new InvalidOperationException("Unexpected HTTP polling/control route: " + path);
}
@@ -0,0 +1,250 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Collections.Concurrent;
using System.Globalization;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Testing;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// One scripted QQQ server with the printing extension, as the service sees it: a loopback WebSocket listener for the realtime
/// channel, an HTTP handler behind the session client, a queue of synthetic jobs with their documents, and a record of what
/// the agent sent. A test drives what the server says (settings, state, commands, acknowledgements) and reads back what
/// reached it. The server knows nothing about the service it answers: a fixture joins it to a runtime, and waits for the
/// runtime's side of a connection. Everything the server holds belongs to the instance, so several can stand beside one
/// service, each at its own loopback address, without one seeing another's jobs, messages, counters or holds. The other parts
/// of this class hold the HTTP side and one accepted realtime connection.
/// </summary>
internal sealed partial class ScriptedPrintServer : IAsyncDisposable
{
/// <summary>The loopback listener the agent's WebSocket connects to.</summary>
internal LoopbackWebSocketPeer Peer { get; } = new();
/// <summary>The server's address: the origin the agent is paired with and signs in to, and the one its session client is made for.</summary>
internal Uri Origin => Peer.Origin;
/// <summary>The realtime settings the server sends in WELCOME: short reconnect delays without jitter, everything else at its default.</summary>
internal RealtimeSettings Settings { get; set; } = new(Revision: 1, ReconnectMinDelayMillis: 100, ReconnectMaxDelayMillis: 1000, ReconnectJitterPercent: 0);
/// <summary>The server's queue of jobs waiting to be claimed, oldest first.</summary>
internal ConcurrentQueue<PrintJob> Queue { get; } = new();
/// <summary>How each job's document is served when the agent downloads it, by job id.</summary>
internal ConcurrentDictionary<string, Func<Stream>> Documents { get; } = new(StringComparer.Ordinal);
/// <summary>Every message the agent sent on any connection to this server, in the order the server received them.</summary>
internal ConcurrentQueue<RealtimeEnvelope> Messages { get; } = new();
/// <summary>When set, the server holds a RECEIVED event (and so the claimed job) until the test completes it.</summary>
internal TaskCompletionSource? ReceivedRelease { get; set; }
/// <summary>When set, the server holds its acknowledgement of STATE_APPLIED until the test completes it, which keeps the agent from becoming ready.</summary>
internal TaskCompletionSource? StateAppliedRelease { get; set; }
/// <summary>Runs for every agent message before the server answers it; a test awaits inside it to hold one acknowledgement back.</summary>
internal Func<RealtimeEnvelope, Task>? BeforeAcknowledgement { get; set; }
/// <summary>The settings revision the agent reported in its latest HELLO.</summary>
internal long LastHelloSettingsRevision { get; private set; }
/// <summary>Returns an error code to answer an agent message with a realtime ERROR frame instead of an acknowledgement (NOT_FOUND models a server that no longer knows the record), or null to acknowledge it.</summary>
internal Func<RealtimeEnvelope, string?>? RefuseWith { get; set; }
/// <summary>When true, the server closes the socket with a close frame straight after refusing a message, as a server that gives up on a misbehaving client does.</summary>
internal bool CloseAfterRefusal { get; set; }
/// <summary>Returns true for an agent message the server must receive and then drop the connection without acknowledging, as a network failure at that exact moment would.</summary>
internal Func<RealtimeEnvelope, bool>? LoseAcknowledgementOf { get; set; }
/// <summary>
/// When true the server behaves as a real one does when the agent reports an older desired-state revision as applied than
/// the newest one it was sent: it sends that desired state again. When false it stays silent, which shows what a lost
/// frame costs when nothing repairs it.
/// </summary>
internal bool ResendUnappliedState { get; set; }
/// <summary>Captures the actual HELLO payload so compatibility tests observe the wire rather than duplicating capability selection.</summary>
internal string[] LastHelloCapabilities { get; private set; } = [];
/// <summary>Guards the queue head and the claimed job, so a claim and the RECEIVED event that frees it never interleave.</summary>
private readonly Lock _claimLock = new();
/// <summary>The job this server handed out and has not yet seen a RECEIVED event for; while it is set, every claim is answered with it again.</summary>
private PrintJob? _claimed;
/// <summary>Cancelled when this server stops answering; every connection's pump runs on its token.</summary>
private readonly CancellationTokenSource _stopping = new();
/// <summary>Every connection this server accepted, so that stopping closes them all.</summary>
private readonly List<Connection> _connections = [];
/// <summary>True once the connections were closed, which makes closing them a second time do nothing.</summary>
private bool _closed;
/// <summary>
/// A session client for this server: the real client the service uses, with its HTTP sent to this server's scripted
/// handler and its WebSocket to this server's loopback listener. A service's session factory calls this for the origin
/// it was asked to reach, so a fixture with several servers hands each origin to the server that owns it.
/// </summary>
internal IQqqSessionClient CreateSession(Uri origin) => new QqqSessionClient(origin, CreateHandler(), allowLoopbackHttp: true);
/// <summary>The scripted HTTP side on its own, for a caller that speaks HTTP to this server without a session client.</summary>
internal HttpMessageHandler CreateHandler() => new Handler(this);
/// <summary>
/// Accepts the agent's WebSocket, requires HELLO first, records its settings revision and capabilities, answers WELCOME
/// with the given desired state and this server's settings, and starts answering the agent on that connection. It returns
/// once WELCOME is sent; whether the agent has applied it is the caller's to wait for, because only the caller can see the
/// agent. The token limits the wait for the socket and its HELLO.
/// </summary>
internal async Task<Connection> AcceptAsync(DeviceDesiredState desired, bool loseSpoolAck, CancellationToken token)
{
(WebSocket socket, RealtimeEnvelope hello) = await AcceptLiveSocketAsync(token);
var connection = new Connection(socket, this, loseSpoolAck, _stopping.Token);
_connections.Add(connection);
Assert.Equal("HELLO", hello.Type);
LastHelloSettingsRevision = hello.Payload.GetProperty("lastSettingsRevision").GetInt64();
LastHelloCapabilities = hello.Payload.GetProperty("capabilities").EnumerateArray().Select(item => item.GetString()!).ToArray();
await connection.SendAsync("WELCOME", new { connectionId = Guid.NewGuid().ToString("N"), serverTime = Now(), desiredState = desired, settings = Settings });
connection.Start();
return connection;
}
/// <summary>
/// Accepts the agent's next live socket and reads its first message. An attempt the agent abandoned while the upgrade was
/// in flight (the service was restarted, or told to reconnect) is still in the listener's queue with nobody behind it; such
/// a socket fails before any message arrives, and the live attempt queued after it is taken instead.
/// </summary>
private async Task<(WebSocket Socket, RealtimeEnvelope Hello)> AcceptLiveSocketAsync(CancellationToken token)
{
while(true)
{
WebSocket? socket = null;
try
{
socket = await Peer.AcceptAsync(token);
RealtimeEnvelope hello = await LoopbackWebSocketPeer.ReceiveAsync(socket, token);
// From this HELLO until the agent's report of the applied state is acknowledged, no claim is legitimate.
_claimsAllowed = false;
return (socket, hello);
}
catch(Exception exception) when(exception is IOException or WebSocketException)
{ socket?.Dispose(); }
}
}
/// <summary>
/// Ends the span in which claims are legitimate, as a restart of the service does: what the new service knows is what the
/// old one saved, and that never includes a connection whose applied state this server acknowledged.
/// </summary>
internal void EndClaimSpan() => _claimsAllowed = false;
/// <summary>Current UTC time in round-trip format, as the server would stamp it.</summary>
internal static string Now() => DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture);
/// <summary>
/// Queues a synthetic PDF job and registers how its document is served. Without a custom stream the document is a few real
/// bytes with a matching hash; with one, the declared length defaults to 25 MiB (or the given length) so large and failing
/// downloads can be simulated.
/// </summary>
internal void AddJob(string id, Func<Stream>? content = null, string printerId = "printer", long? length = null)
{
byte[] bytes = Encoding.ASCII.GetBytes("%PDF-synthetic-" + id);
Documents[id] = content ?? (() => new MemoryStream(bytes));
Queue.Enqueue(new PrintJob(id, printerId, "Synthetic " + id, "PDF", Convert.ToHexString(SHA256.HashData(bytes)), length ?? (content is null ? bytes.Length : 25 * 1024 * 1024),
new PrintOptions(), Now(), "QUEUED"));
}
/// <summary>How many messages of one type the agent has sent to this server so far.</summary>
internal int Count(string type) => Messages.Count(message => string.Equals(message.Type, type, StringComparison.Ordinal));
/// <summary>
/// Stops answering: cancels every connection's pump, lets go of a claim a test left held, and closes the accepted sockets.
/// The listener stays up until the server is disposed, so a fixture can close the connections, stop its service, and
/// only then take the address away, which is the order the suite has always stopped in. Safe to call more than once.
/// </summary>
internal async Task CloseConnectionsAsync()
{
if(_closed)
{ return; }
_closed = true;
await _stopping.CancelAsync();
ClaimHold?.TrySetResult();
foreach(Connection connection in _connections)
{ await connection.DisposeAsync(); }
}
/// <summary>Closes the connections if they are still open, then stops the listener.</summary>
public async ValueTask DisposeAsync()
{
await CloseConnectionsAsync();
Peer.Dispose();
_stopping.Dispose();
}
}
@@ -0,0 +1,204 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Net.WebSockets;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Testing;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// Tests of the scripted server itself, without a service: a plain WebSocket client and plain HTTP requests stand in for the
/// agent, so what is shown is the server's own state and nothing a runtime adds to it. They stand two servers side by side,
/// as they will be behind one service, and show that what is done to one does not show on the other.
/// </summary>
public sealed class ScriptedPrintServerTests
{
/// <summary>The answer to a claim that found no job.</summary>
private const string NoJob = "{}";
/// <summary>
/// Each server answers from its own queue, its own claimed job, its own acknowledged connection and its own counters. The
/// first server has a job and an agent; the second has neither, so its queue is empty while the first's holds the job, and a
/// claim made to it is refused as one made without a connection and counted as forbidden. The first hands out its job.
/// Then the second gets an agent and a job of its own: its claim returns that job, not the one the first server still
/// holds as claimed, and the first, asked again, still returns its own. Messages, HELLO details, documents, settings and
/// the advertised printing version stay with the server they belong to.
/// </summary>
[Fact]
public async Task ScriptedPrintServer_TwoInstances_DoNotShareState()
{
using var limit = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using ScriptedPrintServer first = new();
await using ScriptedPrintServer second = new() { PrintingVersion = "2.4.0" };
second.Settings = second.Settings with { Revision = 9 };
using HttpMessageInvoker toFirst = new(first.CreateHandler());
using HttpMessageInvoker toSecond = new(second.CreateHandler());
using ClientWebSocket agentOfFirst = new();
using ClientWebSocket agentOfSecond = new();
Assert.NotEqual(first.Origin, second.Origin);
first.AddJob("job");
Assert.Equal("job", Assert.Single(first.Queue).JobId);
Assert.Empty(second.Queue);
Assert.Equal("job", Assert.Single(first.Documents).Key);
Assert.Empty(second.Documents);
(RealtimeEnvelope welcome, _) = await ConnectAsync(agentOfFirst, first, settingsRevision: 3, limit.Token);
Assert.Equal(1, welcome.Payload.GetProperty("settings").GetProperty("revision").GetInt64());
Assert.Equal((3, 0), (first.LastHelloSettingsRevision, second.LastHelloSettingsRevision));
Assert.Equal("synthetic", Assert.Single(first.LastHelloCapabilities));
Assert.Empty(second.LastHelloCapabilities);
_ = await Assert.ThrowsAsync<InvalidOperationException>(() => ClaimAsync(toSecond, second, limit.Token));
Assert.Contains("\"jobId\":\"job\"", await ClaimAsync(toFirst, first, limit.Token), StringComparison.Ordinal);
Assert.Equal((1, 0), (first.Claims, first.ForbiddenPolls));
Assert.Equal((0, 1), (second.Claims, second.ForbiddenPolls));
Assert.Equal("STATE_APPLIED", Assert.Single(first.Messages).Type);
Assert.Empty(second.Messages);
second.AddJob("second-job");
(welcome, _) = await ConnectAsync(agentOfSecond, second, settingsRevision: 5, limit.Token);
Assert.Equal(9, welcome.Payload.GetProperty("settings").GetProperty("revision").GetInt64());
Assert.Equal((3, 5), (first.LastHelloSettingsRevision, second.LastHelloSettingsRevision));
Assert.Contains("\"jobId\":\"second-job\"", await ClaimAsync(toSecond, second, limit.Token), StringComparison.Ordinal);
Assert.Contains("\"jobId\":\"job\"", await ClaimAsync(toFirst, first, limit.Token), StringComparison.Ordinal);
Assert.Equal((2, 1), (first.Claims, second.Claims));
Assert.Equal("STATE_APPLIED", Assert.Single(second.Messages).Type);
Assert.Contains("\"version\":\"2.0.0\"", await DiscoveryAsync(toFirst, first, limit.Token), StringComparison.Ordinal);
Assert.Contains("\"version\":\"2.4.0\"", await DiscoveryAsync(toSecond, second, limit.Token), StringComparison.Ordinal);
Assert.Equal((1, 3), (first.DiscoveryRequests, first.Requests));
Assert.Equal((1, 3), (second.DiscoveryRequests, second.Requests));
}
/// <summary>
/// A hold and a refusal placed on one server do not reach the other. With claims held on the first server, a claim to it
/// arrives and waits while a claim to the second is answered at once; the held claim is answered when the hold is
/// released. With the first server told to refuse every message, the same message is refused there with an ERROR frame
/// and acknowledged by the second.
/// </summary>
[Fact]
public async Task ScriptedPrintServer_HoldAndRefusalOnOne_DoNotReachTheOther()
{
using var limit = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using ScriptedPrintServer first = new();
await using ScriptedPrintServer second = new();
using HttpMessageInvoker toFirst = new(first.CreateHandler());
using HttpMessageInvoker toSecond = new(second.CreateHandler());
using ClientWebSocket agentOfFirst = new();
using ClientWebSocket agentOfSecond = new();
_ = await ConnectAsync(agentOfFirst, first, settingsRevision: 1, limit.Token);
_ = await ConnectAsync(agentOfSecond, second, settingsRevision: 1, limit.Token);
first.ClaimHold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Task<string> held = ClaimAsync(toFirst, first, limit.Token);
await first.ClaimArrived.Task.WaitAsync(limit.Token);
Assert.Equal(NoJob, await ClaimAsync(toSecond, second, limit.Token));
Assert.False(held.IsCompleted);
first.ClaimHold.SetResult();
Assert.Equal(NoJob, await held);
first.RefuseWith = _ => "NOT_FOUND";
Assert.Equal("ERROR", await ExchangeAsync(agentOfFirst, "STATUS", limit.Token));
Assert.Equal("ACK", await ExchangeAsync(agentOfSecond, "STATUS", limit.Token));
}
/// <summary>
/// Stopping one server stops only that server. After the first server closes its connections, its own pump has ended,
/// while the second server's connection still acknowledges a message and its HTTP side still answers. The second
/// server's connection ends when the second server itself is closed, and not before.
/// </summary>
[Fact]
public async Task ScriptedPrintServer_ClosingOne_LeavesTheOtherAnswering()
{
using var limit = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using ScriptedPrintServer first = new();
await using ScriptedPrintServer second = new();
using HttpMessageInvoker toSecond = new(second.CreateHandler());
using ClientWebSocket agentOfFirst = new();
using ClientWebSocket agentOfSecond = new();
(_, ScriptedPrintServer.Connection ofFirst) = await ConnectAsync(agentOfFirst, first, settingsRevision: 1, limit.Token);
(_, ScriptedPrintServer.Connection ofSecond) = await ConnectAsync(agentOfSecond, second, settingsRevision: 1, limit.Token);
await first.CloseConnectionsAsync();
Assert.True(ofFirst.Completion.IsCompleted);
Assert.Equal("ACK", await ExchangeAsync(agentOfSecond, "STATUS", limit.Token));
Assert.False(ofSecond.Completion.IsCompleted);
Assert.Contains("\"version\":\"2.0.0\"", await DiscoveryAsync(toSecond, second, limit.Token), StringComparison.Ordinal);
await second.CloseConnectionsAsync();
Assert.True(ofSecond.Completion.IsCompleted);
}
/// <summary>
/// A request that reaches a server's handler but is addressed to another server is refused and counted, and nothing else
/// about either server changes: the handler that received it counts one misrouted request and routes nothing, and the
/// server the request named never sees it. This is what catches a session built on the wrong server's handler. A
/// request addressed to the handler's own server is still answered afterwards.
/// </summary>
[Fact]
public async Task ScriptedPrintServer_RequestForAnotherOrigin_IsRefusedAndCounted()
{
using var limit = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using ScriptedPrintServer first = new();
await using ScriptedPrintServer second = new();
using HttpMessageInvoker toFirst = new(first.CreateHandler());
InvalidOperationException refusal = await Assert.ThrowsAsync<InvalidOperationException>(() => DiscoveryAsync(toFirst, second, limit.Token));
Assert.Contains(second.Origin.Authority, refusal.Message, StringComparison.Ordinal);
Assert.Equal((1, 1, 0), (first.MisroutedRequests, first.Requests, first.DiscoveryRequests));
Assert.Equal((0, 0, 0), (second.MisroutedRequests, second.Requests, second.DiscoveryRequests));
Assert.Contains("\"version\":\"2.0.0\"", await DiscoveryAsync(toFirst, first, limit.Token), StringComparison.Ordinal);
Assert.Equal((1, 2, 1), (first.MisroutedRequests, first.Requests, first.DiscoveryRequests));
}
/// <summary>
/// Plays the agent's opening on one server: opens the realtime socket, says HELLO with the given settings revision, reads
/// WELCOME, reports the desired state as applied and reads the acknowledgement, after which that server takes claims.
/// Returns the WELCOME the agent was sent and the server's end of the connection.
/// </summary>
private static async Task<(RealtimeEnvelope Welcome, ScriptedPrintServer.Connection Connection)> ConnectAsync(ClientWebSocket agent, ScriptedPrintServer server, long settingsRevision, CancellationToken token)
{
agent.Options.AddSubProtocol("qqq-printing.v1");
Task<ScriptedPrintServer.Connection> accepting = server.AcceptAsync(new DeviceDesiredState(1, false, "CONNECTED"), loseSpoolAck: false, token);
await agent.ConnectAsync(new UriBuilder(server.Origin) { Scheme = "ws", Path = "/qqq/v1/qbit/printing/device/realtime" }.Uri, token);
await LoopbackWebSocketPeer.SendAsync(agent, "HELLO", new { lastSettingsRevision = settingsRevision, capabilities = new[] { "synthetic" } }, token);
RealtimeEnvelope welcome = await LoopbackWebSocketPeer.ReceiveAsync(agent, token);
Assert.Equal("WELCOME", welcome.Type);
ScriptedPrintServer.Connection connection = await accepting;
Assert.Equal("ACK", await ExchangeAsync(agent, "STATE_APPLIED", token));
return (welcome, connection);
}
/// <summary>Sends one agent message of the given type and returns the type of the frame the server answers it with.</summary>
private static async Task<string> ExchangeAsync(ClientWebSocket agent, string type, CancellationToken token)
{
await LoopbackWebSocketPeer.SendAsync(agent, type, new { lastAppliedRevision = 1 }, token);
return (await LoopbackWebSocketPeer.ReceiveAsync(agent, token)).Type;
}
/// <summary>Makes one claim on a server through the given handler and returns the body of the answer.</summary>
private static async Task<string> ClaimAsync(HttpMessageInvoker http, ScriptedPrintServer server, CancellationToken token)
{
using HttpRequestMessage request = new(HttpMethod.Post, new Uri(server.Origin, "/qqq/v1/qbit/printing/device/jobs/claim"));
using HttpResponseMessage response = await http.SendAsync(request, token);
return await response.Content.ReadAsStringAsync(token);
}
/// <summary>Reads the version discovery addressed to a server, the document that names its printing version, through the given handler.</summary>
private static async Task<string> DiscoveryAsync(HttpMessageInvoker http, ScriptedPrintServer server, CancellationToken token)
{
using HttpRequestMessage request = new(HttpMethod.Get, new Uri(server.Origin, "/qqq/v1/version"));
using HttpResponseMessage response = await http.SendAsync(request, token);
return await response.Content.ReadAsStringAsync(token);
}
}
@@ -4,7 +4,6 @@
*/
using System.Collections.Concurrent;
using System.IO.Pipes;
using System.Net;
using System.Text;
using System.Text.Json;
@@ -39,20 +38,13 @@ internal sealed class SessionFixture : IDisposable
/// <summary>The management pipe of the runtime built last.</summary>
internal string PipeName { get; private set; } = "";
/// <summary>
/// Builds a runtime on a private pipe and this fixture's clock whose session factory hands out the scripted sessions in
/// priority order (custom factory, next session, default) and counts each creation.
/// </summary>
internal AgentRuntime Runtime()
internal SingleServerHarness Runtime()
{
string pipe = PipeName = "qqq-session-fixture-" + Guid.NewGuid().ToString("N");
return new(Local.DirectoryPath, Logs.Enqueue, Local.Printer, pipeName: pipe,
pipeFactory: () => new NamedPipeServerStream(pipe, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly),
return new(Local.DirectoryPath, Logs.Enqueue, Local.Printer,
sessionFactory: origin =>
{
Created++;
@@ -67,9 +59,9 @@ internal sealed class SessionFixture : IDisposable
/// the clock first. A first evaluation restores the saved session; every later one therefore renews it with the server
/// and checks the identity again, which is what makes a later evaluation a real check.
/// </summary>
internal Task<bool> EvaluateAsync(AgentRuntime runtime, CancellationToken token = default)
internal Task<bool> EvaluateAsync(SingleServerHarness runtime, CancellationToken token = default)
{
Clock.Advance(AgentRuntime.SessionResumeInterval);
Clock.Advance(SingleServerHarness.SessionResumeInterval);
return runtime.EnsureAuthorizationAsync(token);
}
@@ -81,7 +73,7 @@ internal sealed class SessionFixture : IDisposable
/// the realtime socket. Waiting for the backoff, which is a timer on this fixture's clock, is what proves the pass is
/// over: a test that then finds no attempt knows none was made, not that none had been made yet.
/// </summary>
internal async Task RunOnePassAsync(AgentRuntime runtime)
internal async Task RunOnePassAsync(SingleServerHarness runtime)
{
using var guard = new CancellationTokenSource(TimeSpan.FromSeconds(30));
Task running = runtime.RunAsync(guard.Token);
@@ -124,7 +116,7 @@ internal sealed class SessionFixture : IDisposable
/// <summary>Stores the fake session's binding and credential as if the user had already signed in, so a runtime can resume it.</summary>
internal void SaveSession()
{
using AgentStore store = new(Local.DirectoryPath);
using JournalStore store = TestState.OpenStore(Local.DirectoryPath);
store.SaveAuthentication(new AgentAuthentication(Session.Binding, Session.ExportCredential()));
}
@@ -0,0 +1,180 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.IO.Pipes;
using System.Text.Json;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The service's runtime as the suite uses it: one computer paired with one server. This is the only type in the test project
/// that names the runtime class, so when the runtime is split into a host with one connection per server, this file changes
/// and the tests do not. Every member is something a test calls today; none was added for later.
/// <para>
/// Members that keep their shape but will mean something narrower once several servers can exist: <see cref="GetSnapshot"/>
/// returns the one combined snapshot of today, which becomes the computer's part joined with the first server's;
/// <see cref="GetTransportSnapshot"/>, <see cref="EnsureAuthorizationAsync"/>, <see cref="SignInAsync"/>,
/// <see cref="SignOutAsync(CancellationToken)"/> and <see cref="EnrollWithGatesAsync"/> act on the runtime as a whole today
/// and will act on the one server's connection; <see cref="PrepareUpdateAsync"/>, <see cref="ResumeAfterUpdate"/> and
/// <see cref="RunAsync"/> stay with the computer. <see cref="PipeAsync(PipeRequest, CancellationToken)"/> speaks the pipe's
/// first protocol, which has no server in it; when the pipe gains its versioned envelope, this is where a request is wrapped.
/// The constants at the top and the pure functions at the bottom are the runtime's today and move to the types that will own them.
/// </para>
/// </summary>
internal sealed class SingleServerHarness : IDisposable
{
/// <summary>How long a restored or renewed session is used before the service renews it with the server and checks its identity again.</summary>
internal static TimeSpan SessionResumeInterval => AgentRuntime.SessionResumeInterval;
/// <summary>How long a reading of the operator's printer catalog stays current before the service reads it again.</summary>
internal static TimeSpan CatalogRefreshInterval => AgentRuntime.CatalogRefreshInterval;
/// <summary>How long an update pause lasts when the updater never resumes printing.</summary>
internal static TimeSpan UpdatePauseDuration => AgentRuntime.UpdatePauseDuration;
/// <summary>How long update preparation waits for claimed work before it answers that the service is not ready.</summary>
internal static TimeSpan UpdatePreparationLimit => AgentRuntime.UpdatePreparationLimit;
/// <summary>The name of the installed service's management pipe, which no harness may listen on: a test that did would collide with that service, or answer in its place on a computer that has none.</summary>
private const string InstalledPipeName = "QqqPrintAgent";
/// <summary>The runtime every member passes through to. Nothing else in the test project holds one.</summary>
private readonly AgentRuntime _runtime;
/// <summary>The private name of this runtime's management pipe. It is never the installed service's (the constructor refuses that name), and it is unique unless the caller passes a name to keep across a restart.</summary>
internal string PipeName { get; }
/// <summary>
/// Builds a runtime on a state folder with the given log, printers, session factory and clock. The harness makes the
/// management pipe itself, one instance for the current user on a private name, so no test decides how the service listens;
/// a buffer size is given only by the test that needs a reply to outgrow the pipe's buffer. The last argument runs on the
/// runtime's command thread just before it saves a command's result, while that thread holds the control state's lock.
/// The installed service's pipe name is refused, in any letter case, before anything is built.
/// </summary>
internal SingleServerHarness(string directory, Action<string> log, IPrinterBackend printerBackend, Func<Uri, IQqqSessionClient>? sessionFactory = null,
TimeProvider? timeProvider = null, string? pipeName = null, int pipeBufferSize = 0, Action? controlResultSaving = null)
{
if(string.Equals(pipeName, InstalledPipeName, StringComparison.OrdinalIgnoreCase))
{ throw new ArgumentException("A test must not listen on the installed service's management pipe.", nameof(pipeName)); }
string pipe = PipeName = pipeName ?? "qqq-single-server-" + Guid.NewGuid().ToString("N");
_runtime = new AgentRuntime(directory, log, printerBackend, pipeName: pipe,
pipeFactory: () => new NamedPipeServerStream(pipe, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, pipeBufferSize, pipeBufferSize),
sessionFactory: sessionFactory, timeProvider: timeProvider)
{ ControlResultSaving = controlResultSaving };
}
/// <summary>Runs the service loop on the state folder until the token is cancelled, as the Windows service does from start to stop.</summary>
internal Task RunAsync(CancellationToken token) => _runtime.RunAsync(token);
/// <summary>What the service reports about itself: pairing, sign-in, health, printers, jobs, control state and logs, with no credential in it.</summary>
internal AgentSnapshot GetSnapshot() => _runtime.GetSnapshot();
/// <summary>The connection's state and traffic figures alone, read without touching printers or the journal.</summary>
internal AgentTransportSnapshot GetTransportSnapshot() => _runtime.GetTransportSnapshot();
/// <summary>Paces one download chunk on the bandwidth schedule every download of the one server shares; it moves to the server's connection with the rest of the claim pipeline.</summary>
internal Task ThrottleDownloadAsync(int bytes, CancellationToken token) => _runtime.ThrottleDownloadAsync(bytes, token);
/// <summary>Evaluates once whether the service may talk to its server, restoring or renewing the saved session as the reconnect loop and the heartbeat do.</summary>
internal Task<bool> EnsureAuthorizationAsync(CancellationToken token) => _runtime.EnsureAuthorizationAsync(token);
/// <summary>Signs the service in with the session a sign-in request carries and returns the reply the pipe would give.</summary>
internal Task<string> SignInAsync(PipeRequest request, CancellationToken token) => _runtime.SignInAsync(request, token);
/// <summary>Signs the service out of QQQ only and returns the reply the pipe would give.</summary>
internal Task<string> SignOutAsync(CancellationToken token) => _runtime.SignOutAsync(token);
/// <summary>Signs the service out and, when a callback is given, asks for the sign-in provider's own sign-out as well; the reply then carries the provider's request for the companion to open.</summary>
internal Task<string> SignOutAsync(string? postLogoutRedirectUri, CancellationToken token) => _runtime.SignOutAsync(postLogoutRedirectUri, token);
/// <summary>Enrolls the computer with the code an enrollment request carries, behind the same gates the pipe command passes, and returns the reply.</summary>
internal Task<string> EnrollWithGatesAsync(PipeRequest request, CancellationToken token) => _runtime.EnrollWithGatesAsync(request, token);
/// <summary>Stops new claims and waits for the jobs already claimed, as the update helper asks before it replaces the service; returns the reply.</summary>
internal Task<string> PrepareUpdateAsync(CancellationToken token) => _runtime.PrepareUpdateAsync(token);
/// <summary>Lifts the update pause and returns the reply.</summary>
internal string ResumeAfterUpdate() => _runtime.ResumeAfterUpdate();
/// <summary>Sends one command over the runtime's management pipe, as the companion and command line do, and returns the reply.</summary>
internal Task<string> PipeAsync(PipeRequest request, CancellationToken token) => PipeAsync(JsonSerializer.Serialize(request, PrintApiClient.JsonOptions), token);
/// <summary>Sends one command, given as the text a client would write, over the runtime's management pipe and returns the reply.</summary>
internal async Task<string> PipeAsync(string command, CancellationToken token)
{
await using NamedPipeClientStream pipe = new(".", PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
await pipe.ConnectAsync(token);
await AgentPipeFraming.WriteAsync(pipe, command, AgentPipeFraming.MaximumCommandCharacters, token);
return await AgentPipeFraming.ReadAsync(pipe, AgentPipeFraming.MaximumResponseCharacters, token);
}
/// <summary>Releases what the runtime holds in memory; the state folder is the caller's to remove.</summary>
public void Dispose() => _runtime.Dispose();
/// <summary>The access rules the service puts on its management pipe: LocalSystem and Administrators, and nobody else.</summary>
internal static PipeSecurity CreatePipeSecurity() => AgentRuntime.CreatePipeSecurity();
/// <summary>How long the connection loop waits before a given reconnect attempt; the caller supplies the jitter sample, so the schedule can be tested exactly.</summary>
internal static TimeSpan ReconnectDelay(RealtimeSettings settings, int attempt, double sample) => AgentRuntime.ReconnectDelay(settings, attempt, sample);
/// <summary>Waits for every task of a finished realtime connection and rethrows one failure as the reason it ended; the other failures go to the callback.</summary>
internal static Task CompleteConnectionAsync(Action<Exception> alsoFailed, Task reader, params Task[] workers) => AgentRuntime.CompleteConnectionAsync(alsoFailed, reader, workers);
/// <summary>One page of the job list: the journal's jobs merged, under one sort, with the server's queued jobs that the journal does not hold yet.</summary>
internal static JobPage QueryJobsWithQueue(JournalStore store, JobQuery query, IReadOnlyList<JobSnapshot> queued) => AgentRuntime.QueryJobsWithQueue(store, query, queued);
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>Tests of the harness's own promises, the ones no test of the runtime would notice were broken.</summary>
public sealed class SingleServerHarnessTests
{
/// <summary>
/// The harness refuses the installed service's pipe name, in any letter case, because pipe names are compared without
/// regard to case and a test must never listen where the companion and the command line of this computer connect. The
/// refusal comes before the runtime is built: the state folder the harness was given is not created.
/// </summary>
[Theory]
[InlineData("QqqPrintAgent")]
[InlineData("qqqPRINTAGENT")]
public void InstalledServicePipeName_IsRefused_BeforeAnythingIsBuilt(string name)
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-harness-refusal-" + Guid.NewGuid().ToString("N"));
ArgumentException refusal = Assert.Throws<ArgumentException>(() => new SingleServerHarness(directory, _ => { }, new RealtimeRuntimeFixture.SyntheticFilePrinter(), pipeName: name));
Assert.Equal("pipeName", refusal.ParamName);
Assert.False(Directory.Exists(directory));
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// Tests of the synthetic printer itself. They call the fake directly, with no service in between, so they show what the
/// printer holds and what it lets through and nothing else. A test that goes through the service has a second thing in the
/// way: the service submits one print at a time unless the server's settings allow more, so a job for the other queue
/// reaches the printer while one is held only when those settings allow two prints at once.
/// </summary>
public sealed class SyntheticFilePrinterTests
{
/// <summary>
/// With one queue held, a submission to that queue enters the printer and stays there: it is counted as a submission and
/// not as a print. A submission made afterwards to the other queue finishes at once, while the first is still held. When
/// the hold is released the held one finishes too. The printer's order is the order in which jobs finished, not the
/// order in which they entered, so it reads the other queue's job first and the held one second.
/// </summary>
[Fact]
public async Task BlockPrinter_HoldsOneQueueWhileTheOtherPrints()
{
string path = Path.Combine(Path.GetTempPath(), "qqq-block-printer-" + Guid.NewGuid().ToString("N"));
try
{
await File.WriteAllBytesAsync(path, [1, 2, 3]);
var printer = new RealtimeRuntimeFixture.SyntheticFilePrinter { BlockPrinter = "printer" };
Task<string> held = printer.PrintFileAsync(Job("a", "printer"), path, TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.Equal((1, 0), (printer.Submissions, printer.Prints));
Assert.Empty(printer.Order);
Assert.Equal("123", await printer.PrintFileAsync(Job("b", "other"), path, TimeSpan.FromSeconds(30), CancellationToken.None));
Assert.False(held.IsCompleted);
Assert.Equal((2, 1), (printer.Submissions, printer.Prints));
Assert.Equal("b", Assert.Single(printer.Order));
printer.Release.TrySetResult();
Assert.Equal("123", await held);
Assert.Equal((2, 2), (printer.Submissions, printer.Prints));
Assert.Equal(["b", "a"], printer.Order.ToArray());
}
finally
{
File.Delete(path);
}
}
/// <summary>
/// With no queue named, nothing is held on that account: a job for either queue, and a job that names no printer at all,
/// finish at once. The last case is the one a comparison of "no queue held" with "no printer named" would get wrong.
/// </summary>
[Fact]
public async Task BlockPrinter_Unset_HoldsNothing_NotEvenAJobThatNamesNoPrinter()
{
string path = Path.Combine(Path.GetTempPath(), "qqq-block-printer-" + Guid.NewGuid().ToString("N"));
try
{
await File.WriteAllBytesAsync(path, [1, 2, 3]);
var printer = new RealtimeRuntimeFixture.SyntheticFilePrinter();
Task<string>[] submissions =
[
printer.PrintFileAsync(Job("a", "printer"), path, TimeSpan.FromSeconds(30), CancellationToken.None),
printer.PrintFileAsync(Job("b", "other"), path, TimeSpan.FromSeconds(30), CancellationToken.None),
printer.PrintFileAsync(Job("c", null!), path, TimeSpan.FromSeconds(30), CancellationToken.None),
];
Assert.All(submissions, submission => Assert.True(submission.IsCompleted));
Assert.Equal((3, 3), (printer.Submissions, printer.Prints));
Assert.Equal(["a", "b", "c"], printer.Order.ToArray());
}
finally
{
File.Delete(path);
}
}
/// <summary>A three-byte job for the named queue; the length is what the printer checks the staged file against.</summary>
private static PrintJob Job(string id, string printerId) => new(id, printerId, "Synthetic " + id, "PDF", "unused", 3, new PrintOptions(), RealtimeRuntimeFixture.Now(), "CLAIMED");
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
// The store's type under the name tests use for it. Every test declares a store as JournalStore, so when the product type
// is renamed, this line and OpenStore below change and no test does.
global using JournalStore = Qqq.PrintAgent.Windows.AgentStore;
using System.Runtime.CompilerServices;
using Microsoft.Data.Sqlite;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// How tests reach the service's state on disk. This is the only place in the test project that constructs the store, so when
/// the store is opened for one server out of a registry, the way a test names "the store in this folder" changes here and
/// nowhere else. Tests name the store's type by the alias declared above, never by the product's name. This is also the
/// only place a test gets a connection of its own to a journal, because such a connection has to stay out of the pool the
/// stores of that journal share.
/// </summary>
internal static class TestState
{
/// <summary>
/// Opens the store on a state folder, creating the folder and the journal when they are missing, exactly as the service
/// does. With recovery asked for, a pairing file that cannot be read does not stop the open. The caller disposes the store.
/// </summary>
internal static AgentStore OpenStore(string directory, bool recoverUnreadableConfiguration = false) => new(directory, recoverUnreadableConfiguration);
/// <summary>
/// Opens a connection of the test's own to the journal in a folder, for seeding rows, damaging the schema or asking SQLite
/// a question the store has no call for. The connection is not pooled. A pooled one would share the pool of the stores on
/// that journal without taking the journal's lock as they do, and the SQLite library can then hand one connection to two
/// openers (see "Testing the service runtime" in docs/DEVELOPMENT.md). Unpooled, it has a native handle no store can be
/// given, and closing it releases the file at once. The store's type is initialized first because that is what selects
/// the SQLite library, which a test that opens a journal before any store would otherwise find missing.
/// </summary>
internal static SqliteConnection OpenJournal(string directory)
{
RuntimeHelpers.RunClassConstructor(typeof(AgentStore).TypeHandle);
SqliteConnection journal = new(new SqliteConnectionStringBuilder { DataSource = Path.Combine(directory, "journal.db"), Pooling = false }.ToString());
journal.Open();
return journal;
}
/// <summary>
/// The connection string the stores use for the journal in a folder. It names the journal's pool of idle connections, which
/// is how <see cref="JournalFiles"/> empties that pool and no other before a folder is deleted.
/// </summary>
internal static string JournalConnectionString(string directory) => AgentStore.JournalConnectionString(directory);
/// <summary>The lock every store takes while it opens the journal in a folder, for the tests that hold it to show what an open waits for.</summary>
internal static Lock SchemaLockFor(string directory) => AgentStore.SchemaLockFor(directory);
}