using System.Text; using Xunit; namespace XFESystem.Documents.Tests; public sealed class DocumentServiceTests { [Fact] public async Task OpenEditAndSaveUsesTheFilesystemAdapterAndTracksRevision() { var store = new MemoryStore("# Original"); var service = new DocumentService(store); var session = await service.OpenAsync(new("X:\\Users\\Default\\Documents\\Notes.md")); Assert.Equal("markdown", session.HighlightLanguage); Assert.False(session.IsModified); session.Text = "# Updated 中文"; Assert.True(session.IsModified); await service.SaveAsync(session); Assert.Equal("# Updated 中文", Encoding.UTF8.GetString(store.Content)); Assert.Equal("2", session.Revision); Assert.False(session.IsModified); } [Fact] public async Task StaleDocumentSaveDoesNotOverwriteAnotherEditorsChange() { var store = new MemoryStore("Original"); var service = new DocumentService(store); var first = await service.OpenAsync(new("X:\\Users\\Default\\Documents\\Notes.txt")); var second = await service.OpenAsync(first.Location); first.Text = "First editor"; await service.SaveAsync(first); second.Text = "Second editor"; await Assert.ThrowsAsync(async () => await service.SaveAsync(second)); Assert.True(second.IsModified); Assert.Equal("First editor", Encoding.UTF8.GetString(store.Content)); } [Fact] public async Task EditsDuringSaveRemainDirty() { var store = new MemoryStore("Original"); var service = new DocumentService(store); var session = await service.OpenAsync(new("X:\\Users\\Default\\Documents\\Notes.txt")); session.Text = "Snapshot"; store.DuringSave = () => session.Text = "New edit"; await service.SaveAsync(session); Assert.Equal("Snapshot", Encoding.UTF8.GetString(store.Content)); Assert.Equal("New edit", session.Text); Assert.True(session.IsModified); } [Fact] public async Task ReadOnlyFilesRejectSaveWithoutWriting() { var store = new MemoryStore("Read only") { ReadOnly = true }; var service = new DocumentService(store); var session = await service.OpenAsync(new("X:\\System\\Welcome.txt")); session.Text = "Change"; await Assert.ThrowsAsync(async () => await service.SaveAsync(session)); Assert.Equal(0, store.Writes); } [Fact] public async Task InvalidUtf8AndBinaryFilesAreRejected() { var store = new MemoryStore("Text") { Content = [0xC3, 0x28] }; var service = new DocumentService(store); await Assert.ThrowsAsync(async () => await service.OpenAsync(new("X:\\Users\\Default\\Documents\\Bad.txt"))); store.Content = [65, 0, 66]; await Assert.ThrowsAsync(async () => await service.OpenAsync(new("X:\\Users\\Default\\Documents\\Binary.txt"))); } [Fact] public async Task Utf8BomIsRecognizedWithoutBecomingDocumentText() { var store = new MemoryStore("Text") { Content = [0xEF, 0xBB, 0xBF, 65, 66] }; Assert.Equal("AB", (await new DocumentService(store).OpenAsync(new("X:\\Users\\Default\\Documents\\Bom.txt"))).Text); } [Fact] public async Task ReadAndSaveLimitsCountUtf8Bytes() { var store = new MemoryStore("Text"); var service = new DocumentService(store, 5); var session = await service.OpenAsync(new("X:\\Users\\Default\\Documents\\Limit.txt")); session.Text = "中文"; await Assert.ThrowsAsync(async () => await service.SaveAsync(session)); Assert.Equal(0, store.Writes); store.Content = new byte[6]; await Assert.ThrowsAsync(async () => await service.OpenAsync(session.Location)); } [Theory] [InlineData("C:/Windows/Secret.txt")] [InlineData("/etc/passwd")] [InlineData("X:\\Users\\..\\System\\Secret.txt")] [InlineData("X:\\Users\\Secret.")] [InlineData("X:\\Users\\Secret.txt:Stream")] [InlineData("X:\\Users/Secret.txt")] [InlineData("X:\\Users\\Default\\Documents\\Secret\n.txt")] public void PathsCannotAddressHostFilesOrTraverseDirectories(string path) => Assert.Throws(() => new DocumentLocation(path)); private sealed class MemoryStore(string text) : IDocumentStorage { public byte[] Content { get; set; } = Encoding.UTF8.GetBytes(text); public bool ReadOnly { get; init; } public int Writes { get; private set; } public Action? DuringSave { get; set; } private int _revision = 1; public ValueTask ReadAsync(DocumentLocation location, int maximumBytes, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return ValueTask.FromResult(new StoredDocument(Content.ToArray(), _revision.ToString(), ReadOnly)); } public ValueTask ReplaceAsync(DocumentLocation location, ReadOnlyMemory content, string expectedRevision, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (expectedRevision != _revision.ToString()) throw new DocumentConflictException("File changed."); DuringSave?.Invoke(); Content = content.ToArray(); Writes++; return ValueTask.FromResult((++_revision).ToString()); } } }