v9.10.2019.0101  全面巩固批量Insert/Update/Upsert,支持数据备份、恢复和同步,支持实体列表保存到文件以及加载
大石头 authored at 2019-01-01 13:38:33
5.80 KiB
X
using NewLife;
using NewLife.Configuration;
using Xunit;

namespace XUnitTest.Configuration;

/// <summary>FileConfigProvider 原子写入相关测试</summary>
/// <remarks>
/// 写入中途异常不留下半截文件、空内容不覆盖原文件、双边 trim 比较生效、
/// 并发 SaveAll 不出现交叉写入。
/// </remarks>
public class FileConfigProviderAtomicWriteTests
{
    /// <summary>故障注入:tmp 写完后立即抛异常,模拟进程在原子替换前崩溃</summary>
    private sealed class FaultyJsonConfigProvider : JsonConfigProvider
    {
        protected override void OnWrite(String fileName, IConfigSection section)
        {
            // 模拟基类原子写入的前半段:tmp 文件已经落盘(甚至内容是错的),
            // 但还没来得及 File.Replace 时进程崩溃
            var tmp = fileName + ".tmp";
            File.WriteAllText(tmp, "broken half content");
            throw new IOException("simulated crash mid-write");
        }
    }

    /// <summary>覆盖:序列化退化为空字符串时不修改目标文件</summary>
    private sealed class EmptyContentJsonConfigProvider : JsonConfigProvider
    {
        public override String GetString(IConfigSection? section = null) => "";
    }

    [Fact]
    public void AtomicWrite_ProcessCrashMidWrite_KeepsOldContent()
    {
        // 先写一份正常内容打底
        var fileName = "Config/atomic_crash.json";
        var prv = new JsonConfigProvider { FileName = fileName };
        var model = new AtomicTestModel { Name = "original", Count = 1 };
        prv.Save(model);

        var fullPath = fileName.GetBasePath();
        var oldContent = File.ReadAllText(fullPath);
        Assert.Contains("original", oldContent);

        // 切换为故障 provider,模拟写入中途崩溃
        var faulty = new FaultyJsonConfigProvider { FileName = fileName };
        Assert.Throws<IOException>(() => faulty.Save(new AtomicTestModel { Name = "new", Count = 2 }));

        // 关键断言:目标文件保持旧内容,不为空、不被截断
        var afterCrash = File.ReadAllText(fullPath);
        Assert.Equal(oldContent, afterCrash);
        Assert.Contains("original", afterCrash);

        // 清理可能残留的 tmp(不影响断言,仅避免污染下次运行)
        var tmp = fullPath + ".tmp";
        if (File.Exists(tmp)) File.Delete(tmp);
    }

    [Fact]
    public void AtomicWrite_EmptyContent_DoesNotOverwrite()
    {
        // 先写一份正常内容打底
        var fileName = "Config/atomic_empty.json";
        var prv = new JsonConfigProvider { FileName = fileName };
        prv.Save(new AtomicTestModel { Name = "keep-me", Count = 42 });

        var fullPath = fileName.GetBasePath();
        var oldContent = File.ReadAllText(fullPath);
        var oldLastWrite = File.GetLastWriteTimeUtc(fullPath);

        // 切换为返回空字符串的 provider
        var emptyPrv = new EmptyContentJsonConfigProvider { FileName = fileName };
        emptyPrv.Save(new AtomicTestModel { Name = "should-not-overwrite", Count = 0 });

        // 关键断言:文件未被覆盖
        var afterEmpty = File.ReadAllText(fullPath);
        Assert.Equal(oldContent, afterEmpty);
        Assert.Contains("keep-me", afterEmpty);
        Assert.Equal(oldLastWrite, File.GetLastWriteTimeUtc(fullPath));
    }

    [Fact]
    public void AtomicWrite_TrailingWhitespaceOnly_NoActualWrite()
    {
        // 写一份初始内容
        var fileName = "Config/atomic_trim.json";
        var prv = new JsonConfigProvider { FileName = fileName };
        prv.Save(new AtomicTestModel { Name = "stable", Count = 7 });

        var fullPath = fileName.GetBasePath();
        var oldLastWrite = File.GetLastWriteTimeUtc(fullPath);

        // 使用保守延迟,尽量跨过不同文件系统上可能较粗的 mtime 粒度,
        // 避免“实际发生写入但时间戳未明显变化”导致的误判。
        Thread.Sleep(2500);

        // 用同一份模型再保存一次。GetString 输出与磁盘内容仅在末尾换行可能不同,
        // 双边 trim 后应判定为"无变化",不触发实际写入
        var prv2 = new JsonConfigProvider { FileName = fileName };
        prv2.Save(new AtomicTestModel { Name = "stable", Count = 7 });

        var newLastWrite = File.GetLastWriteTimeUtc(fullPath);
        Assert.Equal(oldLastWrite, newLastWrite);
    }

    [Fact]
    public void AtomicWrite_ConcurrentSaveAll_NoCorruption()
    {
        var fileName = "Config/atomic_concurrent.json";
        var prv = new JsonConfigProvider { FileName = fileName };
        // 初始化一份,确保后续 SaveAll 走"目标存在"分支
        prv.Save(new AtomicTestModel { Name = "init", Count = 0 });

        // 并发 SaveAll:让 N 个线程同时改 Root 并保存。
        // 共享同一把锁的前提下,磁盘上不会出现交叉写入或半截文件
        const Int32 threads = 8;
        const Int32 iterationsPerThread = 20;
        Parallel.For(0, threads, t =>
        {
            for (var i = 0; i < iterationsPerThread; i++)
            {
                prv.Save(new AtomicTestModel { Name = $"t{t}", Count = i });
            }
        });

        // 写入全部完成后,文件必须可解析为完整的 JSON 配置
        var fullPath = fileName.GetBasePath();
        var content = File.ReadAllText(fullPath);
        Assert.False(String.IsNullOrWhiteSpace(content));

        var verify = new JsonConfigProvider { FileName = fileName };
        var loaded = verify.Load<AtomicTestModel>();
        Assert.NotNull(loaded);
        Assert.False(String.IsNullOrEmpty(loaded.Name));
    }

    /// <summary>仅供测试使用的最小配置模型</summary>
    public class AtomicTestModel
    {
        public String Name { get; set; } = "";
        public Int32 Count { get; set; }
    }
}