diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e91ef78..c180355 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,10 +4,11 @@ run-name: >- || format('Building {0}', github.ref_name) }} # Trunk-based: main is the only long-lived branch. Every pull request and every push to main builds the -# whole solution (the library and all Examples) and packs the library, but nothing is published here: -# this is a public NuGet package, and NuGet versions are permanent, so a release is a deliberate act - -# publishing a GitHub Release v{semver}, which runs publish.yml. There is no test suite, so a clean build -# and pack is the check that gates the merge (the required status check is this job's name, `build`). +# whole solution (the library, its unit tests and all Examples), runs the unit tests and packs the library, +# but nothing is published here: this is a public NuGet package, and NuGet versions are permanent, so a +# release is a deliberate act - publishing a GitHub Release v{semver}, which runs publish.yml. A clean build, +# a passing test run and a pack are the check that gates the merge (the required status check is this job's +# name, `build`). on: pull_request: branches: [main] @@ -41,9 +42,13 @@ jobs: - name: Restore run: dotnet restore ConvertApi.sln - - name: Build the library and the examples + - name: Build the library, the tests and the examples run: dotnet build ConvertApi.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true + # ConvertApi.Tests serves results from a fake HTTP client, so the tests need no network and no API token. + - name: Test + run: dotnet test ConvertApi.Tests/ConvertApi.Tests.csproj -c Release --no-build + # A pre-release version that sorts below any real release and names the run, so a package # downloaded from this run can be tried out but never mistaken for a published one. - name: Pack diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f1b07..54f8c66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog and this project adheres to Semantic Versioning. +## [Unreleased] + +### Fixed +- `SaveFileAsync` and `SaveFilesAsync` replace an existing file instead of writing over its start. They + opened it with `FileInfo.OpenWrite`, which does not truncate, so a result saved over a longer file kept + that file's trailing bytes and came out corrupt: a 146,734-byte PDF saved over a 400,000-byte file stayed + 400,000 bytes. Every earlier version has this bug. + +### Changed +- `SaveFilesAsync(outputDirectory)` creates the folder if it does not exist, instead of throwing + `DirectoryNotFoundException`. An empty path still means the current directory. +- Unit tests (`ConvertApi.Tests`, NUnit) run in the `build` check of every pull request. + ## [3.2.2] — 2026-10-26 ### Changed diff --git a/ConvertApi.Tests/ConvertApi.Tests.csproj b/ConvertApi.Tests/ConvertApi.Tests.csproj new file mode 100644 index 0000000..32b4ccc --- /dev/null +++ b/ConvertApi.Tests/ConvertApi.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + ConvertApiDotNet.Tests + enable + enable + false + true + + + + + + + + + + + + + + + + + + diff --git a/ConvertApi.Tests/SaveFileTests.cs b/ConvertApi.Tests/SaveFileTests.cs new file mode 100644 index 0000000..b9420a5 --- /dev/null +++ b/ConvertApi.Tests/SaveFileTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using ConvertApiDotNet.Interface; +using ConvertApiDotNet.Model; + +namespace ConvertApiDotNet.Tests; + +// SaveFileAsync and SaveFilesAsync download through the static ConvertApi.GetClient(). Each test installs a +// fake client that serves the result files from memory, so the tests make no network calls, and they must not +// run in parallel with other tests that install a client. +[NonParallelizable] +public class SaveFileTests +{ + private string _dir = ""; + + [SetUp] + public void CreateTempDirectory() + { + _dir = Path.Combine(Path.GetTempPath(), "ConvertApi.Tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + } + + [TearDown] + public void DeleteTempDirectory() => Directory.Delete(_dir, recursive: true); + + [Test] + public async Task SaveFileAsync_ReplacesALongerExistingFile() + { + // The sizes from the report: a 146,734-byte PDF saved over a 400,000-byte file left 400,000 bytes. + var path = Path.Combine(_dir, "document.pdf"); + File.WriteAllBytes(path, Bytes(400_000, seed: 1)); + var content = Bytes(146_734, seed: 2); + var file = Serve(("document.pdf", content)).Single(); + + var saved = await file.SaveFileAsync(path); + + using (Assert.EnterMultipleScope()) + { + Assert.That(saved.FullName, Is.EqualTo(Path.GetFullPath(path))); + Assert.That(saved.Length, Is.EqualTo(content.Length)); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(content)); + } + } + + [Test] + public async Task SaveFilesAsync_ReplacesLongerExistingFiles() + { + var first = Bytes(1_000, seed: 3); + var second = Bytes(2_000, seed: 4); + File.WriteAllBytes(Path.Combine(_dir, "document.jpg"), Bytes(50_000, seed: 5)); + File.WriteAllBytes(Path.Combine(_dir, "document-2.jpg"), Bytes(50_000, seed: 6)); + var response = new ConvertApiResponse { Files = Serve(("document.jpg", first), ("document-2.jpg", second)) }; + + var saved = await response.SaveFilesAsync(_dir); + + using (Assert.EnterMultipleScope()) + { + Assert.That(saved.Select(f => f.Name), Is.EqualTo(new[] { "document.jpg", "document-2.jpg" })); + Assert.That(File.ReadAllBytes(Path.Combine(_dir, "document.jpg")), Is.EqualTo(first)); + Assert.That(File.ReadAllBytes(Path.Combine(_dir, "document-2.jpg")), Is.EqualTo(second)); + } + } + + [Test] + public async Task SaveFilesAsync_CreatesAMissingFolder() + { + var output = Path.Combine(_dir, "output", "nested"); + var content = Bytes(1_000, seed: 7); + var response = new ConvertApiResponse { Files = Serve(("document.pdf", content)) }; + + await response.SaveFilesAsync(output); + + Assert.That(File.ReadAllBytes(Path.Combine(output, "document.pdf")), Is.EqualTo(content)); + } + + [Test] + public async Task SaveFilesAsync_EmptyFolderMeansTheCurrentDirectory() + { + var content = Bytes(1_000, seed: 8); + var response = new ConvertApiResponse { Files = Serve(("document.pdf", content)) }; + + var previous = Environment.CurrentDirectory; + Environment.CurrentDirectory = _dir; + try + { + await response.SaveFilesAsync(""); + } + finally + { + Environment.CurrentDirectory = previous; + } + + Assert.That(File.ReadAllBytes(Path.Combine(_dir, "document.pdf")), Is.EqualTo(content)); + } + + private static byte[] Bytes(int length, int seed) + { + var bytes = new byte[length]; + new Random(seed).NextBytes(bytes); + return bytes; + } + + // Installs a client that serves these files, and returns them as a conversion response lists them. + private static ConvertApiFile[] Serve(params (string Name, byte[] Content)[] files) + { + var served = new Dictionary(); + var results = new List(); + foreach (var (name, content) in files) + { + var url = new Uri("https://v2.convertapi.com/d/test/" + name); + served[url] = content; + results.Add(new ConvertApiFile { FileName = name, FileSize = content.Length, Url = url }); + } + + // This constructor is the SDK's only way to replace the client that ConvertApi.GetClient() returns. + _ = new ConvertApi("test-token", new FakeClient(served)); + return results.ToArray(); + } + + private sealed class FakeClient(Dictionary files) : IConvertApiHttpClient + { + public HttpClient Client { get; } = new(new FileServer(files)); + } + + private sealed class FileServer(Dictionary files) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method != HttpMethod.Get || !files.TryGetValue(request.RequestUri!, out var content)) + throw new InvalidOperationException($"Unexpected request: {request.Method} {request.RequestUri}"); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(content) }); + } + } +} diff --git a/ConvertApi.sln b/ConvertApi.sln index f41db12..7f8a346 100644 --- a/ConvertApi.sln +++ b/ConvertApi.sln @@ -59,6 +59,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UploadOnceReuseTwice", "Exa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConverterCatalogDemo", "Examples\ConverterCatalogDemo\ConverterCatalogDemo.csproj", "{DFEC8F03-E140-488A-B457-59715605F5CA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConvertApi.Tests", "ConvertApi.Tests\ConvertApi.Tests.csproj", "{2DBDE523-FC5A-4662-A99D-94835F760E20}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -153,6 +155,10 @@ Global {DFEC8F03-E140-488A-B457-59715605F5CA}.Debug|Any CPU.Build.0 = Debug|Any CPU {DFEC8F03-E140-488A-B457-59715605F5CA}.Release|Any CPU.ActiveCfg = Release|Any CPU {DFEC8F03-E140-488A-B457-59715605F5CA}.Release|Any CPU.Build.0 = Release|Any CPU + {2DBDE523-FC5A-4662-A99D-94835F760E20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DBDE523-FC5A-4662-A99D-94835F760E20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DBDE523-FC5A-4662-A99D-94835F760E20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DBDE523-FC5A-4662-A99D-94835F760E20}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ConvertApi/ConvertApiExtension.cs b/ConvertApi/ConvertApiExtension.cs index 584ad49..92bc164 100644 --- a/ConvertApi/ConvertApiExtension.cs +++ b/ConvertApi/ConvertApiExtension.cs @@ -33,7 +33,9 @@ private static async Task SaveFileAsync(Uri url, string fileName) var fileInfo = new FileInfo(fileName); using (var readFile = await AsStreamAsync(url)) { - using (var fileStream = fileInfo.OpenWrite()) + // FileMode.Create truncates an existing file. FileInfo.OpenWrite (FileMode.OpenOrCreate) did not, + // so a result saved over a longer file kept that file's trailing bytes and came out corrupt. + using (var fileStream = new FileStream(fileInfo.FullName, FileMode.Create, FileAccess.Write, FileShare.None)) await readFile.CopyToAsync(fileStream); } @@ -41,11 +43,18 @@ private static async Task SaveFileAsync(Uri url, string fileName) } #region File Task Methods + /// + /// Save the first result file as , replacing the file if it exists. + /// public static async Task SaveFileAsync(this ConvertApiResponse response, string fileName) { return await response.Files[0].SaveFileAsync(fileName); } + /// + /// Save every result file into under its own name, replacing files + /// that exist. The folder is created if it does not exist. + /// public static async Task> SaveFilesAsync(this ConvertApiResponse response, string outputDirectory) { return await response.Files.SaveFilesAsync(outputDirectory); @@ -56,13 +65,24 @@ public static async Task FileStreamAsync(this ConvertApiFile processedFi return await AsStreamAsync(processedFile.Url); } + /// + /// Save the file as , replacing the file if it exists. + /// public static async Task SaveFileAsync(this ConvertApiFile processedFile, string fileName) { return await SaveFileAsync(processedFile.Url, fileName); } + /// + /// Save the files into , each under its own name, replacing files that + /// exist. The folder is created if it does not exist. + /// public static async Task> SaveFilesAsync(this IEnumerable processedFile, string outputDirectory) { + // An empty path still means the current directory, as Path.Combine("", name) below always did. + if (!string.IsNullOrEmpty(outputDirectory)) + Directory.CreateDirectory(outputDirectory); + var list = new List(); foreach (var file in processedFile) { diff --git a/RELEASING.md b/RELEASING.md index e73c592..47c0ceb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,7 +2,7 @@ `main` is the only long-lived branch. Changes reach it through pull requests, merge commits only, and every pull request and push to `main` must pass the `build` check (`.github/workflows/build.yml`: restore, -build the library and every example, pack). Merging publishes nothing. +build the library, its unit tests and every example, run the unit tests, pack). Merging publishes nothing. A release is a **GitHub Release** whose tag is the version, published by `tomasr78`: