Document local cloud startup and harden persistence

This commit is contained in:
Jacob Dubin
2026-05-25 00:30:41 -05:00
parent c36a01b142
commit 4e816e175a
17 changed files with 517 additions and 9 deletions

View File

@@ -0,0 +1,90 @@
using System.Net;
using System.Net.Http.Json;
using System.Net.WebSockets;
using Microsoft.AspNetCore.Mvc.Testing;
namespace Jibo.Cloud.Tests.Api;
public sealed class JiboCloudApiIntegrationTests
{
[Fact]
public async Task Health_ReturnsCurrentVersion()
{
await using var factory = CreateFactory();
var client = factory.CreateClient();
var response = await client.GetAsync("/health");
var body = await response.Content.ReadFromJsonAsync<HealthResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.True(body!.Ok);
Assert.Equal("OpenJibo Cloud Api", body.Service);
Assert.Equal("1.0.19", body.Version);
}
[Fact]
public async Task HttpProtocolDispatch_HandlesCreateHubTokenTarget()
{
await using var factory = CreateFactory();
var client = factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/")
{
Content = JsonContent.Create(new { })
};
request.Headers.TryAddWithoutValidation("X-Amz-Target", "Account_20160715.CreateHubToken");
request.Headers.Host = "api.jibo.com";
var response = await client.SendAsync(request);
var payload = await response.Content.ReadFromJsonAsync<CreateHubTokenResponse>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(payload);
Assert.False(string.IsNullOrWhiteSpace(payload!.Token));
}
[Fact]
public async Task WebSocket_MissingTokenOnNeoHubListen_ReturnsUnauthorized()
{
await using var factory = CreateFactory();
var client = factory.Server.CreateWebSocketClient();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
client.ConnectAsync(new Uri("ws://neo-hub.jibo.com/"), CancellationToken.None));
Assert.Contains("401", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task WebSocket_TokenPathOnNeoHubListen_Connects()
{
await using var factory = CreateFactory();
var client = factory.Server.CreateWebSocketClient();
using var socket = await client.ConnectAsync(new Uri("ws://neo-hub.jibo.com/test-token"), CancellationToken.None);
Assert.Equal(WebSocketState.Open, socket.State);
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "test-complete", CancellationToken.None);
}
private static WebApplicationFactory<Program> CreateFactory()
{
var root = Path.Combine(Path.GetTempPath(), $"openjibo-api-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
return new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseSetting("OpenJibo:Telemetry:DirectoryPath", Path.Combine(root, "websocket"));
builder.UseSetting("OpenJibo:ProtocolTelemetry:DirectoryPath", Path.Combine(root, "http"));
builder.UseSetting("OpenJibo:TurnTelemetry:DirectoryPath", Path.Combine(root, "turn"));
builder.UseSetting("OpenJibo:State:PersistencePath", Path.Combine(root, "cloud-state.json"));
builder.UseSetting("OpenJibo:PersonalMemory:PersistencePath", Path.Combine(root, "personal-memory.json"));
builder.UseSetting("OpenJibo:Media:DirectoryPath", Path.Combine(root, "media"));
});
}
private sealed record HealthResponse(bool Ok, string Service, string Version);
private sealed record CreateHubTokenResponse(string Token);
}

View File

@@ -190,6 +190,34 @@ public sealed class PersistenceStoreTests
}
}
[Fact]
public void PersonalMemoryStore_IgnoresCorruptSnapshotAndOverwritesWithValidJson()
{
var persistenceDirectory = Path.Combine(Path.GetTempPath(), $"openjibo-corrupt-memory-{Guid.NewGuid():N}");
var persistencePath = Path.Combine(persistenceDirectory, "memory.json");
try
{
Directory.CreateDirectory(persistenceDirectory);
File.WriteAllText(persistencePath, "{ not valid json");
var scope = new PersonalMemoryTenantScope("acct-corrupt", "loop-corrupt", "device-corrupt");
var store = new InMemoryPersonalMemoryStore(persistencePath);
Assert.Null(store.GetName(scope));
store.SetName(scope, "Recovered");
var reloaded = new InMemoryPersonalMemoryStore(persistencePath);
Assert.Equal("Recovered", reloaded.GetName(scope));
Assert.DoesNotContain(Directory.GetFiles(persistenceDirectory),
path => Path.GetFileName(path).Contains(".tmp", StringComparison.OrdinalIgnoreCase));
}
finally
{
if (Directory.Exists(persistenceDirectory)) Directory.Delete(persistenceDirectory, recursive: true);
}
}
private sealed class RecordingSnapshotStore : ISnapshotStore
{
public List<object> Saves { get; } = [];

View File

@@ -9,12 +9,14 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Jibo.Cloud\dotnet\src\Jibo.Cloud.Api\Jibo.Cloud.Api.csproj" />
<ProjectReference Include="..\..\src\Jibo.Cloud\dotnet\src\Jibo.Cloud.Application\Jibo.Cloud.Application.csproj" />
<ProjectReference Include="..\..\src\Jibo.Cloud\dotnet\src\Jibo.Cloud.Infrastructure\Jibo.Cloud.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\Jibo.Runtime.Abstractions\Jibo.Runtime.Abstractions.csproj" />

View File

@@ -333,7 +333,7 @@ public sealed class JiboInteractionServiceTests
["messageType"] = "TRIGGER",
["triggerSource"] = "PRESENCE",
["context"] =
"""{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
"""{"runtime":{"location":{"iso":"2026-05-21T15:00:00-05:00"},"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
}
});
@@ -364,7 +364,7 @@ public sealed class JiboInteractionServiceTests
["messageType"] = "TRIGGER",
["triggerSource"] = "PRESENCE",
["context"] =
"""{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"},{"id":"person-2"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"},{"id":"person-2","firstName":"sam"}]}}}"""
"""{"runtime":{"location":{"iso":"2026-05-21T15:00:00-05:00"},"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"},{"id":"person-2"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"},{"id":"person-2","firstName":"sam"}]}}}"""
}
});
@@ -513,6 +513,58 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("ProactiveHolidayGreeting", decision.ContextUpdates![GreetingRouteKey]);
}
[Fact]
public async Task BuildDecisionAsync_TriggerUsesHolidayGreetingOnlyOnMatchingFixedDate()
{
var cloudStateStore = new InMemoryCloudStateStore();
cloudStateStore.UpsertHoliday(new HolidayRecord
{
LoopId = "loop-fixed-holiday",
Name = "Test Holiday",
Category = "holiday",
Date = new DateOnly(2026, 8, 13),
IsEnabled = true,
Source = "manual",
CountryCode = "US"
});
var service = CreateService(cloudStateStore: cloudStateStore);
var ordinaryDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = string.Empty,
NormalizedTranscript = string.Empty,
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-fixed-holiday",
["loopId"] = "loop-fixed-holiday",
["messageType"] = "TRIGGER",
["triggerSource"] = "PRESENCE",
["context"] =
"""{"runtime":{"location":{"iso":"2026-08-12T09:00:00-05:00"},"perception":{"speaker":"person-8","peoplePresent":[{"id":"person-8"}]},"loop":{"users":[{"id":"person-8","firstName":"jake"}]}}}"""
}
});
var holidayDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = string.Empty,
NormalizedTranscript = string.Empty,
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-fixed-holiday",
["loopId"] = "loop-fixed-holiday",
["messageType"] = "TRIGGER",
["triggerSource"] = "PRESENCE",
["context"] =
"""{"runtime":{"location":{"iso":"2026-08-13T09:00:00-05:00"},"perception":{"speaker":"person-9","peoplePresent":[{"id":"person-9"}]},"loop":{"users":[{"id":"person-9","firstName":"sam"}]}}}"""
}
});
Assert.Equal("proactive_greeting", ordinaryDecision.IntentName);
Assert.Equal("ProactiveGreeting", ordinaryDecision.ContextUpdates![GreetingRouteKey]);
Assert.Equal("proactive_holiday_greeting", holidayDecision.IntentName);
Assert.Equal("ProactiveHolidayGreeting", holidayDecision.ContextUpdates![GreetingRouteKey]);
}
[Fact]
public async Task BuildDecisionAsync_TriggerWithKnownIdentity_SuppressesRepeatGreetingFromCloudHistory()
{
@@ -2255,7 +2307,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("personal_report_opt_in", decision.IntentName);
Assert.Equal("Would you like your personal report now?", decision.ReplyText);
Assert.Equal("shared/yes_no", ((IReadOnlyList<string>)decision.SkillPayload!["listen_contexts"])[0]);
Assert.NotNull(decision.SkillPayload);
var listenContexts = Assert.IsAssignableFrom<IReadOnlyList<string>>(decision.SkillPayload["listen_contexts"]);
Assert.Equal("shared/yes_no", listenContexts[0]);
Assert.NotNull(decision.ContextUpdates);
Assert.Equal("awaiting_opt_in", decision.ContextUpdates![PersonalReportStateKey]);
Assert.Equal(true, decision.ContextUpdates[PersonalReportWeatherEnabledKey]);
@@ -2286,7 +2340,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("personal_report_verify_user", decision.IntentName);
Assert.Equal("I think this is alex. Is that right?", decision.ReplyText);
Assert.Equal("shared/yes_no", ((IReadOnlyList<string>)decision.SkillPayload!["listen_contexts"])[0]);
Assert.NotNull(decision.SkillPayload);
var listenContexts = Assert.IsAssignableFrom<IReadOnlyList<string>>(decision.SkillPayload["listen_contexts"]);
Assert.Equal("shared/yes_no", listenContexts[0]);
Assert.NotNull(decision.ContextUpdates);
Assert.Equal("awaiting_identity_confirmation", decision.ContextUpdates![PersonalReportStateKey]);
Assert.Equal("alex", decision.ContextUpdates[PersonalReportUserNameKey]);