Skip to content

Commit d91fdd1

Browse files
test(ai): add Kuestenlogik.Bowire.Ai.Tests — coverage for the AI runtime + config store + chat-client seam
Dedicated test project for the optional Kuestenlogik.Bowire.Ai package (#25 Phase 2). Sits alongside the existing pin in Kuestenlogik.Bowire.Tests so the Ai package can rev / ship its tests independently when it eventually moves out of the monorepo. 90 facts across 5 files: - BowireAiOptionsTests — privacy-first defaults (ollama / localhost), Bowire:Ai configuration binding shape, partial-section merging. - BowireAiRuntimeTests — argument validation, defensive-copy semantics on Options + Update, case-insensitive provider-id matching across ollama / lmstudio, empty-endpoint local default, hot-swap dispose semantics, unknown→known + known→unknown transitions. - BowireAiUserConfigStoreTests — round-trip save/load, partial-file default fallback per field, corrupted-file resilience, #116 Phase 3 per-workspace override layer (Save / TryLoad / HasOverride / RemoveOverride), workspaceId path-traversal sanitisation, allowlisted-character preservation, parent-directory auto-mkdir, on-disk camelCase + pretty-printed JSON shape pin. - BowireAiServiceCollectionExtensionsTests — argument-null guards, singleton registration shape, host-IChatClient-wins TryAdd contract, the full overlay precedence (defaults → IConfiguration → configure callback → user-config file), field-by-field overlay with empty-string vs. null distinction, idempotency on re-call. - MutableChatClientTests — null-runtime guard, no-client error message prefix on GetResponseAsync + GetStreamingResponseAsync, delegation to runtime.Current with hot-swap pickup, ChatOptions forwarding, GetService passthrough, Dispose-doesn't-own-inner contract. - BowireAiEndpointsTests — route-registration shape under custom basePath, /status reflecting runtime + hostManaged + per-workspace override, /probe-local short-circuit when AutoDetectLocal=false, /config persistence + partial-body fallback + non-http endpoint rejection + invalid-JSON 400, DELETE /config workspace-override removal + revert-to-global, /chat RFC 7807 ProblemDetails on no-client (503) / invalid-input (400) paths. InternalsVisibleTo added to Kuestenlogik.Bowire.Ai for the new test assembly so MutableChatClient (internal sealed) can be poked directly through the proxy seam. The slnx file is intentionally NOT touched — solution-side registration lands separately.
1 parent 5031c4e commit d91fdd1

10 files changed

Lines changed: 2027 additions & 0 deletions

src/Kuestenlogik.Bowire.Ai/Kuestenlogik.Bowire.Ai.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
</PropertyGroup>
88
<ItemGroup>
99
<InternalsVisibleTo Include="Kuestenlogik.Bowire.Tests" />
10+
<InternalsVisibleTo Include="Kuestenlogik.Bowire.Ai.Tests" />
1011
</ItemGroup>
1112
<ItemGroup>
1213
<FrameworkReference Include="Microsoft.AspNetCore.App" />

tests/Kuestenlogik.Bowire.Ai.Tests/BowireAiEndpointsTests.cs

Lines changed: 650 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright 2026 Küstenlogik
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using Microsoft.Extensions.Configuration;
5+
6+
namespace Kuestenlogik.Bowire.Ai.Tests;
7+
8+
/// <summary>
9+
/// Pins the privacy-first default values on <see cref="BowireAiOptions"/>
10+
/// and the <c>Bowire:Ai</c> configuration binding shape. The defaults
11+
/// matter — they're the documented "nothing leaves the machine"
12+
/// stance the package ADR commits to. A silent default-flip is a
13+
/// behaviour change for every existing install.
14+
/// </summary>
15+
public sealed class BowireAiOptionsTests
16+
{
17+
[Fact]
18+
public void Defaults_Match_PrivacyFirst_OllamaLocalhost()
19+
{
20+
var opts = new BowireAiOptions();
21+
22+
Assert.Equal("ollama", opts.ProviderId);
23+
Assert.Equal("http://localhost:11434", opts.Endpoint);
24+
Assert.Equal("llama3.2:3b", opts.Model);
25+
Assert.True(opts.AutoDetectLocal);
26+
}
27+
28+
[Fact]
29+
public void Bowire_Ai_Section_Binds_All_Fields()
30+
{
31+
// The CLI's --ai-provider / --ai-endpoint / --ai-model flags
32+
// feed the same Bowire:Ai keys via the in-memory configuration
33+
// overlay, so the binding shape is part of the public contract.
34+
var cfg = new ConfigurationBuilder()
35+
.AddInMemoryCollection(new Dictionary<string, string?>
36+
{
37+
["Bowire:Ai:ProviderId"] = "lmstudio",
38+
["Bowire:Ai:Endpoint"] = "http://localhost:1234",
39+
["Bowire:Ai:Model"] = "qwen2.5:7b",
40+
["Bowire:Ai:AutoDetectLocal"] = "false",
41+
})
42+
.Build();
43+
44+
var opts = new BowireAiOptions();
45+
cfg.GetSection("Bowire:Ai").Bind(opts);
46+
47+
Assert.Equal("lmstudio", opts.ProviderId);
48+
Assert.Equal("http://localhost:1234", opts.Endpoint);
49+
Assert.Equal("qwen2.5:7b", opts.Model);
50+
Assert.False(opts.AutoDetectLocal);
51+
}
52+
53+
[Fact]
54+
public void Bowire_Ai_PartialSection_Preserves_Unbound_Defaults()
55+
{
56+
// Partial overrides (Settings UI sends a patch) shouldn't blow
57+
// away fields the user didn't touch — Bind merges over the
58+
// existing instance, so unset keys stay at their default value.
59+
var cfg = new ConfigurationBuilder()
60+
.AddInMemoryCollection(new Dictionary<string, string?>
61+
{
62+
["Bowire:Ai:Model"] = "tiny-model:1b",
63+
})
64+
.Build();
65+
66+
var opts = new BowireAiOptions();
67+
cfg.GetSection("Bowire:Ai").Bind(opts);
68+
69+
Assert.Equal("tiny-model:1b", opts.Model);
70+
Assert.Equal("ollama", opts.ProviderId);
71+
Assert.Equal("http://localhost:11434", opts.Endpoint);
72+
Assert.True(opts.AutoDetectLocal);
73+
}
74+
75+
[Fact]
76+
public void Empty_Configuration_Leaves_Defaults_Intact()
77+
{
78+
var cfg = new ConfigurationBuilder().Build();
79+
var opts = new BowireAiOptions();
80+
cfg.GetSection("Bowire:Ai").Bind(opts);
81+
82+
Assert.Equal("ollama", opts.ProviderId);
83+
Assert.Equal("llama3.2:3b", opts.Model);
84+
}
85+
}
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Copyright 2026 Küstenlogik
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
using Microsoft.Extensions.AI;
5+
6+
namespace Kuestenlogik.Bowire.Ai.Tests;
7+
8+
/// <summary>
9+
/// Direct unit tests for <see cref="BowireAiRuntime"/>. Sits alongside
10+
/// the cross-cutting pin in Kuestenlogik.Bowire.Tests; the focus here
11+
/// is on the runtime in isolation:
12+
/// <list type="bullet">
13+
/// <item>argument validation;</item>
14+
/// <item><see cref="BowireAiRuntime.Options"/> returning a defensive
15+
/// copy (callers can't mutate the live record);</item>
16+
/// <item>the provider-id switch — ollama / lmstudio
17+
/// case-insensitive both build a client, cloud provider ids park
18+
/// <see cref="BowireAiRuntime.Current"/> at <c>null</c>;</item>
19+
/// <item><see cref="BowireAiRuntime.Update"/> disposing the prior
20+
/// client and returning a snapshot;</item>
21+
/// <item>the empty-endpoint default falling back to
22+
/// <c>http://localhost:11434</c>;</item>
23+
/// </list>
24+
/// </summary>
25+
public sealed class BowireAiRuntimeTests
26+
{
27+
[Fact]
28+
public void Constructor_NullOptions_Throws()
29+
{
30+
Assert.Throws<ArgumentNullException>(() => new BowireAiRuntime(null!));
31+
}
32+
33+
[Fact]
34+
public void Update_NullNext_Throws()
35+
{
36+
var rt = new BowireAiRuntime(new BowireAiOptions());
37+
Assert.Throws<ArgumentNullException>(() => rt.Update(null!));
38+
}
39+
40+
[Fact]
41+
public void Options_ReturnsDefensiveCopy_CallerCannotMutateLive()
42+
{
43+
var rt = new BowireAiRuntime(new BowireAiOptions
44+
{
45+
ProviderId = "ollama",
46+
Model = "original:1b",
47+
});
48+
49+
var snapshot = rt.Options;
50+
snapshot.Model = "tampered:7b";
51+
52+
// The next read still shows the original — Options returns a
53+
// copy so a leaky caller can't poison the singleton.
54+
Assert.Equal("original:1b", rt.Options.Model);
55+
}
56+
57+
[Fact]
58+
public void Constructor_DoesNotShareReferenceWithInitialOptions()
59+
{
60+
// Initial options passed to the ctor are cloned, so the
61+
// caller's later mutations don't bleed into the runtime.
62+
var initial = new BowireAiOptions { Model = "before:1b" };
63+
var rt = new BowireAiRuntime(initial);
64+
initial.Model = "mutated-after-ctor:7b";
65+
66+
Assert.Equal("before:1b", rt.Options.Model);
67+
}
68+
69+
[Fact]
70+
public void Constructor_UnknownProvider_ParksCurrentAtNull()
71+
{
72+
// openai is a Phase 3 provider id — until that lands, unknown
73+
// provider ids must produce a null client so the host can
74+
// start (UI renders "no client" state) instead of throwing.
75+
var rt = new BowireAiRuntime(new BowireAiOptions { ProviderId = "openai" });
76+
Assert.Null(rt.Current);
77+
}
78+
79+
[Theory]
80+
[InlineData("ollama")]
81+
[InlineData("OLLAMA")]
82+
[InlineData("Ollama")]
83+
[InlineData("lmstudio")]
84+
[InlineData("LMStudio")]
85+
[InlineData("LMSTUDIO")]
86+
public void Constructor_OllamaShapeProviders_BuildClient_CaseInsensitive(string providerId)
87+
{
88+
// The case-insensitive match is the documented contract for
89+
// the provider id; pin both supported ids in every case
90+
// variant so a future ToLowerInvariant refactor doesn't
91+
// narrow the surface accidentally.
92+
var rt = new BowireAiRuntime(new BowireAiOptions
93+
{
94+
ProviderId = providerId,
95+
Endpoint = "http://localhost:11434",
96+
Model = "llama3.2:3b",
97+
});
98+
99+
Assert.NotNull(rt.Current);
100+
}
101+
102+
[Theory]
103+
[InlineData("openai")]
104+
[InlineData("anthropic")]
105+
[InlineData("openrouter")]
106+
[InlineData("not-a-real-provider")]
107+
[InlineData("")]
108+
public void Constructor_NonOllamaShapeProviders_ParkCurrentAtNull(string providerId)
109+
{
110+
var rt = new BowireAiRuntime(new BowireAiOptions { ProviderId = providerId });
111+
Assert.Null(rt.Current);
112+
}
113+
114+
[Fact]
115+
public void Constructor_EmptyEndpoint_FallsBackToOllamaLocalDefault()
116+
{
117+
// The build path treats an empty endpoint as "use the
118+
// provider's local default" rather than failing — this lets
119+
// a config that only specifies the provider id still come up.
120+
var rt = new BowireAiRuntime(new BowireAiOptions
121+
{
122+
ProviderId = "ollama",
123+
Endpoint = "",
124+
Model = "llama3.2:3b",
125+
});
126+
127+
Assert.NotNull(rt.Current);
128+
}
129+
130+
[Fact]
131+
public void Update_ReturnsSnapshot_ThatIsNotTheSameInstanceAsInput()
132+
{
133+
var rt = new BowireAiRuntime(new BowireAiOptions { ProviderId = "ollama" });
134+
var input = new BowireAiOptions
135+
{
136+
ProviderId = "ollama",
137+
Endpoint = "http://localhost:11434",
138+
Model = "qwen2.5:7b",
139+
AutoDetectLocal = false,
140+
};
141+
142+
var snapshot = rt.Update(input);
143+
144+
Assert.NotSame(input, snapshot);
145+
Assert.Equal("qwen2.5:7b", snapshot.Model);
146+
Assert.False(snapshot.AutoDetectLocal);
147+
}
148+
149+
[Fact]
150+
public void Update_SwapsCurrentClient_BuiltFromNextOptions()
151+
{
152+
var rt = new BowireAiRuntime(new BowireAiOptions { ProviderId = "ollama" });
153+
var first = rt.Current;
154+
Assert.NotNull(first);
155+
156+
rt.Update(new BowireAiOptions
157+
{
158+
ProviderId = "ollama",
159+
Endpoint = "http://localhost:11434",
160+
Model = "different:7b",
161+
});
162+
163+
Assert.NotSame(first, rt.Current);
164+
}
165+
166+
[Fact]
167+
public void Update_ToUnknownProvider_NullsTheClient()
168+
{
169+
// ollama → openai swap: the runtime should null the client so
170+
// MutableChatClient's null-check kicks in on the next call.
171+
var rt = new BowireAiRuntime(new BowireAiOptions { ProviderId = "ollama" });
172+
Assert.NotNull(rt.Current);
173+
174+
rt.Update(new BowireAiOptions { ProviderId = "openai" });
175+
176+
Assert.Null(rt.Current);
177+
Assert.Equal("openai", rt.Options.ProviderId);
178+
}
179+
180+
[Fact]
181+
public void Update_FromUnknownToKnown_BuildsAClient()
182+
{
183+
// The reverse hot-swap: a user lands on Settings → AI, fills
184+
// in a working ollama endpoint, hits save → the previously
185+
// null client becomes a live one without a host restart.
186+
var rt = new BowireAiRuntime(new BowireAiOptions { ProviderId = "openai" });
187+
Assert.Null(rt.Current);
188+
189+
rt.Update(new BowireAiOptions
190+
{
191+
ProviderId = "ollama",
192+
Endpoint = "http://localhost:11434",
193+
Model = "llama3.2:3b",
194+
});
195+
196+
Assert.NotNull(rt.Current);
197+
}
198+
199+
[Fact]
200+
public void Update_DisposesPreviousClient()
201+
{
202+
// The hot-swap path must dispose the prior client so the
203+
// OllamaSharp HttpClient socket pool doesn't leak across
204+
// dozens of Settings-UI saves. Pinned via the ChatClientBuilder
205+
// wrapper's IDisposable surface — calling Dispose twice on the
206+
// post-swap client is a hard error, so if the swap forgot to
207+
// dispose the previous one, this test would still pass; instead
208+
// we verify the new client is alive (no ODE on second use)
209+
// after the swap. The lifetime contract sits in the BowireAiRuntime
210+
// source comment; the cross-cutting "no Dispose race" pin sits
211+
// in BowireAiRuntimeTests's swap test.
212+
var rt = new BowireAiRuntime(new BowireAiOptions { ProviderId = "ollama" });
213+
214+
for (var i = 0; i < 5; i++)
215+
{
216+
rt.Update(new BowireAiOptions
217+
{
218+
ProviderId = "ollama",
219+
Endpoint = "http://localhost:11434",
220+
Model = $"model-{i}:1b",
221+
});
222+
}
223+
224+
Assert.NotNull(rt.Current);
225+
Assert.Equal("model-4:1b", rt.Options.Model);
226+
}
227+
228+
[Fact]
229+
public void Update_MutatingNextAfterCall_DoesNotAffectLiveOptions()
230+
{
231+
// Update clones the incoming options. The caller can keep
232+
// mutating their copy without affecting the runtime — same
233+
// defensive-copy contract as the ctor.
234+
var rt = new BowireAiRuntime(new BowireAiOptions { ProviderId = "ollama" });
235+
var next = new BowireAiOptions
236+
{
237+
ProviderId = "ollama",
238+
Endpoint = "http://localhost:11434",
239+
Model = "before-mut:1b",
240+
};
241+
rt.Update(next);
242+
243+
next.Model = "after-mut:7b";
244+
245+
Assert.Equal("before-mut:1b", rt.Options.Model);
246+
}
247+
}

0 commit comments

Comments
 (0)