diff --git a/src/PinguApps.Appwrite.Shared/Converters/DocumentGenericConverter.cs b/src/PinguApps.Appwrite.Shared/Converters/DocumentGenericConverter.cs new file mode 100644 index 00000000..b11a585d --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Converters/DocumentGenericConverter.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Responses; +using PinguApps.Appwrite.Shared.Utils; + +namespace PinguApps.Appwrite.Shared.Converters; + +public class DocumentGenericConverter : JsonConverter> + where TData : class, new() +{ + public override Document Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? id = null; + string? collectionId = null; + string? databaseId = null; + DateTime? createdAt = null; + DateTime? updatedAt = null; + List? permissions = null; + TData data = new(); + + var dataProperties = new Dictionary(); + + var dateTimeConverter = new MultiFormatDateTimeConverter(); + var permissionListConverter = new PermissionListConverter(); + + if (reader.TokenType is not JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token"); + } + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + var propertyName = reader.GetString()!; + + reader.Read(); + + switch (propertyName) + { + case "$id": + id = reader.GetString(); + break; + case "$collectionId": + collectionId = reader.GetString(); + break; + case "$databaseId": + databaseId = reader.GetString(); + break; + case "$createdAt": + createdAt = dateTimeConverter.Read(ref reader, typeof(DateTime), options); + break; + case "$updatedAt": + updatedAt = dateTimeConverter.Read(ref reader, typeof(DateTime), options); + break; + case "$permissions": + permissions = permissionListConverter.Read(ref reader, typeof(List), options); + break; + default: + var value = ReadValue(ref reader, options); + dataProperties[propertyName] = value; + break; + } + } + + if (id is null) + { + throw new JsonException("Unable to find a value for Id"); + } + + if (collectionId is null) + { + throw new JsonException("Unable to find a value for CollectionId"); + } + + if (databaseId is null) + { + throw new JsonException("Unable to find a value for DatabaseId"); + } + + if (createdAt is null) + { + throw new JsonException("Unable to find a value for CreatedAt"); + } + + if (updatedAt is null) + { + throw new JsonException("Unable to find a value for UpdatedAt"); + } + + if (permissions is null) + { + throw new JsonException("Unable to find a value for Permissions"); + } + + // Deserialize the remaining properties into TData + var dataJson = JsonSerializer.Serialize(dataProperties, options); + data = JsonSerializer.Deserialize(dataJson, options) ?? new TData(); + + return new Document(id, collectionId, databaseId, createdAt.Value, updatedAt.Value, permissions, data); + } + + internal object? ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.String: + var str = reader.GetString(); + + if ((DateTime.TryParse(str, out var dateTime))) + { + return dateTime; + } + return str; + + case JsonTokenType.Number: + if (reader.TryGetInt64(out var longValue)) + { + return longValue; + } + return reader.GetSingle(); + + case JsonTokenType.True: + case JsonTokenType.False: + return reader.GetBoolean(); + + case JsonTokenType.Null: + return null; + + case JsonTokenType.StartArray: + return ReadArray(ref reader, options); + + case JsonTokenType.StartObject: + return ReadObject(ref reader, options); + + default: + throw new JsonException($"Unsupported token type: {reader.TokenType}"); + } + } + + private IReadOnlyCollection ReadArray(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var list = new List(); + + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.EndArray) + { + break; + } + + var item = ReadValue(ref reader, options); + list.Add(item); + } + + return list; + } + + private Dictionary ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var dict = new Dictionary(); + + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.EndObject) + { + break; + } + + var propertyName = reader.GetString()!; + + reader.Read(); + + var value = ReadValue(ref reader, options); + + dict[propertyName] = value; + } + + return dict; + } + + public override void Write(Utf8JsonWriter writer, Document value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writer.WriteString("$id", value.Id); + writer.WriteString("$collectionId", value.CollectionId); + writer.WriteString("$databaseId", value.DatabaseId); + + // Use MultiFormatDateTimeConverter for DateTime properties + var dateTimeConverter = new MultiFormatDateTimeConverter(); + + writer.WritePropertyName("$createdAt"); + dateTimeConverter.Write(writer, value.CreatedAt, options); + + writer.WritePropertyName("$updatedAt"); + dateTimeConverter.Write(writer, value.UpdatedAt, options); + + writer.WritePropertyName("$permissions"); + JsonSerializer.Serialize(writer, value.Permissions, options); + + // Serialize the Data property + if (value.Data is not null) + { + var dataProperties = JsonSerializer.SerializeToElement(value.Data, options); + foreach (var property in dataProperties.EnumerateObject()) + { + writer.WritePropertyName(property.Name); + WriteValue(writer, property.Value, options); + } + } + + writer.WriteEndObject(); + } + + internal void WriteValue(Utf8JsonWriter writer, JsonElement element, JsonSerializerOptions options) + { + var dateTimeConverter = new MultiFormatDateTimeConverter(); + + switch (element.ValueKind) + { + case JsonValueKind.String: + var stringValue = element.GetString(); + if (DateTime.TryParse(stringValue, out var dateTimeValue)) + { + // Write DateTime using the MultiFormatDateTimeConverter + dateTimeConverter.Write(writer, dateTimeValue, options); + } + else + { + writer.WriteStringValue(stringValue); + } + break; + case JsonValueKind.Number: + if (element.TryGetInt32(out var intValue)) + writer.WriteNumberValue(intValue); + else if (element.TryGetInt64(out var longValue)) + writer.WriteNumberValue(longValue); + else if (element.TryGetDouble(out var doubleValue)) + writer.WriteNumberValue(doubleValue); + break; + case JsonValueKind.True: + case JsonValueKind.False: + writer.WriteBooleanValue(element.GetBoolean()); + break; + case JsonValueKind.Null: + writer.WriteNullValue(); + break; + case JsonValueKind.Array: + writer.WriteStartArray(); + foreach (var item in element.EnumerateArray()) + { + WriteValue(writer, item, options); + } + writer.WriteEndArray(); + break; + case JsonValueKind.Object: + writer.WriteStartObject(); + foreach (var property in element.EnumerateObject()) + { + writer.WritePropertyName(property.Name); + WriteValue(writer, property.Value, options); + } + writer.WriteEndObject(); + break; + case JsonValueKind.Undefined: + throw new JsonException("Cannot serialize undefined JsonElement"); + } + } +} diff --git a/src/PinguApps.Appwrite.Shared/Converters/DocumentGenericConverterFactory.cs b/src/PinguApps.Appwrite.Shared/Converters/DocumentGenericConverterFactory.cs new file mode 100644 index 00000000..da178a5f --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Converters/DocumentGenericConverterFactory.cs @@ -0,0 +1,24 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Responses; + +namespace PinguApps.Appwrite.Shared.Converters; +public class DocumentGenericConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + // Ensure the type is a generic type of Document<> + return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Document<>); + } + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + // Extract the TData type from Doocument + Type dataType = typeToConvert.GetGenericArguments()[0]; + + // Create a specific generic converter for Doocument + var converterType = typeof(DocumentGenericConverter<>).MakeGenericType(dataType); + return (JsonConverter?)Activator.CreateInstance(converterType); + } +} diff --git a/src/PinguApps.Appwrite.Shared/Responses/Document.cs b/src/PinguApps.Appwrite.Shared/Responses/Document.cs index 5e822dd3..0723c37b 100644 --- a/src/PinguApps.Appwrite.Shared/Responses/Document.cs +++ b/src/PinguApps.Appwrite.Shared/Responses/Document.cs @@ -19,14 +19,14 @@ namespace PinguApps.Appwrite.Shared.Responses; /// Document data [JsonConverter(typeof(DocumentConverter))] public record Document( - [property: JsonPropertyName("$id")] string Id, - [property: JsonPropertyName("$collectionId")] string CollectionId, - [property: JsonPropertyName("$databaseId")] string DatabaseId, - [property: JsonPropertyName("$createdAt"), JsonConverter(typeof(MultiFormatDateTimeConverter))] DateTime CreatedAt, - [property: JsonPropertyName("$updatedAt"), JsonConverter(typeof(MultiFormatDateTimeConverter))] DateTime UpdatedAt, - [property: JsonPropertyName("$permissions"), JsonConverter(typeof(PermissionReadOnlyListConverter))] IReadOnlyList Permissions, + string Id, + string CollectionId, + string DatabaseId, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList Permissions, [property: JsonExtensionData] Dictionary Data -) +) : DocumentBase(Id, CollectionId, DatabaseId, CreatedAt, UpdatedAt, Permissions) { /// /// Extract document data by key diff --git a/src/PinguApps.Appwrite.Shared/Responses/DocumentBase.cs b/src/PinguApps.Appwrite.Shared/Responses/DocumentBase.cs new file mode 100644 index 00000000..15ea69c7 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Responses/DocumentBase.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Converters; +using PinguApps.Appwrite.Shared.Utils; + +namespace PinguApps.Appwrite.Shared.Responses; + +/// +/// An Appwrite Document object +/// +/// Document ID +/// Collection ID +/// Database ID +/// Document creation date in ISO 8601 format +/// Document update date in ISO 8601 format +/// Document permissions. Learn more about permissions +[JsonConverter(typeof(DocumentConverter))] +public abstract record DocumentBase( + [property: JsonPropertyName("$id")] string Id, + [property: JsonPropertyName("$collectionId")] string CollectionId, + [property: JsonPropertyName("$databaseId")] string DatabaseId, + [property: JsonPropertyName("$createdAt"), JsonConverter(typeof(MultiFormatDateTimeConverter))] DateTime CreatedAt, + [property: JsonPropertyName("$updatedAt"), JsonConverter(typeof(MultiFormatDateTimeConverter))] DateTime UpdatedAt, + [property: JsonPropertyName("$permissions"), JsonConverter(typeof(PermissionReadOnlyListConverter))] IReadOnlyList Permissions +); diff --git a/src/PinguApps.Appwrite.Shared/Responses/DocumentGeneric.cs b/src/PinguApps.Appwrite.Shared/Responses/DocumentGeneric.cs new file mode 100644 index 00000000..e5465118 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Responses/DocumentGeneric.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Converters; +using PinguApps.Appwrite.Shared.Utils; + +namespace PinguApps.Appwrite.Shared.Responses; + +/// +/// An Appwrite Document object +/// +/// Document ID +/// Collection ID +/// Database ID +/// Document creation date in ISO 8601 format +/// Document update date in ISO 8601 format +/// Document permissions. Learn more about permissions +/// Document data +[JsonConverter(typeof(DocumentGenericConverterFactory))] +public record Document( + string Id, + string CollectionId, + string DatabaseId, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList Permissions, + TData Data +) : DocumentBase(Id, CollectionId, DatabaseId, CreatedAt, UpdatedAt, Permissions) + where TData : class, new(); diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Converters/DocumentGenericConverterFactoryTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Converters/DocumentGenericConverterFactoryTests.cs new file mode 100644 index 00000000..2110449e --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Converters/DocumentGenericConverterFactoryTests.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using PinguApps.Appwrite.Shared.Converters; +using PinguApps.Appwrite.Shared.Responses; + +namespace PinguApps.Appwrite.Shared.Tests.Converters; +public class DocumentGenericConverterFactoryTests +{ + private readonly DocumentGenericConverterFactory _factory; + + public DocumentGenericConverterFactoryTests() + { + _factory = new DocumentGenericConverterFactory(); + } + + public class TestData + { + public string? Field1 { get; set; } + } + + [Fact] + public void CanConvert_DocumentGenericType_ReturnsTrue() + { + // Arrange + var typeToConvert = typeof(Document); + + // Act + var result = _factory.CanConvert(typeToConvert); + + // Assert + Assert.True(result); + } + + [Fact] + public void CanConvert_NonGenericType_ReturnsFalse() + { + // Arrange + var typeToConvert = typeof(TestData); + + // Act + var result = _factory.CanConvert(typeToConvert); + + // Assert + Assert.False(result); + } + + [Fact] + public void CanConvert_DifferentGenericType_ReturnsFalse() + { + // Arrange + var typeToConvert = typeof(List); + + // Act + var result = _factory.CanConvert(typeToConvert); + + // Assert + Assert.False(result); + } + + [Fact] + public void CreateConverter_DocumentGenericType_ReturnsConverter() + { + // Arrange + var typeToConvert = typeof(Document); + var options = new JsonSerializerOptions(); + + // Act + var converter = _factory.CreateConverter(typeToConvert, options); + + // Assert + Assert.NotNull(converter); + Assert.IsType>(converter); + } + + [Fact] + public void CreateConverter_NonGenericType_ThrowsException() + { + // Arrange + var typeToConvert = typeof(TestData); + var options = new JsonSerializerOptions(); + + // Act & Assert + Assert.Throws(() => _factory.CreateConverter(typeToConvert, options)); + } + + [Fact] + public void CreateConverter_DifferentGenericType_ReturnsConverterWithFirstGenericArgument() + { + // Arrange + var typeToConvert = typeof(List); + var options = new JsonSerializerOptions(); + + // Act + var converter = _factory.CreateConverter(typeToConvert, options); + + // Assert + Assert.NotNull(converter); + Assert.IsType>(converter); + } +} diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Converters/DocumentGenericConverterTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Converters/DocumentGenericConverterTests.cs new file mode 100644 index 00000000..9a18580c --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Converters/DocumentGenericConverterTests.cs @@ -0,0 +1,686 @@ +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Converters; +using PinguApps.Appwrite.Shared.Enums; +using PinguApps.Appwrite.Shared.Responses; +using PinguApps.Appwrite.Shared.Utils; + +namespace PinguApps.Appwrite.Shared.Tests.Converters; +public class DocumentGenericConverterTests +{ + private readonly JsonSerializerOptions _options; + + public DocumentGenericConverterTests() + { + _options = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = { new DocumentGenericConverter() } + }; + } + + public class TestData + { + public string? Field1 { get; set; } + public int Field2 { get; set; } + public bool Field3 { get; set; } + public DateTime? Field4 { get; set; } + public List? Field5 { get; set; } + public Dictionary? Field6 { get; set; } + public float? FloatField { get; set; } + public long? LongField { get; set; } + public double? DoubleField { get; set; } + } + + [Fact] + public void Read_ValidJson_ReturnsDocument() + { + var json = @" + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$databaseId"": ""db1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": ""value1"", + ""Field2"": 42, + ""Field3"": true, + ""Field4"": ""2020-10-15T06:38:00.000+00:00"", + ""Field5"": [""item1"", ""item2""], + ""Field6"": { ""key1"": ""value1"", ""key2"": 2 } + }"; + + var document = JsonSerializer.Deserialize>(json, _options); + + Assert.NotNull(document); + Assert.Equal("1", document.Id); + Assert.Equal("col1", document.CollectionId); + Assert.Equal("db1", document.DatabaseId); + Assert.Equal(DateTime.Parse("2020-10-15T06:38:00.000+00:00"), document.CreatedAt); + Assert.Equal(DateTime.Parse("2020-10-15T06:38:00.000+00:00"), document.UpdatedAt); + Assert.Single(document.Permissions); + Assert.Equal(PermissionType.Read, document.Permissions[0].PermissionType); + Assert.Equal(RoleType.Any, document.Permissions[0].RoleType); + + Assert.NotNull(document.Data); + Assert.Equal("value1", document.Data.Field1); + Assert.Equal(42, document.Data.Field2); + Assert.True(document.Data.Field3); + Assert.Equal(DateTime.Parse("2020-10-15T06:38:00.000+00:00"), document.Data.Field4); + Assert.Equal(new List { "item1", "item2" }, document.Data.Field5); + + Assert.NotNull(document.Data.Field6); + var field6 = document.Data.Field6!; + Assert.Equal(2, field6.Count); + + // Extract values from JsonElement + Assert.True(field6.ContainsKey("key1")); + Assert.Equal("value1", field6["key1"]?.ToString()); + + Assert.True(field6.ContainsKey("key2")); + Assert.Equal(2, Convert.ToInt32(field6["key2"]?.ToString())); + } + + [Fact] + public void Read_InvalidJson_ThrowsJsonException() + { + var json = @" + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$databaseId"": ""db1"", + ""$createdAt"": ""invalid-date"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": ""value1"" + }"; + + Assert.Throws(() => JsonSerializer.Deserialize>(json, _options)); + } + + [Fact] + public void Read_MissingRequiredFields_ThrowsJsonException() + { + var json = @" + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": ""value1"" + }"; + + Assert.Throws(() => JsonSerializer.Deserialize>(json, _options)); + } + + [Fact] + public void Write_ValidDocument_WritesJson() + { + var testData = new TestData + { + Field1 = "value1", + Field2 = 42, + Field3 = true, + Field4 = DateTime.Parse("2020-10-15T06:38:00.000+00:00"), + Field5 = ["item1", "item2"], + Field6 = new Dictionary { { "key1", "value1" }, { "key2", 2 } } + }; + + var document = new Document( + "1", + "col1", + "db1", + DateTime.Parse("2020-10-15T06:38:00.000+00:00"), + DateTime.Parse("2020-10-15T06:38:00.000+00:00"), + [Permission.Read().Any()], + testData + ); + + var json = JsonSerializer.Serialize(document, _options); + + var expectedJson = @" + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$databaseId"": ""db1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": ""value1"", + ""Field2"": 42, + ""Field3"": true, + ""Field4"": ""2020-10-15T06:38:00.000+00:00"", + ""Field5"": [""item1"", ""item2""], + ""Field6"": { ""key1"": ""value1"", ""key2"": 2 }, + ""FloatField"": null, + ""LongField"": null, + ""DoubleField"": null + }".ReplaceLineEndings("").Replace(" ", ""); + + Assert.Equal(JsonDocument.Parse(expectedJson).RootElement.ToString(), JsonDocument.Parse(json).RootElement.ToString()); + } + + [Fact] + public void Write_NullData_WritesJsonWithNoDataProperties() + { + var document = new Document( + "1", + "col1", + "db1", + DateTime.UtcNow, + DateTime.UtcNow, + [Permission.Read().Any()], + null! + ); + + var json = JsonSerializer.Serialize(document, _options); + + Assert.Contains("\"$id\"", json); + Assert.DoesNotContain("\"Field1\"", json); + } + + [Fact] + public void Read_NullProperty_InsertedIntoData() + { + var json = @" + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$databaseId"": ""db1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": null + }"; + + var document = JsonSerializer.Deserialize>(json, _options); + + Assert.NotNull(document); + Assert.NotNull(document.Data); + Assert.Null(document.Data.Field1); + } + + [Fact] + public void Write_NullValue_SerializesCorrectly() + { + var testData = new TestData + { + Field1 = null + }; + + var document = new Document( + "1", + "col1", + "db1", + DateTime.UtcNow, + DateTime.UtcNow, + [Permission.Read().Any()], + testData + ); + + var json = JsonSerializer.Serialize(document, _options); + + Assert.Contains("\"Field1\":null", json); + } + + //[Fact] + //public void ReadValue_UnsupportedTokenType_ThrowsJsonException() + //{ + // var json = @" + // { + // ""$id"": ""1"", + // ""$collectionId"": ""col1"", + // ""$databaseId"": ""db1"", + // ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + // ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + // ""$permissions"": [""read(\""any\"")""], + // ""unsupported"": /** comment */ + // }"; + + // Assert.Throws(() => JsonSerializer.Deserialize>(json, _options)); + //} + + [Fact] + public void Write_CustomObject_SerializesUsingJsonSerializer() + { + var testData = new TestData + { + Field6 = new Dictionary + { + { "nestedObject", new { Prop1 = "value1", Prop2 = 2 } } + } + }; + + var document = new Document( + "1", + "col1", + "db1", + DateTime.UtcNow, + DateTime.UtcNow, + [Permission.Read().Any()], + testData + ); + + var json = JsonSerializer.Serialize(document, _options); + + Assert.Contains("\"Prop1\":\"value1\"", json); + Assert.Contains("\"Prop2\":2", json); + } + + [Fact] + public void Read_InvalidJsonTokenType_ThrowsJsonException() + { + var json = @" + [ + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$databaseId"": ""db1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": ""value1"" + } + ]"; + + Assert.Throws(() => JsonSerializer.Deserialize>(json, _options)); + } + + [Fact] + public void Write_DateTimeValue_SerializesCorrectly() + { + var testData = new TestData + { + Field4 = DateTime.Parse("2020-10-15T06:38:00.000+00:00") + }; + + var document = new Document( + "1", + "col1", + "db1", + DateTime.UtcNow, + DateTime.UtcNow, + [], + testData + ); + + var json = JsonSerializer.Serialize(document, _options); + + Assert.Contains("\"Field4\":\"2020-10-15T06:38:00.000+00:00\"", json); + } + + [Fact] + public void Write_NullDataProperty_WritesNull() + { + var testData = new TestData + { + Field5 = null + }; + + var document = new Document( + "1", + "col1", + "db1", + DateTime.UtcNow, + DateTime.UtcNow, + [], + testData + ); + + var json = JsonSerializer.Serialize(document, _options); + + Assert.Contains("\"Field5\":null", json); + } + + [Fact] + public void Write_BooleanValue_SerializesCorrectly() + { + var testData = new TestData + { + Field3 = true + }; + + var document = new Document( + "1", + "col1", + "db1", + DateTime.UtcNow, + DateTime.UtcNow, + [], + testData + ); + + var json = JsonSerializer.Serialize(document, _options); + + Assert.Contains("\"Field3\":true", json); + } + + [Fact] + public void Write_NumberValues_SerializesCorrectly() + { + var testData = new TestData + { + Field2 = 123 + }; + + var document = new Document( + "1", + "col1", + "db1", + DateTime.UtcNow, + DateTime.UtcNow, + [], + testData + ); + + var json = JsonSerializer.Serialize(document, _options); + + Assert.Contains("\"Field2\":123", json); + } + + [Fact] + public void Read_MissingId_ThrowsJsonException() + { + var json = @" + { + ""$collectionId"": ""col1"", + ""$databaseId"": ""db1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": ""value1"" + }"; + + Assert.Throws(() => JsonSerializer.Deserialize>(json, _options)); + } + + [Fact] + public void Read_MissingCollectionId_ThrowsJsonException() + { + var json = @" + { + ""$id"": ""1"", + ""$databaseId"": ""db1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": ""value1"" + }"; + + Assert.Throws(() => JsonSerializer.Deserialize>(json, _options)); + } + + [Fact] + public void Read_MissingDatabaseId_ThrowsJsonException() + { + var json = @" + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": ""value1"" + }"; + + Assert.Throws(() => JsonSerializer.Deserialize>(json, _options)); + } + + [Fact] + public void Read_MissingCreatedAt_ThrowsJsonException() + { + var json = @" + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$databaseId"": ""db1"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": ""value1"" + }"; + + Assert.Throws(() => JsonSerializer.Deserialize>(json, _options)); + } + + [Fact] + public void Read_MissingUpdatedAt_ThrowsJsonException() + { + var json = @" + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$databaseId"": ""db1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": ""value1"" + }"; + + Assert.Throws(() => JsonSerializer.Deserialize>(json, _options)); + } + + [Fact] + public void Read_MissingPermissions_ThrowsJsonException() + { + var json = @" + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$databaseId"": ""db1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""Field1"": ""value1"" + }"; + + Assert.Throws(() => JsonSerializer.Deserialize>(json, _options)); + } + + [Fact] + public void Write_NullDocumentData_SerializesCorrectly() + { + var document = new Document( + "1", + "col1", + "db1", + DateTime.UtcNow, + DateTime.UtcNow, + [], + null! + ); + + var json = JsonSerializer.Serialize(document, _options); + + Assert.Contains("\"$id\"", json); + Assert.DoesNotContain("\"Field1\"", json); + } + + // Custom converter that returns null during deserialization + public class NullReturningConverter : JsonConverter where T : class + { + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Always return null + reader.Skip(); // Skip the current value to avoid infinite loops + return null; + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + // Write null + writer.WriteNullValue(); + } + } + + [Fact] + public void Read_DataDeserializationReturnsNull_DataSetToNewInstance() + { + var json = @" + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$databaseId"": ""db1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""Field1"": ""value1"", + ""Field2"": 42 + }"; + + // Create new options with the NullReturningConverter for TestData + var optionsWithNullConverter = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = + { + new NullReturningConverter(), + new DocumentGenericConverter(), + new MultiFormatDateTimeConverter(), + new PermissionListConverter() + } + }; + + var document = JsonSerializer.Deserialize>(json, optionsWithNullConverter); + + Assert.NotNull(document); + Assert.NotNull(document.Data); + // Since data deserialization returned null, Data should be set to new TData() + // So Data's properties should have default values + Assert.Null(document.Data.Field1); + Assert.Equal(0, document.Data.Field2); + } + + [Fact] + public void ReadValue_UnsupportedTokenType_ThrowsJsonException() + { + var json = @"{ + ""Field1"": /* Comment */ ""value1"" + }"; + + var readerOptions = new JsonReaderOptions + { + CommentHandling = JsonCommentHandling.Allow + }; + + var bytes = Encoding.UTF8.GetBytes(json); + + var reader = new Utf8JsonReader(bytes, readerOptions); + + var converter = new DocumentGenericConverter(); + + // Read the StartObject token + reader.Read(); // JsonTokenType.StartObject + + // Read the PropertyName token + reader.Read(); // JsonTokenType.PropertyName + + var propertyName = reader.GetString()!; + + // Read the Comment token + reader.Read(); // JsonTokenType.Comment + + // At this point, reader.TokenType is Comment, which is not handled in ReadValue + // Calling ReadValue should now hit the default case and throw JsonException + try + { + converter.ReadValue(ref reader, _options); + Assert.Fail("Did not throw JsonException"); + } + catch (JsonException) + { + } + } + + [Fact] + public void ReadValue_FloatNumber_ReturnsSingle() + { + var json = @" + { + ""$id"": ""1"", + ""$collectionId"": ""col1"", + ""$databaseId"": ""db1"", + ""$createdAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$updatedAt"": ""2020-10-15T06:38:00.000+00:00"", + ""$permissions"": [""read(\""any\"")""], + ""FloatField"": 1.23 + }"; + + var document = JsonSerializer.Deserialize>(json, _options); + + Assert.NotNull(document); + Assert.NotNull(document.Data); + Assert.Equal(1.23f, document.Data.FloatField); + } + + [Fact] + public void WriteValue_UndefinedValueKind_CallsJsonSerializer() + { + var converter = new DocumentGenericConverter(); + + // Create a default-initialized JsonElement (ValueKind is Undefined) + JsonElement undefinedElement = default; + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + + // Since JsonSerializer.Serialize will throw an exception when trying to serialize an undefined JsonElement, + // we can expect an InvalidOperationException + Assert.Throws(() => converter.WriteValue(writer, undefinedElement, _options)); + } + + [Fact] + public void WriteValue_LongNumber_WritesLongValue() + { + var testData = new TestData + { + LongField = (long)int.MaxValue + 1 // Value larger than int.MaxValue + }; + + var document = new Document( + "1", + "col1", + "db1", + DateTime.UtcNow, + DateTime.UtcNow, + [], + testData + ); + + var json = JsonSerializer.Serialize(document, _options); + + // Verify that the LongField is serialized correctly + var jsonDoc = JsonDocument.Parse(json); + Assert.True(jsonDoc.RootElement.TryGetProperty("LongField", out var longFieldElement)); + Assert.Equal(JsonValueKind.Number, longFieldElement.ValueKind); + Assert.Equal((long)int.MaxValue + 1, longFieldElement.GetInt64()); + } + + [Fact] + public void WriteValue_DoubleNumber_WritesDoubleValue() + { + var testData = new TestData + { + DoubleField = 1.23e20 // A large double value + }; + + var document = new Document( + "1", + "col1", + "db1", + DateTime.UtcNow, + DateTime.UtcNow, + [], + testData + ); + + var json = JsonSerializer.Serialize(document, _options); + + // Verify that the DoubleField is serialized correctly + var jsonDoc = JsonDocument.Parse(json); + Assert.True(jsonDoc.RootElement.TryGetProperty("DoubleField", out var doubleFieldElement)); + Assert.Equal(JsonValueKind.Number, doubleFieldElement.ValueKind); + Assert.Equal(1.23e20, doubleFieldElement.GetDouble()); + } +}