diff --git a/v2rayN/ServiceLib.Tests/Common/JsonUtilsMetadataTests.cs b/v2rayN/ServiceLib.Tests/Common/JsonUtilsMetadataTests.cs new file mode 100644 index 00000000..2c7e676e --- /dev/null +++ b/v2rayN/ServiceLib.Tests/Common/JsonUtilsMetadataTests.cs @@ -0,0 +1,82 @@ +namespace ServiceLib.Tests.Common; + +public class JsonUtilsMetadataTests +{ + [Test] + [Arguments(false, false)] + [Arguments(false, true)] + [Arguments(true, false)] + [Arguments(true, true)] + public async Task Serialize_PreservesIndentationNullsAndUnicode(bool indented, bool nullValue) + { + var item = new WebDavItem { Url = "https://example.com/русский", UserName = "tester" }; + + var json = JsonUtils.Serialize(item, indented, nullValue); + var node = JsonUtils.ParseJson(json)!.AsObject(); + + await node["Url"]!.GetValue().Should().BeEqualTo(item.Url); + await json.Contains("русский").Should().BeTrue(); + await json.Contains(Environment.NewLine).Should().BeEqualTo(indented); + await node.ContainsKey("Password").Should().BeEqualTo(nullValue); + await node.ContainsKey("DirName").Should().BeEqualTo(nullValue); + await JsonUtils.Deserialize(json)!.UserName.Should().BeEqualTo("tester"); + } + + [Test] + public async Task Deserialize_AcceptsCommentsAndCaseInsensitiveNames() + { + const string json = "{/* existing configuration */ \"uRl\":\"https://example.com\",\"USERNAME\":\"tester\"}"; + var item = JsonUtils.Deserialize(json)!; + + await item.Url.Should().BeEqualTo("https://example.com"); + await item.UserName.Should().BeEqualTo("tester"); + await JsonUtils.Deserialize("invalid").Should().BeNull(); + await JsonUtils.Deserialize(" ").Should().BeNull(); + } + + [Test] + public async Task NodeAndCamelCaseSerialization_UseTheRequestedOptions() + { + var item = new WebDavItem { Url = "https://example.com" }; + var node = JsonUtils.SerializeToNode(item, nullValue: true)!.AsObject(); + var camelCase = JsonUtils.ParseJson(JsonUtils.SerializeCamelCase(item))!.AsObject(); + + await node.ContainsKey("Password").Should().BeTrue(); + await JsonUtils.SerializeToNode(item)!.AsObject().ContainsKey("Password").Should().BeFalse(); + await camelCase["url"]!.GetValue().Should().BeEqualTo(item.Url); + await camelCase.ContainsKey("Url").Should().BeFalse(); + await camelCase.ContainsKey("password").Should().BeFalse(); + + var values = JsonUtils.SerializeToArray(["first", "second"])!; + await values.Count.Should().BeEqualTo(2); + await values[0]!.GetValue().Should().BeEqualTo("first"); + await values[1]!.GetValue().Should().BeEqualTo("second"); + await JsonUtils.SerializeToArray(null).Should().BeNull(); + } + + [Test] + public async Task DeepCopy_KeepsProfileDataAndCreatesAnIndependentModel() + { + var item = new ProfileItem { IndexId = "profile", Address = "example.com", Port = 443, Remarks = "Original" }; + var copy = JsonUtils.DeepCopy(item)!; + + await copy.IndexId.Should().BeEqualTo(item.IndexId); + await copy.Address.Should().BeEqualTo(item.Address); + await copy.Port.Should().BeEqualTo(443); + copy.Remarks = "Changed"; + await item.Remarks.Should().BeEqualTo("Original"); + } + + [Test] + public async Task UnsupportedTypes_PreserveTheExistingFailureResults() + { + await JsonUtils.Serialize(new UnsupportedValue()).Should().BeEqualTo(string.Empty); + await JsonUtils.SerializeCamelCase(new UnsupportedValue()).Should().BeEqualTo(string.Empty); + await JsonUtils.Deserialize("{\"Name\":\"value\"}").Should().BeNull(); + } + + private sealed class UnsupportedValue + { + public string Name { get; set; } = "value"; + } +} diff --git a/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs b/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs index d083dcf9..0c290f14 100644 --- a/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs +++ b/v2rayN/ServiceLib.Tests/CoreConfig/V2ray/CoreConfigV2rayServiceTests.cs @@ -141,7 +141,8 @@ public class CoreConfigV2rayServiceTests await headers["User-Agent"]!.GetValue().Should().BeEqualTo("v2rayN"); await headers["Set-Cookie"]!.AsArray() .Select(item => item!.GetValue()) - .Should().BeEquivalentTo(["a=1", "b=2"]); + .Order(StringComparer.Ordinal).SequenceEqual(["a=1", "b=2"]) + .Should().BeTrue(); } [Test] diff --git a/v2rayN/ServiceLib.Tests/Helper/HttpRequestHeadersHelperTests.cs b/v2rayN/ServiceLib.Tests/Helper/HttpRequestHeadersHelperTests.cs index 468f99a3..617fa4df 100644 --- a/v2rayN/ServiceLib.Tests/Helper/HttpRequestHeadersHelperTests.cs +++ b/v2rayN/ServiceLib.Tests/Helper/HttpRequestHeadersHelperTests.cs @@ -67,11 +67,11 @@ public class HttpRequestHeadersHelperTests await oldHeaders.Count.Should().BeEqualTo(0); item.RequestHeaders = "{\"X-hwid\":\"my_device\"}"; - database.Update(item); + database.Update(item, typeof(SubItem)); await database.Find(item.Id).RequestHeaders.Should().BeEqualTo(item.RequestHeaders); item.RequestHeaders = ""; - database.Update(item); + database.Update(item, typeof(SubItem)); await database.Find(item.Id).RequestHeaders.Should().BeEqualTo(""); } } diff --git a/v2rayN/ServiceLib.Tests/Helper/SQLiteHelperTests.cs b/v2rayN/ServiceLib.Tests/Helper/SQLiteHelperTests.cs new file mode 100644 index 00000000..ca0c2b3a --- /dev/null +++ b/v2rayN/ServiceLib.Tests/Helper/SQLiteHelperTests.cs @@ -0,0 +1,282 @@ +namespace ServiceLib.Tests.Helper; + +[NotInParallel] +public class SQLiteHelperTests +{ + private static readonly Lazy Database = new(() => + { + var database = SQLiteHelper.Instance; + database.CreateTable(); + database.CreateTable(); + database.CreateTable(); + database.CreateTable(); + return database; + }); + + [Test] + public async Task SingleWrites_ShouldPreserveValuesAndDeleteByPrimaryKey() + { + var database = Database.Value; + var id = Guid.NewGuid().ToString("N"); + var row = new HelperRow { Id = id, Value = $"{id}-original" }; + try + { + await (await database.InsertAsync(row)).Should().BeEqualTo(1); + await (await database.ReplaceAsync(new HelperRow { Id = id, Value = $"{id}-replacement" })).Should().BeEqualTo(1); + row.Value = $"{id}-updated"; + await (await database.UpdateAsync(row)).Should().BeEqualTo(1); + + var rows = await database.QueryAsync($"SELECT * FROM SQLiteHelperTestRows WHERE Id = '{id}'"); + await rows.Count.Should().BeEqualTo(1); + await rows[0].Value.Should().BeEqualTo($"{id}-updated"); + await (await database.TableAsync().Where(item => item.Id == id).CountAsync()).Should().BeEqualTo(1); + + row.Value = "not-the-stored-value"; + await (await database.DeleteAsync(row)).Should().BeEqualTo(1); + await (await database.DeleteAsync(row)).Should().BeEqualTo(0); + await (await database.TableAsync().Where(item => item.Id == id).CountAsync()).Should().BeEqualTo(0); + } + finally + { + await database.ExecuteAsync($"DELETE FROM SQLiteHelperTestRows WHERE Id = '{id}'"); + } + } + + [Test] + public async Task BulkWrites_ShouldCommitEveryRowAndReturnAffectedCounts() + { + var database = Database.Value; + var prefix = Guid.NewGuid().ToString("N"); + var rows = new[] + { + new HelperRow { Id = $"{prefix}-first", Value = $"{prefix}-original-first" }, + new HelperRow { Id = $"{prefix}-second", Value = $"{prefix}-original-second" }, + }; + try + { + await (await database.InsertAllAsync(rows)).Should().BeEqualTo(2); + rows[0].Value = $"{prefix}-updated-first"; + rows[1].Value = $"{prefix}-updated-second"; + await (await database.UpdateAllAsync(rows)).Should().BeEqualTo(2); + + var saved = await database.QueryAsync($"SELECT * FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%' ORDER BY Id"); + await saved.Count.Should().BeEqualTo(2); + await saved[0].Value.Should().BeEqualTo($"{prefix}-updated-first"); + await saved[1].Value.Should().BeEqualTo($"{prefix}-updated-second"); + } + finally + { + await database.ExecuteAsync($"DELETE FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%'"); + } + } + + [Test] + public async Task InsertAllAsync_ShouldRollBackEarlierRowsWhenALaterInsertFails() + { + var database = Database.Value; + var id = Guid.NewGuid().ToString("N"); + try + { + SQLiteException? error = null; + try + { + await database.InsertAllAsync(new[] + { + new HelperRow { Id = id, Value = $"{id}-first" }, + new HelperRow { Id = id, Value = $"{id}-duplicate" }, + }); + } + catch (SQLiteException exception) + { + error = exception; + } + + await error.Should().NotBeNull(); + await (await database.TableAsync().Where(item => item.Id == id).CountAsync()).Should().BeEqualTo(0); + } + finally + { + await database.ExecuteAsync($"DELETE FROM SQLiteHelperTestRows WHERE Id = '{id}'"); + } + } + + [Test] + public async Task UpdateAllAsync_ShouldRollBackEarlierChangesWhenALaterUpdateFails() + { + var database = Database.Value; + var prefix = Guid.NewGuid().ToString("N"); + var first = new HelperRow { Id = $"{prefix}-first", Value = $"{prefix}-original-first" }; + var second = new HelperRow { Id = $"{prefix}-second", Value = $"{prefix}-original-second" }; + try + { + await database.InsertAllAsync(new[] { first, second }); + first.Value = $"{prefix}-updated-first"; + second.Value = first.Value; + SQLiteException? error = null; + try + { + await database.UpdateAllAsync(new[] { first, second }); + } + catch (SQLiteException exception) + { + error = exception; + } + + await error.Should().NotBeNull(); + var saved = await database.QueryAsync($"SELECT * FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%' ORDER BY Id"); + await saved.Count.Should().BeEqualTo(2); + await saved[0].Value.Should().BeEqualTo($"{prefix}-original-first"); + await saved[1].Value.Should().BeEqualTo($"{prefix}-original-second"); + } + finally + { + await database.ExecuteAsync($"DELETE FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%'"); + } + } + + [Test] + public async Task EmptyBatches_ShouldReturnZero() + { + var database = Database.Value; + await (await database.InsertAllAsync(Array.Empty())).Should().BeEqualTo(0); + await (await database.UpdateAllAsync(Array.Empty())).Should().BeEqualTo(0); + } + + [Test] + public async Task DeleteAsync_ShouldRejectRowsWithoutAPrimaryKey() + { + var database = Database.Value; + var value = Guid.NewGuid().ToString("N"); + try + { + var row = new NoPrimaryKeyRow { Value = value }; + await database.InsertAsync(row); + NotSupportedException? error = null; + try + { + await database.DeleteAsync(row); + } + catch (NotSupportedException exception) + { + error = exception; + } + + await error.Should().NotBeNull(); + await (await database.TableAsync().Where(item => item.Value == value).CountAsync()).Should().BeEqualTo(1); + } + finally + { + await database.ExecuteAsync($"DELETE FROM SQLiteHelperNoPrimaryKeyTestRows WHERE Value = '{value}'"); + } + } + + [Test] + public async Task DeleteAllAsync_ShouldReturnTheNumberOfDeletedRows() + { + var database = Database.Value; + await database.DeleteAllAsync(); + await database.InsertAllAsync(new[] { new DeleteAllRow { Id = "first" }, new DeleteAllRow { Id = "second" } }); + + await (await database.DeleteAllAsync()).Should().BeEqualTo(2); + await (await database.TableAsync().CountAsync()).Should().BeEqualTo(0); + } + + [Test] + public async Task ReplaceAllAsync_ShouldInsertAndReplaceRowsAndReturnAffectedCounts() + { + var database = Database.Value; + var prefix = Guid.NewGuid().ToString("N"); + var existing = new HelperRow { Id = $"{prefix}-existing", Value = $"{prefix}-original" }; + try + { + await database.InsertAsync(existing); + var replacements = new[] + { + new HelperRow { Id = existing.Id, Value = $"{prefix}-updated" }, + new HelperRow { Id = $"{prefix}-new", Value = $"{prefix}-new-value" }, + }; + + await (await database.ReplaceAllAsync(replacements)).Should().BeEqualTo(2); + await (await database.ReplaceAllAsync(replacements)).Should().BeEqualTo(2); + var saved = await database.QueryAsync($"SELECT * FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%' ORDER BY Id"); + + await saved.Count.Should().BeEqualTo(2); + await saved[0].Value.Should().BeEqualTo($"{prefix}-updated"); + await saved[1].Value.Should().BeEqualTo($"{prefix}-new-value"); + await (await database.ReplaceAllAsync(Array.Empty())).Should().BeEqualTo(0); + } + finally + { + await database.ExecuteAsync($"DELETE FROM SQLiteHelperTestRows WHERE Id LIKE '{prefix}%'"); + } + } + + [Test] + public async Task ReplaceAllAsync_ShouldRollBackReplacementsAndInsertsWhenALaterRowFails() + { + var database = Database.Value; + var prefix = Guid.NewGuid().ToString("N"); + var existing = new ReplacementRow { Id = $"{prefix}-existing", Value = "original" }; + try + { + await database.InsertAsync(existing); + SQLiteException? error = null; + try + { + await database.ReplaceAllAsync(new[] + { + new ReplacementRow { Id = existing.Id, Value = "replacement" }, + new ReplacementRow { Id = $"{prefix}-new", Value = "inserted" }, + new ReplacementRow { Id = $"{prefix}-invalid", Value = null! }, + }); + } + catch (SQLiteException exception) + { + error = exception; + } + + await error.Should().NotBeNull(); + var saved = await database.QueryAsync($"SELECT * FROM SQLiteHelperReplaceAllTestRows WHERE Id LIKE '{prefix}%'"); + await saved.Count.Should().BeEqualTo(1); + await saved[0].Id.Should().BeEqualTo(existing.Id); + await saved[0].Value.Should().BeEqualTo("original"); + } + finally + { + await database.ExecuteAsync($"DELETE FROM SQLiteHelperReplaceAllTestRows WHERE Id LIKE '{prefix}%'"); + } + } + + [Table("SQLiteHelperTestRows")] + public class HelperRow + { + [PrimaryKey] + public string Id { get; set; } = string.Empty; + + [Unique] + public string Value { get; set; } = string.Empty; + } + + [Table("SQLiteHelperDeleteAllTestRows")] + public class DeleteAllRow + { + [PrimaryKey] + public string Id { get; set; } = string.Empty; + } + + [Table("SQLiteHelperReplaceAllTestRows")] + public class ReplacementRow + { + [PrimaryKey] + public string Id { get; set; } = string.Empty; + + [NotNull] + public string Value { get; set; } = string.Empty; + } + + [Table("SQLiteHelperNoPrimaryKeyTestRows")] + public class NoPrimaryKeyRow + { + public string Value { get; set; } = string.Empty; + } +} diff --git a/v2rayN/ServiceLib.Tests/ViewModels/ClashProxiesViewModelTests.cs b/v2rayN/ServiceLib.Tests/ViewModels/ClashProxiesViewModelTests.cs index 156c5961..0c5c40fe 100644 --- a/v2rayN/ServiceLib.Tests/ViewModels/ClashProxiesViewModelTests.cs +++ b/v2rayN/ServiceLib.Tests/ViewModels/ClashProxiesViewModelTests.cs @@ -96,7 +96,6 @@ public class ClashProxiesViewModelTests public async ValueTask DisposeAsync() { await _cancellation.CancelAsync(); - _listener.Stop(); try { await _serverTask; @@ -106,6 +105,7 @@ public class ClashProxiesViewModelTests } finally { + _listener.Stop(); _cancellation.Dispose(); } } diff --git a/v2rayN/ServiceLib/Common/JsonUtils.cs b/v2rayN/ServiceLib/Common/JsonUtils.cs index f77a154c..b333be93 100644 --- a/v2rayN/ServiceLib/Common/JsonUtils.cs +++ b/v2rayN/ServiceLib/Common/JsonUtils.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization.Metadata; + namespace ServiceLib.Common; public class JsonUtils @@ -77,7 +79,7 @@ public class JsonUtils { return default; } - return JsonSerializer.Deserialize(strJson, _defaultDeserializeOptions); + return JsonSerializer.Deserialize(strJson, (JsonTypeInfo)_defaultDeserializeOptions.GetTypeInfo(typeof(T))); } catch { @@ -130,7 +132,7 @@ public class JsonUtils (false, true) => _defaultSerializeOptions, _ => _defaultSerializeNoIndentedOptions }; - result = JsonSerializer.Serialize(obj, options); + result = JsonSerializer.Serialize(obj, (JsonTypeInfo)options.GetTypeInfo(typeof(T))); } catch (Exception ex) { @@ -161,7 +163,7 @@ public class JsonUtils PropertyNamingPolicy = JsonNamingPolicy.CamelCase, TypeInfoResolver = AppJsonContext.Default, }; - result = JsonSerializer.Serialize(obj, options); + result = JsonSerializer.Serialize(obj, (JsonTypeInfo)options.GetTypeInfo(typeof(T))); } catch (Exception ex) { @@ -179,7 +181,7 @@ public class JsonUtils public static JsonNode? SerializeToNode(T? obj, bool nullValue = false) { var options = nullValue ? _nullValueSerializeOptions : _defaultSerializeOptions; - return JsonSerializer.SerializeToNode(obj, options); + return JsonSerializer.SerializeToNode(obj, (JsonTypeInfo)options.GetTypeInfo(typeof(T))); } public static JsonArray? SerializeToArray(IEnumerable? values) @@ -191,7 +193,7 @@ public class JsonUtils var array = new JsonArray(); foreach (var value in values) { - var valueNode = JsonSerializer.SerializeToNode(value, _defaultSerializeOptions); + var valueNode = JsonSerializer.SerializeToNode(value, (JsonTypeInfo)_defaultSerializeOptions.GetTypeInfo(typeof(string))); array.Add(valueNode); } return array; diff --git a/v2rayN/ServiceLib/Helper/SqliteHelper.cs b/v2rayN/ServiceLib/Helper/SqliteHelper.cs index c57b70fb..0cead8e3 100644 --- a/v2rayN/ServiceLib/Helper/SqliteHelper.cs +++ b/v2rayN/ServiceLib/Helper/SqliteHelper.cs @@ -1,4 +1,4 @@ -using System.Collections; +using System.Diagnostics.CodeAnalysis; namespace ServiceLib.Helper; @@ -18,47 +18,73 @@ public sealed class SQLiteHelper _dbAsync = new SQLiteAsyncConnection(_connstr, false); } - public CreateTableResult CreateTable() + public CreateTableResult CreateTable<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() { return _db.CreateTable(); } - public async Task InsertAllAsync(IEnumerable models) + public async Task InsertAllAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(IEnumerable models) { - return await _dbAsync.InsertAllAsync(models, runInTransaction: true).ConfigureAwait(false); + var count = 0; + await _dbAsync.RunInTransactionAsync(connection => + { + foreach (var model in models) + { + count += connection.Insert(model, typeof(T)); + } + }).ConfigureAwait(false); + return count; } - public async Task ReplaceAllAsync(IEnumerable models) + public async Task ReplaceAllAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(IEnumerable models) { - return await _dbAsync.InsertAllAsync(models, extra: "OR REPLACE", runInTransaction: true).ConfigureAwait(false); + var count = 0; + await _dbAsync.RunInTransactionAsync(connection => + { + foreach (var model in models) + { + count += connection.InsertOrReplace(model, typeof(T)); + } + }).ConfigureAwait(false); + return count; } - public async Task InsertAsync(object model) + public async Task InsertAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(T model) { - return await _dbAsync.InsertAsync(model); + return await _dbAsync.InsertAsync(model, typeof(T)); } - public async Task ReplaceAsync(object model) + public async Task ReplaceAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(T model) { - return await _dbAsync.InsertOrReplaceAsync(model); + return await _dbAsync.InsertOrReplaceAsync(model, typeof(T)); } - public async Task UpdateAsync(object model) + public async Task UpdateAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(T model) { - return await _dbAsync.UpdateAsync(model); + return await _dbAsync.UpdateAsync(model, typeof(T)); } - public async Task UpdateAllAsync(IEnumerable models) + public async Task UpdateAllAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(IEnumerable models) { - return await _dbAsync.UpdateAllAsync(models, runInTransaction: true).ConfigureAwait(false); + var count = 0; + await _dbAsync.RunInTransactionAsync(connection => + { + foreach (var model in models) + { + count += connection.Update(model, typeof(T)); + } + }).ConfigureAwait(false); + return count; } - public async Task DeleteAsync(object model) + public async Task DeleteAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(T model) { - return await _dbAsync.DeleteAsync(model); + var mapping = await _dbAsync.GetMappingAsync(typeof(T)); + var primaryKey = mapping.PK ?? throw new NotSupportedException($"Cannot delete {mapping.TableName}: it has no PK"); + return await _dbAsync.DeleteAsync(primaryKey.GetValue(model), mapping); } - public async Task DeleteAllAsync() + public async Task DeleteAllAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() { return await _dbAsync.DeleteAllAsync(); } @@ -68,12 +94,12 @@ public sealed class SQLiteHelper return await _dbAsync.ExecuteAsync(sql); } - public async Task> QueryAsync(string sql) where T : new() + public async Task> QueryAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(string sql) where T : new() { return await _dbAsync.QueryAsync(sql); } - public AsyncTableQuery TableAsync() where T : new() + public AsyncTableQuery TableAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() where T : new() { return _dbAsync.Table(); }