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<DocumentConflictException>(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<UnauthorizedAccessException>(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<DecoderFallbackException>(async () => await service.OpenAsync(new("X:\\Users\\Default\\Documents\\Bad.txt")));
store.Content = [65, 0, 66];
await Assert.ThrowsAsync<IOException>(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<IOException>(async () => await service.SaveAsync(session));
Assert.Equal(0, store.Writes);
store.Content = new byte[6];
await Assert.ThrowsAsync<IOException>(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<ArgumentException>(() => 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<StoredDocument> ReadAsync(DocumentLocation location, int maximumBytes, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return ValueTask.FromResult(new StoredDocument(Content.ToArray(), _revision.ToString(), ReadOnly));
}
public ValueTask<string> ReplaceAsync(DocumentLocation location, ReadOnlyMemory<byte> 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());
}
}
}
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<DocumentConflictException>(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<UnauthorizedAccessException>(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<DecoderFallbackException>(async () => await service.OpenAsync(new("X:\\Users\\Default\\Documents\\Bad.txt")));
store.Content = [65, 0, 66];
await Assert.ThrowsAsync<IOException>(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<IOException>(async () => await service.SaveAsync(session));
Assert.Equal(0, store.Writes);
store.Content = new byte[6];
await Assert.ThrowsAsync<IOException>(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<ArgumentException>(() => 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<StoredDocument> ReadAsync(DocumentLocation location, int maximumBytes, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return ValueTask.FromResult(new StoredDocument(Content.ToArray(), _revision.ToString(), ReadOnly));
}
public ValueTask<string> ReplaceAsync(DocumentLocation location, ReadOnlyMemory<byte> 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());
}
}
}