Files
WebDrop/server/tests/WebDrop.Api.Tests/BackupTests.cs
T
justme 72a5e5b839 feat(server): split file storage into a separate file service with tickets, receipts and WebDAV backup
- new projects WebDrop.FileApi (file service), WebDrop.FileProtocol (shared tickets and receipts) and WebDrop.Sync (WebDAV)
- file content of any size goes to the file service; chat, avatars and space structure stay on the main backend
- tickets issued by the main backend, receipts by the file service, deletion queue and daily reconciliation
- daily database snapshot and file sync to external storage (OpenList/WebDAV) with a 30 day retention
- reorganize Core, Storage and Api into feature folders (namespaces unchanged)
- web client uploads file frames to the file service and reads them with tickets
2026-10-17 15:25:16 +08:00

72 lines
3.1 KiB
C#

using System.IO.Compression;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
/// <summary>主后端每天把数据库快照压缩后传到外部存储(WebDAV),按保留期清理旧的。</summary>
public sealed class BackupTests : IAsyncLifetime
{
private readonly FakeWebDav _dav = new();
private ServerFixture _server = null!;
public async Task InitializeAsync()
{
await _dav.StartAsync();
_server = ServerFixture.WithSettings(new()
{
["WebDrop:Backup:WebDav:Url"] = _dav.Url,
["WebDrop:Backup:WebDav:Username"] = FakeWebDav.User,
["WebDrop:Backup:WebDav:Password"] = FakeWebDav.Password,
["WebDrop:Backup:WebDav:BasePath"] = "webdrop",
["WebDrop:Backup:RetainDays"] = "30",
});
await _server.InitializeAsync();
}
public async Task DisposeAsync()
{
await _server.DisposeAsync();
await _dav.DisposeAsync();
}
[Fact]
public async Task TheSnapshotIsAConsistentCompressedDatabaseAndOldOnesAreRetired()
{
var ch = await _server.CreateAsync();
var worker = _server.App.Services.GetServices<Microsoft.Extensions.Hosting.IHostedService>().OfType<WebDrop.Api.BackupWorker>().Single();
// 40 天前做的一份,和今天的一份
var old = await worker.RunOnceAsync(DateTime.Now.AddDays(-40), CancellationToken.None);
Assert.Equal(0, old.Purged);
Assert.True(_dav.Files.ContainsKey($"webdrop/chat/{old.Name}"));
var today = await worker.RunOnceAsync(DateTime.Now, CancellationToken.None);
Assert.Equal(1, today.Purged); // 超过 30 天的那份被清掉
Assert.False(_dav.Files.ContainsKey($"webdrop/chat/{old.Name}"));
Assert.True(_dav.Files.ContainsKey($"webdrop/chat/{today.Name}"));
// 备份能还原成数据库,里面有刚建的频道和账号
string path = Path.Combine(Path.GetTempPath(), "restore-" + Guid.NewGuid().ToString("N") + ".db");
try
{
using (var input = new MemoryStream(_dav.Files[$"webdrop/chat/{today.Name}"]))
using (var zip = new GZipStream(input, CompressionMode.Decompress))
using (var output = File.Create(path))
zip.CopyTo(output);
using var conn = new SqliteConnection($"Data Source={path};Mode=ReadOnly");
conn.Open();
using var count = conn.CreateCommand();
count.CommandText = "SELECT (SELECT COUNT(*) FROM channels), (SELECT COUNT(*) FROM accounts)";
using var reader = count.ExecuteReader();
Assert.True(reader.Read());
Assert.True(reader.GetInt64(0) >= 1 && reader.GetInt64(1) >= 2);
}
finally { SqliteConnection.ClearAllPools(); try { File.Delete(path); } catch (IOException) { } }
// 本地不留临时文件
Assert.Empty(Directory.EnumerateFiles(Path.Combine(_server.DataDir, "v2", "backup-tmp")));
}
}