From d7ea8eebab353d3ce37cc5982167bf29255c721d Mon Sep 17 00:00:00 2001 From: Jacob Dubin Date: Sat, 11 Apr 2026 21:19:35 -0500 Subject: [PATCH] first pass and feature add ons --- .../Abstractions/ICloudStateStore.cs | 16 + .../Services/JiboCloudProtocolService.cs | 523 ++++++++++++++++-- .../Jibo.Cloud.Domain/Models/BackupRecord.cs | 8 + .../Models/KeyRequestRecord.cs | 10 + .../Jibo.Cloud.Domain/Models/LoopRecord.cs | 13 + .../Jibo.Cloud.Domain/Models/MediaRecord.cs | 15 + .../Models/ProtocolFixture.cs | 8 + .../Jibo.Cloud.Domain/Models/RobotProfile.cs | 10 + .../Persistence/InMemoryCloudStateStore.cs | 205 +++++++ .../Fixtures/ProtocolFixtureLoader.cs | 48 ++ .../Jibo.Cloud.Tests/Jibo.Cloud.Tests.csproj | 7 + .../Protocol/JiboCloudProtocolServiceTests.cs | 78 +++ .../Protocol/ProtocolFixtureReplayTests.cs | 22 + 13 files changed, 918 insertions(+), 45 deletions(-) create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/BackupRecord.cs create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/KeyRequestRecord.cs create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/LoopRecord.cs create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/MediaRecord.cs create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolFixture.cs create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/RobotProfile.cs create mode 100644 OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/ProtocolFixtureLoader.cs create mode 100644 OpenJibo/tests/Jibo.Cloud.Tests/Protocol/ProtocolFixtureReplayTests.cs diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs index fd3b40a..8fa9fe5 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs @@ -6,12 +6,28 @@ public interface ICloudStateStore { AccountProfile GetAccount(); DeviceRegistration GetRobot(); + RobotProfile GetRobotProfile(); DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion); string IssueHubToken(); string IssueRobotToken(string deviceId); CloudSession OpenSession(string kind, string? deviceId, string? token, string? hostName, string? path); CloudSession? FindSessionByToken(string token); + IReadOnlyList GetLoops(); IReadOnlyList ListUpdates(string? subsystem = null, string? filter = null); UpdateManifest GetUpdateFrom(string? subsystem, string? fromVersion, string? filter); + UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length, string? subsystem, string? filter, IDictionary? dependencies); + UpdateManifest RemoveUpdate(string? updateId); + IReadOnlyList ListMedia(IReadOnlyList? loopIds = null, long? after = null, long? before = null); + IReadOnlyList GetMedia(IReadOnlyList paths); + IReadOnlyList RemoveMedia(IReadOnlyList paths); + MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted, IDictionary? meta); + IReadOnlyList GetBackups(); + bool ShouldCreateSymmetricKey(string loopId); + string GetOrCreateSymmetricKey(string loopId); + KeyRequestRecord CreateKeyRequest(string loopId, string publicKey); + KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey); + IReadOnlyList GetIncomingKeyRequests(); + IReadOnlyList GetBinaryRequests(); + IReadOnlyList GetHolidays(); void UpdateRobot(DeviceRegistration registration); } diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs index 4c04013..12ebd6f 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs @@ -50,9 +50,19 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) var servicePrefix = envelope.ServicePrefix ?? string.Empty; var operation = envelope.Operation ?? string.Empty; + if (servicePrefix.StartsWith("Log_", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(HandleLog(operation, envelope)); + } + + if (servicePrefix.StartsWith("Backup_", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(HandleBackup(operation)); + } + if (servicePrefix.StartsWith("Account_", StringComparison.OrdinalIgnoreCase)) { - return Task.FromResult(HandleAccount(operation)); + return Task.FromResult(HandleAccount(operation, envelope)); } if (servicePrefix.StartsWith("Notification_", StringComparison.OrdinalIgnoreCase)) @@ -65,6 +75,21 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) return Task.FromResult(HandleLoop(operation)); } + if (servicePrefix.Equals("Media_20160725", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(HandleMedia(operation, envelope)); + } + + if (servicePrefix.StartsWith("Key_", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(HandleKey(operation, envelope)); + } + + if (servicePrefix.StartsWith("Person_", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(HandlePerson(operation)); + } + if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase)) { return Task.FromResult(HandleRobot(operation, envelope)); @@ -78,28 +103,78 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) return Task.FromResult(ProtocolDispatchResult.Ok(new { ok = true, - service = servicePrefix, - operation + host = envelope.HostName, + target = $"{servicePrefix}.{operation}".Trim('.'), + operation, + note = "unknown target default response" })); } - private ProtocolDispatchResult HandleAccount(string operation) + private ProtocolDispatchResult HandleAccount(string operation, ProtocolEnvelope envelope) { var account = stateStore.GetAccount(); + var body = envelope.TryParseBody(); - return operation switch + if (operation.Equals("CreateHubToken", StringComparison.OrdinalIgnoreCase)) { - "CreateHubToken" => ProtocolDispatchResult.Ok(new + return ProtocolDispatchResult.Ok(new { token = stateStore.IssueHubToken(), expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds() - }), - "CreateAccessToken" => ProtocolDispatchResult.Ok(new + }); + } + + if (operation.Equals("CreateAccessToken", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(new { token = $"access-{account.AccountId}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}", expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds() - }), - "Get" => ProtocolDispatchResult.Ok(new[] + }); + } + + if (operation.Equals("CheckEmail", StringComparison.OrdinalIgnoreCase)) + { + var email = ReadString(body, "email") ?? string.Empty; + return ProtocolDispatchResult.Ok(new + { + exists = email.Equals(account.Email, StringComparison.OrdinalIgnoreCase) + }); + } + + if (operation is "Create" or "Login") + { + return ProtocolDispatchResult.Ok(new + { + id = account.AccountId, + email = ReadString(body, "email") ?? account.Email, + firstName = ReadString(body, "firstName") ?? account.FirstName, + lastName = ReadString(body, "lastName") ?? account.LastName, + gender = "unknown", + birthday = 631152000000L, + phoneNumber = "+10000000000", + photoUrl = string.Empty, + isActive = true, + messagingAllowed = true, + accessKeyId = account.AccessKeyId, + secretAccessKey = account.SecretAccessKey, + roles = Array.Empty(), + facebookConnected = false, + termsAccepted = true + }); + } + + if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase)) + { + var ids = ReadStringArray(body, "ids"); + var matches = ids.Count == 0 || ids.Contains(account.AccountId, StringComparer.OrdinalIgnoreCase); + + if (!matches) + { + return ProtocolDispatchResult.Ok(Array.Empty()); + } + + return ProtocolDispatchResult.Ok(new[] { new { @@ -110,15 +185,89 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) accessKeyId = account.AccessKeyId, secretAccessKey = account.SecretAccessKey } - }), - _ => ProtocolDispatchResult.Ok(new + }); + } + + if (operation is "Update" or "ResetKeys" or "Remove" or "ActivateByCode" or "ResendActivationCode" or + "ChangePassword" or "SendPasswordReset" or "PasswordResetByCode" or "UpdatePhoto" or "RemovePhoto" or + "VerifyPhoneByCode" or "AcceptTerms" or "FacebookConnect" or "FacebookMobileConnect") + { + return ProtocolDispatchResult.Ok(new { id = account.AccountId, email = account.Email, firstName = account.FirstName, - lastName = account.LastName - }) - }; + lastName = account.LastName, + accessKeyId = account.AccessKeyId, + secretAccessKey = account.SecretAccessKey + }); + } + + if (operation is "ChangeEmail" or "SendPhoneVerificationCode") + { + return ProtocolDispatchResult.Ok(new + { + id = account.AccountId + }); + } + + if (operation.Equals("GetAccountByAccessToken", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(new + { + id = account.AccountId, + accessKeyId = account.AccessKeyId, + secretAccessKey = account.SecretAccessKey, + email = account.Email, + friendlyId = stateStore.GetRobot().RobotId, + payload = ReadObject(body, "payload") + }); + } + + if (operation.Equals("Search", StringComparison.OrdinalIgnoreCase)) + { + var query = (ReadString(body, "query") ?? string.Empty).ToLowerInvariant(); + var haystack = $"{account.Email} {account.FirstName} {account.LastName} {account.AccountId}".ToLowerInvariant(); + + return ProtocolDispatchResult.Ok(query.Length > 0 && haystack.Contains(query) + ? new[] + { + new + { + id = account.AccountId, + email = account.Email, + firstName = account.FirstName, + lastName = account.LastName + } + } + : Array.Empty()); + } + + if (operation.Equals("FacebookPrepareLogin", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(new + { + url = "https://example.com/facebook-login", + client_id = "fake-client-id", + scope = "email", + response_type = "token", + state = $"fb-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}", + redirect_uri = "https://api.jibo.com/facebook/callback" + }); + } + + if (operation.Equals("ConfirmEmailReset", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(new { }); + } + + return ProtocolDispatchResult.Ok(new + { + id = account.AccountId, + email = account.Email, + firstName = account.FirstName, + lastName = account.LastName + }); } private ProtocolDispatchResult HandleNotification(string operation, ProtocolEnvelope envelope) @@ -131,7 +280,11 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) var body = envelope.TryParseBody(); var deviceId = envelope.DeviceId ?? ReadString(body, "deviceId") + ?? ReadString(body, "serial_number") ?? ReadString(body, "serialNumber") + ?? ReadString(body, "cpuid") + ?? ReadString(body, "cpuId") + ?? ReadString(body, "robotId") ?? "unknown-device"; stateStore.GetOrCreateDevice(deviceId, envelope.FirmwareVersion, envelope.ApplicationVersion); @@ -149,24 +302,181 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) return ProtocolDispatchResult.Ok(Array.Empty()); } - var robot = stateStore.GetRobot(); - var account = stateStore.GetAccount(); - - return ProtocolDispatchResult.Ok(new[] + return ProtocolDispatchResult.Ok(stateStore.GetLoops().Select(loop => new { - new + id = loop.LoopId, + name = loop.Name, + owner = loop.OwnerAccountId, + robot = loop.RobotId, + robotFriendlyId = loop.RobotFriendlyId, + members = Array.Empty(), + isSuspended = loop.IsSuspended, + created = loop.CreatedUtc.ToUnixTimeMilliseconds(), + updated = loop.UpdatedUtc.ToUnixTimeMilliseconds() + }).ToArray()); + } + + private ProtocolDispatchResult HandleLog(string operation, ProtocolEnvelope envelope) + { + return operation switch + { + "PutEventsAsync" => ProtocolDispatchResult.Ok(new { - id = "openjibo-default-loop", - name = "OpenJibo Default Loop", - owner = account.AccountId, - robot = robot.RobotId, - robotFriendlyId = robot.DeviceId, - members = Array.Empty(), - isSuspended = false, - created = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - updated = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - } - }); + contentEncoding = "gzip", + uploadUrl = "https://api.jibo.com/upload/log-events" + }), + "PutEvents" => ProtocolDispatchResult.Ok(new { }), + "PutBinaryAsync" => ProtocolDispatchResult.Ok(new + { + url = "https://api.jibo.com/log/binary/fake-id", + uploadUrl = "https://api.jibo.com/upload/log-binary" + }), + "PutAsrBinary" => ProtocolDispatchResult.Ok(new + { + bucketName = "openjibo-test", + key = "asr/fake-key", + uploadUrl = "https://api.jibo.com/upload/asr-binary" + }), + "NewKinesisCredentials" => ProtocolDispatchResult.Ok(new + { + credentials = new + { + AccessKeyId = "fake-access-key", + Expiration = DateTimeOffset.UtcNow.AddHours(1).ToString("O"), + SecretAccessKey = "fake-secret", + SessionToken = "fake-session" + }, + region = "us-east-1", + streamName = "openjibo-log-stream" + }), + _ => ProtocolDispatchResult.Ok(new { }) + }; + } + + private ProtocolDispatchResult HandleMedia(string operation, ProtocolEnvelope envelope) + { + var body = envelope.TryParseBody(); + + if (operation.Equals("List", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(stateStore.ListMedia( + ReadStringArray(body, "loopIds"), + ReadLong(body, "after"), + ReadLong(body, "before")).Select(MapMedia).ToArray()); + } + + if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray()); + } + + if (operation.Equals("Remove", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray()); + } + + if (operation.Equals("Create", StringComparison.OrdinalIgnoreCase)) + { + var loopId = ReadHeader(envelope, "x-loop-id") ?? ReadString(body, "loopId") ?? stateStore.GetLoops()[0].LoopId; + var path = ReadHeader(envelope, "x-path") ?? ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + var type = ReadHeader(envelope, "x-type") ?? ReadString(body, "type") ?? "unknown"; + var reference = ReadHeader(envelope, "x-reference") ?? ReadString(body, "reference") ?? string.Empty; + var isEncrypted = ReadBooleanHeader(envelope, "x-encrypted") || ReadBool(body, "isEncrypted"); + var meta = ReadObject(body, "meta"); + + return ProtocolDispatchResult.Ok(MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta))); + } + + return ProtocolDispatchResult.Ok(Array.Empty()); + } + + private ProtocolDispatchResult HandlePerson(string operation) + { + if (operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(stateStore.GetHolidays()); + } + + return ProtocolDispatchResult.Ok(Array.Empty()); + } + + private ProtocolDispatchResult HandleBackup(string operation) + { + if (operation.Equals("List", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(stateStore.GetBackups()); + } + + return ProtocolDispatchResult.Ok(Array.Empty()); + } + + private ProtocolDispatchResult HandleKey(string operation, ProtocolEnvelope envelope) + { + var body = envelope.TryParseBody(); + var loopId = ReadString(body, "loopId") ?? ReadString(body, "id") ?? stateStore.GetLoops()[0].LoopId; + + if (operation.Equals("ShouldCreate", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(new + { + shouldCreate = stateStore.ShouldCreateSymmetricKey(loopId) + }); + } + + if (operation.Equals("CreateSymmetricKey", StringComparison.OrdinalIgnoreCase)) + { + var symmetricKey = stateStore.GetOrCreateSymmetricKey(loopId); + return ProtocolDispatchResult.Ok(new + { + loopId, + key = symmetricKey, + symmetricKey, + created = true + }); + } + + if (operation is "CreateRequest" or "RequestSymmetricKey") + { + var record = stateStore.CreateKeyRequest(loopId, ReadString(body, "publicKey") ?? string.Empty); + return ProtocolDispatchResult.Ok(new + { + id = record.RequestId, + loopId = record.LoopId + }); + } + + if (operation.Equals("GetRequest", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"), ReadString(body, "publicKey"))); + } + + if (operation.Equals("ListIncomingRequests", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(stateStore.GetIncomingKeyRequests()); + } + + if (operation.Equals("ListBinaryRequests", StringComparison.OrdinalIgnoreCase)) + { + return ProtocolDispatchResult.Ok(stateStore.GetBinaryRequests()); + } + + if (operation is "Share" or "ShareSymmetricKey" or "ShareBinary") + { + return ProtocolDispatchResult.Ok(new { ok = true }); + } + + if (operation.Equals("LoadSymmetricKey", StringComparison.OrdinalIgnoreCase)) + { + var symmetricKey = stateStore.GetOrCreateSymmetricKey(loopId); + return ProtocolDispatchResult.Ok(new + { + loopId, + key = symmetricKey, + symmetricKey + }); + } + + return ProtocolDispatchResult.Ok(new { ok = true, operation }); } private ProtocolDispatchResult HandleRobot(string operation, ProtocolEnvelope envelope) @@ -186,16 +496,28 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) }; stateStore.UpdateRobot(updated); - robot = updated; + return ProtocolDispatchResult.Ok(new + { + result = "ok" + }); + } + + if (operation.Equals("GetRobot", StringComparison.OrdinalIgnoreCase)) + { + var profile = stateStore.GetRobotProfile(); + return ProtocolDispatchResult.Ok(new + { + id = ReadString(envelope.TryParseBody(), "id") ?? profile.RobotId, + payload = profile.Payload, + calibrationPayload = profile.CalibrationPayload, + updated = profile.UpdatedUtc.ToUnixTimeMilliseconds(), + created = profile.CreatedUtc.ToUnixTimeMilliseconds() + }); } return ProtocolDispatchResult.Ok(new { - id = robot.RobotId, - friendlyId = robot.DeviceId, - name = robot.FriendlyName, - firmwareVersion = robot.FirmwareVersion, - applicationVersion = robot.ApplicationVersion + result = "ok" }); } @@ -209,8 +531,21 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) return operation switch { "ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate).ToArray()), - "ListUpdatesFrom" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate).ToArray()), + "ListUpdatesFrom" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter) + .Where(update => fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase)) + .Select(MapUpdate) + .ToArray()), "GetUpdateFrom" => ProtocolDispatchResult.Ok(MapUpdate(stateStore.GetUpdateFrom(subsystem, fromVersion, filter))), + "CreateUpdate" => ProtocolDispatchResult.Ok(MapUpdate(stateStore.CreateUpdate( + fromVersion, + ReadString(body, "toVersion"), + ReadString(body, "changes"), + ReadString(body, "shaHash"), + ReadLong(body, "length"), + subsystem, + filter, + ReadObject(body, "dependencies")))), + "RemoveUpdate" => ProtocolDispatchResult.Ok(MapUpdate(stateStore.RemoveUpdate(ReadString(body, "id")))), _ => ProtocolDispatchResult.Ok(Array.Empty()) }; } @@ -221,6 +556,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) { _id = update.UpdateId, created = update.CreatedUtc.ToUnixTimeMilliseconds(), + accountId = "usr_openjibo_owner", fromVersion = update.FromVersion, toVersion = update.ToVersion, changes = update.Changes, @@ -228,18 +564,31 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) shaHash = update.ShaHash, length = update.Length, subsystem = update.Subsystem, - filter = update.Filter + filter = update.Filter, + dependencies = new Dictionary() + }; + } + + private static object MapMedia(MediaRecord item) + { + return new + { + path = item.Path, + created = item.CreatedUtc.ToUnixTimeMilliseconds(), + type = item.MediaType, + reference = item.Reference, + accountId = item.AccountId, + loopId = item.LoopId, + url = item.Url, + isEncrypted = item.IsEncrypted, + isDeleted = item.IsDeleted, + meta = item.Meta }; } private static string? ReadString(JsonElement? element, string propertyName) { - if (element is null) - { - return null; - } - - if (!element.Value.TryGetProperty(propertyName, out var property)) + if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) { return null; } @@ -248,4 +597,88 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) ? property.GetString() : property.ToString(); } + + private static long? ReadLong(JsonElement? element, string propertyName) + { + if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) + { + return null; + } + + if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var number)) + { + return number; + } + + return long.TryParse(property.ToString(), out var parsed) ? parsed : null; + } + + private static bool ReadBool(JsonElement? element, string propertyName) + { + if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) + { + return false; + } + + return property.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => bool.TryParse(property.ToString(), out var parsed) && parsed + }; + } + + private static IReadOnlyList ReadStringArray(JsonElement? element, string propertyName) + { + if (element is null || !element.Value.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array) + { + return []; + } + + return property.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .ToArray(); + } + + private static IDictionary? ReadObject(JsonElement? element, string propertyName) + { + if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) + { + return null; + } + + if (property.ValueKind != JsonValueKind.Object) + { + return null; + } + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var child in property.EnumerateObject()) + { + result[child.Name] = child.Value.ValueKind switch + { + JsonValueKind.String => child.Value.GetString(), + JsonValueKind.Number when child.Value.TryGetInt64(out var longValue) => longValue, + JsonValueKind.Number when child.Value.TryGetDouble(out var doubleValue) => doubleValue, + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => child.Value.ToString() + }; + } + + return result; + } + + private static string? ReadHeader(ProtocolEnvelope envelope, string headerName) + { + return envelope.Headers.TryGetValue(headerName, out var value) ? value : null; + } + + private static bool ReadBooleanHeader(ProtocolEnvelope envelope, string headerName) + { + return envelope.Headers.TryGetValue(headerName, out var value) && + bool.TryParse(value, out var parsed) && + parsed; + } } diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/BackupRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/BackupRecord.cs new file mode 100644 index 0000000..dc663be --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/BackupRecord.cs @@ -0,0 +1,8 @@ +namespace Jibo.Cloud.Domain.Models; + +public sealed class BackupRecord +{ + public string BackupId { get; init; } = Guid.NewGuid().ToString("N"); + public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; + public string Name { get; init; } = "backup"; +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/KeyRequestRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/KeyRequestRecord.cs new file mode 100644 index 0000000..f8ce3d8 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/KeyRequestRecord.cs @@ -0,0 +1,10 @@ +namespace Jibo.Cloud.Domain.Models; + +public sealed class KeyRequestRecord +{ + public string RequestId { get; init; } = Guid.NewGuid().ToString("N"); + public string LoopId { get; init; } = "openjibo-default-loop"; + public string PublicKey { get; init; } = string.Empty; + public string EncryptedKey { get; init; } = string.Empty; + public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/LoopRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/LoopRecord.cs new file mode 100644 index 0000000..51fbc84 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/LoopRecord.cs @@ -0,0 +1,13 @@ +namespace Jibo.Cloud.Domain.Models; + +public sealed class LoopRecord +{ + public string LoopId { get; init; } = "openjibo-default-loop"; + public string Name { get; init; } = "OpenJibo Default Loop"; + public string OwnerAccountId { get; init; } = "usr_openjibo_owner"; + public string RobotId { get; init; } = "my-robot-name"; + public string RobotFriendlyId { get; init; } = "my-robot-serial-number"; + public bool IsSuspended { get; init; } + public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/MediaRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/MediaRecord.cs new file mode 100644 index 0000000..8514be7 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/MediaRecord.cs @@ -0,0 +1,15 @@ +namespace Jibo.Cloud.Domain.Models; + +public sealed class MediaRecord +{ + public string Path { get; init; } = string.Empty; + public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; + public string MediaType { get; init; } = "unknown"; + public string Reference { get; init; } = string.Empty; + public string AccountId { get; init; } = "usr_openjibo_owner"; + public string LoopId { get; init; } = "openjibo-default-loop"; + public string Url { get; init; } = string.Empty; + public bool IsEncrypted { get; init; } + public bool IsDeleted { get; init; } + public IDictionary Meta { get; init; } = new Dictionary(); +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolFixture.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolFixture.cs new file mode 100644 index 0000000..e514a89 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/ProtocolFixture.cs @@ -0,0 +1,8 @@ +namespace Jibo.Cloud.Domain.Models; + +public sealed class ProtocolFixture +{ + public string Name { get; init; } = string.Empty; + public ProtocolEnvelope Request { get; init; } = new(); + public int ExpectedStatusCode { get; init; } = 200; +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/RobotProfile.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/RobotProfile.cs new file mode 100644 index 0000000..1c9aec8 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/RobotProfile.cs @@ -0,0 +1,10 @@ +namespace Jibo.Cloud.Domain.Models; + +public sealed class RobotProfile +{ + public string RobotId { get; init; } = "my-robot-name"; + public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; + public IDictionary Payload { get; init; } = new Dictionary(); + public IDictionary CalibrationPayload { get; init; } = new Dictionary(); + public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs index cedb25b..785fe5e 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs @@ -9,8 +9,14 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore private readonly AccountProfile _account = new(); private readonly ConcurrentDictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _sessionsByToken = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _symmetricKeys = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _keyRequests = new(StringComparer.OrdinalIgnoreCase); private readonly List _updates; + private readonly List _media = []; + private readonly List _backups = []; + private readonly List _loops; private DeviceRegistration _robot; + private RobotProfile _robotProfile; public InMemoryCloudStateStore() { @@ -25,6 +31,26 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore }; _devices[_robot.DeviceId] = _robot; + _robotProfile = new RobotProfile + { + RobotId = _robot.RobotId, + Payload = new Dictionary + { + ["SSID"] = "my-ssid", + ["connectedAt"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + ["platform"] = "12.10.0", + ["serialNumber"] = _robot.DeviceId + } + }; + _loops = + [ + new LoopRecord + { + OwnerAccountId = _account.AccountId, + RobotId = _robot.RobotId, + RobotFriendlyId = _robot.DeviceId + } + ]; _updates = [ @@ -45,6 +71,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore public DeviceRegistration GetRobot() => _robot; + public RobotProfile GetRobotProfile() => _robotProfile; + public DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion) { return _devices.AddOrUpdate( @@ -121,6 +149,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore return _sessionsByToken.GetValueOrDefault(token); } + public IReadOnlyList GetLoops() => _loops.ToArray(); + public IReadOnlyList ListUpdates(string? subsystem = null, string? filter = null) { return _updates @@ -141,9 +171,184 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore }; } + public UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length, string? subsystem, string? filter, IDictionary? dependencies) + { + var update = new UpdateManifest + { + UpdateId = $"upd-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}", + FromVersion = fromVersion ?? "unknown", + ToVersion = toVersion ?? fromVersion ?? "unknown", + Changes = changes ?? string.Empty, + Url = $"https://api.jibo.com/update/upd-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}", + ShaHash = shaHash ?? "fake-sha-hash", + Length = length ?? 0, + Subsystem = subsystem ?? "unknown", + Filter = filter + }; + + _updates.Add(update); + return update; + } + + public UpdateManifest RemoveUpdate(string? updateId) + { + var existing = _updates.FirstOrDefault(update => update.UpdateId == updateId); + if (existing is not null) + { + _updates.Remove(existing); + return existing; + } + + return new UpdateManifest + { + UpdateId = updateId ?? "unknown-update", + Changes = "Update not found", + Url = "https://api.jibo.com/update/missing", + ShaHash = "missing", + Subsystem = "unknown" + }; + } + + public IReadOnlyList ListMedia(IReadOnlyList? loopIds = null, long? after = null, long? before = null) + { + return _media + .Where(item => loopIds is null || loopIds.Count == 0 || loopIds.Contains(item.LoopId)) + .Where(item => after is null || item.CreatedUtc.ToUnixTimeMilliseconds() > after) + .Where(item => before is null || item.CreatedUtc.ToUnixTimeMilliseconds() < before) + .ToArray(); + } + + public IReadOnlyList GetMedia(IReadOnlyList paths) + { + return _media.Where(item => paths.Contains(item.Path)).ToArray(); + } + + public IReadOnlyList RemoveMedia(IReadOnlyList paths) + { + var replacements = new List(); + for (var i = 0; i < _media.Count; i++) + { + if (!paths.Contains(_media[i].Path)) + { + continue; + } + + var updated = new MediaRecord + { + Path = _media[i].Path, + CreatedUtc = _media[i].CreatedUtc, + MediaType = _media[i].MediaType, + Reference = _media[i].Reference, + AccountId = _media[i].AccountId, + LoopId = _media[i].LoopId, + Url = _media[i].Url, + IsEncrypted = _media[i].IsEncrypted, + IsDeleted = true, + Meta = _media[i].Meta + }; + + _media[i] = updated; + replacements.Add(updated); + } + + return replacements; + } + + public MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted, IDictionary? meta) + { + var item = new MediaRecord + { + Path = path, + MediaType = type, + Reference = reference, + AccountId = _account.AccountId, + LoopId = loopId, + Url = $"https://api.jibo.com/media/{Uri.EscapeDataString(path)}", + IsEncrypted = isEncrypted, + Meta = meta ?? new Dictionary() + }; + + _media.Add(item); + return item; + } + + public IReadOnlyList GetBackups() => _backups.ToArray(); + + public bool ShouldCreateSymmetricKey(string loopId) => true; + + public string GetOrCreateSymmetricKey(string loopId) + { + return _symmetricKeys.GetOrAdd(loopId, key => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"open-jibo-symmetric-key:{key}"))); + } + + public KeyRequestRecord CreateKeyRequest(string loopId, string publicKey) + { + var record = new KeyRequestRecord + { + RequestId = $"req-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}", + LoopId = loopId, + PublicKey = publicKey + }; + + _keyRequests[record.RequestId] = record; + return record; + } + + public KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey) + { + if (!string.IsNullOrWhiteSpace(requestId) && _keyRequests.TryGetValue(requestId, out var record)) + { + return record; + } + + return new KeyRequestRecord + { + RequestId = requestId ?? "unknown-request", + LoopId = loopId, + PublicKey = publicKey ?? string.Empty + }; + } + + public IReadOnlyList GetIncomingKeyRequests() => []; + + public IReadOnlyList GetBinaryRequests() => []; + + public IReadOnlyList GetHolidays() + { + return + [ + new + { + id = "easter-1", + eventId = (string?)null, + name = "Easter", + category = "holiday", + subcategory = (string?)null, + loopId = _loops[0].LoopId, + memberId = (string?)null, + isEnabled = true, + date = "2026-04-05", + endDate = (string?)null, + created = DateTimeOffset.UtcNow.ToString("O") + } + ]; + } + public void UpdateRobot(DeviceRegistration registration) { _robot = registration; _devices[registration.DeviceId] = registration; + _robotProfile = new RobotProfile + { + RobotId = registration.RobotId, + Payload = new Dictionary + { + ["SSID"] = "my-ssid", + ["connectedAt"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + ["platform"] = registration.FirmwareVersion ?? "12.10.0", + ["serialNumber"] = registration.DeviceId + }, + UpdatedUtc = DateTimeOffset.UtcNow + }; } } diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/ProtocolFixtureLoader.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/ProtocolFixtureLoader.cs new file mode 100644 index 0000000..3accba0 --- /dev/null +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Fixtures/ProtocolFixtureLoader.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using Jibo.Cloud.Domain.Models; + +namespace Jibo.Cloud.Tests.Fixtures; + +internal static class ProtocolFixtureLoader +{ + public static ProtocolFixture Load(string relativePath) + { + var fullPath = Path.Combine(AppContext.BaseDirectory, relativePath); + using var document = JsonDocument.Parse(File.ReadAllText(fullPath)); + var root = document.RootElement; + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (root.TryGetProperty("headers", out var headerElement) && headerElement.ValueKind == JsonValueKind.Object) + { + foreach (var property in headerElement.EnumerateObject()) + { + headers[property.Name] = property.Value.ToString(); + } + } + + var bodyText = root.TryGetProperty("body", out var bodyElement) + ? bodyElement.GetRawText() + : string.Empty; + + var target = headers.TryGetValue("x-amz-target", out var targetValue) + ? targetValue + : string.Empty; + var targetParts = target.Split('.', 2, StringSplitOptions.RemoveEmptyEntries); + + return new ProtocolFixture + { + Name = Path.GetFileNameWithoutExtension(relativePath), + Request = new ProtocolEnvelope + { + HostName = root.TryGetProperty("host", out var hostElement) ? hostElement.GetString() ?? "api.jibo.com" : "api.jibo.com", + Method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() ?? "POST" : "POST", + Path = root.TryGetProperty("path", out var pathElement) ? pathElement.GetString() ?? "/" : "/", + Headers = headers, + ServicePrefix = targetParts.Length > 0 ? targetParts[0] : null, + Operation = targetParts.Length > 1 ? targetParts[1] : null, + BodyText = bodyText + }, + ExpectedStatusCode = 200 + }; + } +} diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Jibo.Cloud.Tests.csproj b/OpenJibo/tests/Jibo.Cloud.Tests/Jibo.Cloud.Tests.csproj index 2909fbc..d5ef86d 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Jibo.Cloud.Tests.csproj +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Jibo.Cloud.Tests.csproj @@ -19,4 +19,11 @@ + + + fixtures\%(Filename)%(Extension) + PreserveNewest + + + diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs index 9432ac3..8e70a7d 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs @@ -61,4 +61,82 @@ public sealed class JiboCloudProtocolServiceTests Assert.Equal("robot", payload.RootElement.GetProperty("subsystem").GetString()); Assert.True(payload.RootElement.TryGetProperty("url", out _)); } + + [Fact] + public async Task PutEventsAsync_ReturnsUploadUrl() + { + var result = await _service.DispatchAsync(new ProtocolEnvelope + { + HostName = "api.jibo.com", + Method = "POST", + ServicePrefix = "Log_20160715", + Operation = "PutEventsAsync", + BodyText = "{}" + }); + + using var payload = JsonDocument.Parse(result.BodyText); + Assert.Equal("gzip", payload.RootElement.GetProperty("contentEncoding").GetString()); + Assert.Contains("/upload/log-events", payload.RootElement.GetProperty("uploadUrl").GetString()); + } + + [Fact] + public async Task MediaCreateAndGet_ReturnsCreatedItem() + { + var created = await _service.DispatchAsync(new ProtocolEnvelope + { + HostName = "api.jibo.com", + Method = "POST", + ServicePrefix = "Media_20160725", + Operation = "Create", + BodyText = """{"path":"/media/test-item","type":"image","reference":"demo"}""" + }); + + using var createdPayload = JsonDocument.Parse(created.BodyText); + Assert.Equal("/media/test-item", createdPayload.RootElement.GetProperty("path").GetString()); + + var fetched = await _service.DispatchAsync(new ProtocolEnvelope + { + HostName = "api.jibo.com", + Method = "POST", + ServicePrefix = "Media_20160725", + Operation = "Get", + BodyText = """{"paths":["/media/test-item"]}""" + }); + + using var fetchedPayload = JsonDocument.Parse(fetched.BodyText); + Assert.Single(fetchedPayload.RootElement.EnumerateArray()); + } + + [Fact] + public async Task KeyCreateSymmetricKey_ReturnsKeyPayload() + { + var result = await _service.DispatchAsync(new ProtocolEnvelope + { + HostName = "api.jibo.com", + Method = "POST", + ServicePrefix = "Key_20160715", + Operation = "CreateSymmetricKey", + BodyText = """{"loopId":"openjibo-default-loop"}""" + }); + + using var payload = JsonDocument.Parse(result.BodyText); + Assert.Equal("openjibo-default-loop", payload.RootElement.GetProperty("loopId").GetString()); + Assert.False(string.IsNullOrWhiteSpace(payload.RootElement.GetProperty("key").GetString())); + } + + [Fact] + public async Task PersonListHolidays_ReturnsHoliday() + { + var result = await _service.DispatchAsync(new ProtocolEnvelope + { + HostName = "api.jibo.com", + Method = "POST", + ServicePrefix = "Person_20160715", + Operation = "ListHolidays", + BodyText = "{}" + }); + + using var payload = JsonDocument.Parse(result.BodyText); + Assert.Single(payload.RootElement.EnumerateArray()); + } } diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/ProtocolFixtureReplayTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/ProtocolFixtureReplayTests.cs new file mode 100644 index 0000000..8c64d56 --- /dev/null +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/ProtocolFixtureReplayTests.cs @@ -0,0 +1,22 @@ +using Jibo.Cloud.Application.Services; +using Jibo.Cloud.Infrastructure.Persistence; +using Jibo.Cloud.Tests.Fixtures; + +namespace Jibo.Cloud.Tests.Protocol; + +public sealed class ProtocolFixtureReplayTests +{ + private readonly JiboCloudProtocolService _service = new(new InMemoryCloudStateStore()); + + [Theory] + [InlineData("fixtures\\create-hub-token.request.json")] + [InlineData("fixtures\\new-robot-token.request.json")] + public async Task FixtureRequest_ReplaysSuccessfully(string relativePath) + { + var fixture = ProtocolFixtureLoader.Load(relativePath); + var result = await _service.DispatchAsync(fixture.Request); + + Assert.Equal(fixture.ExpectedStatusCode, result.StatusCode); + Assert.False(string.IsNullOrWhiteSpace(result.BodyText)); + } +}