Document local cloud startup and harden persistence
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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; } = [];
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user