Skip to content

Commit 4517a75

Browse files
committed
feat(12-03): add GsdStateMachineTests — 7 deterministic xUnit tests (ROB-02)
1 parent 031007d commit 4517a75

1 file changed

Lines changed: 178 additions & 0 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
using GsdOrchestrator.Checkpointing;
2+
using GsdOrchestrator.Mcp;
3+
using GsdOrchestrator.Workflows;
4+
using GsdOrchestrator.Workflows.Models;
5+
using GsdOrchestrator.Workflows.States;
6+
using Microsoft.Extensions.Logging.Abstractions;
7+
using NSubstitute;
8+
using NSubstitute.ExceptionExtensions;
9+
using Polly.Registry;
10+
using Xunit;
11+
12+
namespace GsdOrchestrator.Tests;
13+
14+
public class GsdStateMachineTests
15+
{
16+
// ── Helpers ─────────────────────────────────────────────────────────────
17+
18+
private static GsdStateMachine BuildSut(
19+
ICheckpointStore checkpoints,
20+
IWorkflowState[] states,
21+
IMcpClient? mcpClient = null)
22+
{
23+
var client = mcpClient ?? Substitute.For<IMcpClient>();
24+
var registry = new ResiliencePipelineRegistry<string>();
25+
// Pass-through pipeline — no retry/circuit breaker in unit tests
26+
registry.TryAddBuilder("mcp-tools", (b, _) => { });
27+
var dispatcher = new McpToolDispatcher(
28+
client,
29+
registry,
30+
NullLogger<McpToolDispatcher>.Instance);
31+
return new GsdStateMachine(
32+
checkpoints,
33+
dispatcher,
34+
states,
35+
NullLogger<GsdStateMachine>.Instance);
36+
}
37+
38+
private static IWorkflowState MakeState(WorkflowState from, WorkflowState to)
39+
{
40+
var state = Substitute.For<IWorkflowState>();
41+
state.State.Returns(from);
42+
state.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
43+
.Returns(ci => Task.FromResult(ci.Arg<GsdWorkflowContext>() with { CurrentState = to }));
44+
return state;
45+
}
46+
47+
// ── Tests ───────────────────────────────────────────────────────────────
48+
49+
[Fact]
50+
public async Task RunAsync_SingleStateTransitionsToDone_ReturnsDoneContext()
51+
{
52+
var checkpoints = Substitute.For<ICheckpointStore>();
53+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
54+
.Returns(Task.CompletedTask);
55+
checkpoints.ArchiveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
56+
.Returns(Task.CompletedTask);
57+
58+
var idleState = MakeState(WorkflowState.Idle, WorkflowState.Done);
59+
var sut = BuildSut(checkpoints, [idleState]);
60+
61+
var ctx = await sut.RunAsync("owner", "repo", 42, CancellationToken.None);
62+
63+
Assert.Equal(WorkflowState.Done, ctx.CurrentState);
64+
await checkpoints.Received().ArchiveAsync(ctx.WorkflowId, Arg.Any<CancellationToken>());
65+
}
66+
67+
[Fact]
68+
public async Task RunAsync_StateThrowsException_ContextTransitionsToFailed()
69+
{
70+
var checkpoints = Substitute.For<ICheckpointStore>();
71+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
72+
.Returns(Task.CompletedTask);
73+
74+
var idleState = Substitute.For<IWorkflowState>();
75+
idleState.State.Returns(WorkflowState.Idle);
76+
idleState.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
77+
.ThrowsAsync(new InvalidOperationException("simulated failure"));
78+
var sut = BuildSut(checkpoints, [idleState]);
79+
80+
var ctx = await sut.RunAsync("owner", "repo", 1, CancellationToken.None);
81+
82+
Assert.Equal(WorkflowState.Failed, ctx.CurrentState);
83+
Assert.NotNull(ctx.FailureReason);
84+
}
85+
86+
[Fact]
87+
public async Task RunAsync_NoHandlerForState_ThrowsInvalidOperationException()
88+
{
89+
var checkpoints = Substitute.For<ICheckpointStore>();
90+
91+
// No states registered — Idle has no handler, throws before SaveAsync
92+
var sut = BuildSut(checkpoints, []);
93+
94+
await Assert.ThrowsAsync<InvalidOperationException>(
95+
() => sut.RunAsync("owner", "repo", 1, CancellationToken.None));
96+
}
97+
98+
[Fact]
99+
public async Task RunAsync_MultipleStateTransitions_AllCheckpointsSaved()
100+
{
101+
var checkpoints = Substitute.For<ICheckpointStore>();
102+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
103+
.Returns(Task.CompletedTask);
104+
checkpoints.ArchiveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
105+
.Returns(Task.CompletedTask);
106+
107+
var idleState = MakeState(WorkflowState.Idle, WorkflowState.Analyzing);
108+
var analyzingState = MakeState(WorkflowState.Analyzing, WorkflowState.Done);
109+
var sut = BuildSut(checkpoints, [idleState, analyzingState]);
110+
111+
var ctx = await sut.RunAsync("owner", "repo", 5, CancellationToken.None);
112+
113+
Assert.Equal(WorkflowState.Done, ctx.CurrentState);
114+
// SaveAsync: before Idle (×1) + before Analyzing (×1) + final checkpoint (×1) = 3
115+
await checkpoints.Received(3).SaveAsync(
116+
Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>());
117+
}
118+
119+
[Fact]
120+
public async Task ResumeAsync_CheckpointExists_ResumesFromSavedState()
121+
{
122+
var checkpoints = Substitute.For<ICheckpointStore>();
123+
var savedCtx = new GsdWorkflowContext
124+
{
125+
WorkflowId = "test-wf-01",
126+
CurrentState = WorkflowState.Analyzing,
127+
Issue = new IssueContext(7, "Test issue", "", [], "owner", "repo", "main")
128+
};
129+
checkpoints.LoadAsync("test-wf-01", Arg.Any<CancellationToken>())
130+
.Returns(savedCtx);
131+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
132+
.Returns(Task.CompletedTask);
133+
checkpoints.ArchiveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
134+
.Returns(Task.CompletedTask);
135+
136+
var analyzingState = MakeState(WorkflowState.Analyzing, WorkflowState.Done);
137+
var sut = BuildSut(checkpoints, [analyzingState]);
138+
139+
var ctx = await sut.ResumeAsync("test-wf-01", CancellationToken.None);
140+
141+
Assert.Equal(WorkflowState.Done, ctx.CurrentState);
142+
}
143+
144+
[Fact]
145+
public async Task ResumeAsync_NoCheckpointExists_ThrowsInvalidOperationException()
146+
{
147+
var checkpoints = Substitute.For<ICheckpointStore>();
148+
checkpoints.LoadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
149+
.Returns((GsdWorkflowContext?)null);
150+
var sut = BuildSut(checkpoints, []);
151+
152+
await Assert.ThrowsAsync<InvalidOperationException>(
153+
() => sut.ResumeAsync("missing-id", CancellationToken.None));
154+
}
155+
156+
[Fact]
157+
public async Task RunAsync_CancellationRequested_ThrowsOperationCanceledException()
158+
{
159+
var checkpoints = Substitute.For<ICheckpointStore>();
160+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
161+
.Returns(Task.CompletedTask);
162+
163+
using var cts = new CancellationTokenSource();
164+
var idleState = Substitute.For<IWorkflowState>();
165+
idleState.State.Returns(WorkflowState.Idle);
166+
idleState.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
167+
.Returns(async ci =>
168+
{
169+
cts.Cancel();
170+
ci.Arg<CancellationToken>().ThrowIfCancellationRequested();
171+
return ci.Arg<GsdWorkflowContext>();
172+
});
173+
var sut = BuildSut(checkpoints, [idleState]);
174+
175+
await Assert.ThrowsAsync<OperationCanceledException>(
176+
() => sut.RunAsync("owner", "repo", 1, cts.Token));
177+
}
178+
}

0 commit comments

Comments
 (0)