Introduced Newtonsoft.Json-backed persistence. Improved backing-store reliability and serialization across the caching stack. Improved networking security posture. Key changes: - Add new GameCult.Caching.NewtonsoftJson project with SingleFile/MultiFile backing stores and polymorphic known-type envelopes. - Add MessagePack helper serialization (DatabaseEntrySerialization) and update MessagePack backing stores to use the shared options/resolver; add DatabaseLink formatter and generator/analyzer docs. - Update CultLib.sln to include the new projects and new caching tests project. - Improve CultCache persistence behavior: track pending store operations, wait for operations on PullAll, and centralize async push handling with error logging. - Harden file I/O in multi-file/single-file stores: atomic temp-file writes, safe Replace/Move semantics, rename-safe deletes, and improved file-watcher subscription and debounce disposal. - Add README files and documentation for several subprojects and packaging projects. These changes add JSON persistence support, improve serialization consistency, and make on-disk operations more robust and observable.
262 lines
9.3 KiB
C#
262 lines
9.3 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using GameCult.Caching.NewtonsoftJson;
|
|
using Newtonsoft.Json;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Caching.Tests
|
|
{
|
|
public class BackingStoreTests
|
|
{
|
|
[Test]
|
|
public async Task MultiFileBackingStore_Rename_RemovesStaleFile()
|
|
{
|
|
var rootPath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(rootPath);
|
|
|
|
try
|
|
{
|
|
var store = new MultiFileNewtonsoftJsonBackingStore(rootPath);
|
|
store.RegisterType<NamedTestEntry>();
|
|
var cache = new CultCache();
|
|
cache.AddBackingStore(store);
|
|
|
|
var entry = new NamedTestEntry
|
|
{
|
|
ID = Guid.NewGuid(),
|
|
Name = "OriginalName",
|
|
Value = "value"
|
|
};
|
|
|
|
await cache.AddAsync(entry);
|
|
|
|
entry.Name = "RenamedUser";
|
|
await cache.AddAsync(entry);
|
|
|
|
var files = Directory.GetFiles(rootPath, "*.json", SearchOption.AllDirectories)
|
|
.Select(Path.GetFileName)
|
|
.ToArray();
|
|
|
|
Assert.That(files, Does.Contain("RenamedUser.json"));
|
|
Assert.That(files, Does.Not.Contain("OriginalName.json"));
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(rootPath))
|
|
{
|
|
Directory.Delete(rootPath, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task MultiFileNewtonsoftJsonBackingStore_RoundTrips_DiscoveredEntryType()
|
|
{
|
|
var rootPath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(rootPath);
|
|
|
|
try
|
|
{
|
|
var writeStore = new MultiFileNewtonsoftJsonBackingStore(rootPath);
|
|
writeStore.RegisterType<NamedTestEntry>();
|
|
var writeCache = new CultCache();
|
|
writeCache.AddBackingStore(writeStore);
|
|
|
|
var entry = new NamedTestEntry
|
|
{
|
|
ID = Guid.NewGuid(),
|
|
Name = "RoundTrip",
|
|
Value = "payload"
|
|
};
|
|
|
|
await writeCache.AddAsync(entry);
|
|
|
|
var readStore = new MultiFileNewtonsoftJsonBackingStore(rootPath);
|
|
readStore.RegisterType<NamedTestEntry>();
|
|
var readCache = new CultCache();
|
|
readCache.AddBackingStore(readStore);
|
|
await readCache.PullAllBackingStoresAsync();
|
|
|
|
var loaded = readCache.Get(entry.ID);
|
|
|
|
Assert.That(loaded, Is.Not.Null);
|
|
Assert.That(GetEntryValue(loaded!), Is.EqualTo("payload"));
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(rootPath))
|
|
{
|
|
Directory.Delete(rootPath, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task SingleFileNewtonsoftJsonBackingStore_RoundTrips_DiscoveredEntryType()
|
|
{
|
|
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.json");
|
|
|
|
try
|
|
{
|
|
var writeStore = new SingleFileNewtonsoftJsonBackingStore(filePath);
|
|
writeStore.RegisterType<NamedTestEntry>();
|
|
var writeCache = new CultCache();
|
|
writeCache.AddBackingStore(writeStore);
|
|
|
|
var entry = new NamedTestEntry
|
|
{
|
|
ID = Guid.NewGuid(),
|
|
Name = "SingleFile",
|
|
Value = "payload"
|
|
};
|
|
|
|
await writeCache.AddAsync(entry);
|
|
writeStore.PushAll();
|
|
|
|
var readStore = new SingleFileNewtonsoftJsonBackingStore(filePath);
|
|
readStore.RegisterType<NamedTestEntry>();
|
|
var readCache = new CultCache();
|
|
readCache.AddBackingStore(readStore);
|
|
await readCache.PullAllBackingStoresAsync();
|
|
|
|
var loaded = readCache.Get(entry.ID);
|
|
|
|
Assert.That(loaded, Is.Not.Null);
|
|
Assert.That(GetEntryValue(loaded!), Is.EqualTo("payload"));
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(filePath))
|
|
{
|
|
File.Delete(filePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void MultiFileNewtonsoftJsonBackingStore_Rejects_UnknownDiscriminator()
|
|
{
|
|
var rootPath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(rootPath);
|
|
|
|
try
|
|
{
|
|
var store = new MultiFileNewtonsoftJsonBackingStore(rootPath);
|
|
store.RegisterType<NamedTestEntry>();
|
|
var payload = """
|
|
{
|
|
"Type": "Unknown.Assembly:Unknown.Type",
|
|
"Data": {
|
|
"ID": "00000000-0000-0000-0000-000000000001",
|
|
"Name": "Bad",
|
|
"Value": "Bad"
|
|
}
|
|
}
|
|
""";
|
|
|
|
Assert.That(
|
|
() => store.Deserialize(Encoding.UTF8.GetBytes(payload)),
|
|
Throws.TypeOf<JsonSerializationException>().With.Message.Contains("Unknown DatabaseEntry discriminator"));
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(rootPath))
|
|
{
|
|
Directory.Delete(rootPath, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task MultiFileNewtonsoftJsonBackingStore_Delete_Removes_File()
|
|
{
|
|
var rootPath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(rootPath);
|
|
|
|
try
|
|
{
|
|
var store = new MultiFileNewtonsoftJsonBackingStore(rootPath);
|
|
store.RegisterType<NamedTestEntry>();
|
|
var cache = new CultCache();
|
|
cache.AddBackingStore(store);
|
|
|
|
var entry = new NamedTestEntry
|
|
{
|
|
ID = Guid.NewGuid(),
|
|
Name = "DeleteMe",
|
|
Value = "payload"
|
|
};
|
|
|
|
await cache.AddAsync(entry);
|
|
cache.Remove(entry);
|
|
|
|
var files = Directory.GetFiles(rootPath, "*.json", SearchOption.AllDirectories)
|
|
.Select(Path.GetFileName)
|
|
.ToArray();
|
|
|
|
Assert.That(files, Does.Not.Contain("DeleteMe.json"));
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(rootPath))
|
|
{
|
|
Directory.Delete(rootPath, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void MessagePackDatabaseEntrySerialization_RoundTrips_DatabaseLink()
|
|
{
|
|
var serializationType = typeof(MultiFileMessagePackBackingStore).Assembly
|
|
.GetType("GameCult.Caching.MessagePack.DatabaseEntrySerialization", throwOnError: true)!;
|
|
var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)!
|
|
.MakeGenericMethod(typeof(DatabaseLink<DatabaseEntry>));
|
|
var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)!
|
|
.MakeGenericMethod(typeof(DatabaseLink<DatabaseEntry>));
|
|
var link = new DatabaseLink<DatabaseEntry> { LinkID = Guid.NewGuid() };
|
|
|
|
var payload = (byte[])serialize.Invoke(null, [link])!;
|
|
var roundTrip = (DatabaseLink<DatabaseEntry>)deserialize.Invoke(null, [payload])!;
|
|
|
|
Assert.That(roundTrip.LinkID, Is.EqualTo(link.LinkID));
|
|
}
|
|
|
|
[Test]
|
|
public void MessagePackDatabaseEntrySerialization_Rejects_InvalidPayload()
|
|
{
|
|
var serializationType = typeof(MultiFileMessagePackBackingStore).Assembly
|
|
.GetType("GameCult.Caching.MessagePack.DatabaseEntrySerialization", throwOnError: true)!;
|
|
var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)!
|
|
.MakeGenericMethod(typeof(DatabaseEntry));
|
|
|
|
Assert.That(
|
|
() => deserialize.Invoke(null, [new byte[] { 0xC1 }]),
|
|
Throws.TypeOf<TargetInvocationException>()
|
|
.With.InnerException.InstanceOf<global::MessagePack.MessagePackSerializationException>());
|
|
}
|
|
|
|
private sealed class NamedTestEntry : DatabaseEntry, INamedEntry
|
|
{
|
|
public string Name = string.Empty;
|
|
public string Value = string.Empty;
|
|
|
|
public string EntryName
|
|
{
|
|
get => Name;
|
|
set => Name = value;
|
|
}
|
|
}
|
|
|
|
private static string GetEntryValue(DatabaseEntry entry)
|
|
{
|
|
return entry.GetType().GetField("Value")?.GetValue(entry) as string ?? string.Empty;
|
|
}
|
|
}
|
|
}
|