Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions ConvertApi.Tests/ConvertApi.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>ConvertApiDotNet.Tests</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit.Analyzers" Version="4.15.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.3.1" />
</ItemGroup>

<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ConvertApi\ConvertApi.csproj" />
</ItemGroup>

</Project>
134 changes: 134 additions & 0 deletions ConvertApi.Tests/SaveFileTests.cs
Original file line number Diff line number Diff line change
@@ -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<Uri, byte[]>();
var results = new List<ConvertApiFile>();
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<Uri, byte[]> files) : IConvertApiHttpClient
{
public HttpClient Client { get; } = new(new FileServer(files));
}

private sealed class FileServer(Dictionary<Uri, byte[]> files) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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) });
}
}
}
6 changes: 6 additions & 0 deletions ConvertApi.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion ConvertApi/ConvertApiExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,28 @@ private static async Task<FileInfo> 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);
}

return fileInfo;
}

#region File Task Methods
/// <summary>
/// Save the first result file as <paramref name="fileName"/>, replacing the file if it exists.
/// </summary>
public static async Task<FileInfo> SaveFileAsync(this ConvertApiResponse response, string fileName)
{
return await response.Files[0].SaveFileAsync(fileName);
}

/// <summary>
/// Save every result file into <paramref name="outputDirectory"/> under its own name, replacing files
/// that exist. The folder is created if it does not exist.
/// </summary>
public static async Task<List<FileInfo>> SaveFilesAsync(this ConvertApiResponse response, string outputDirectory)
{
return await response.Files.SaveFilesAsync(outputDirectory);
Expand All @@ -56,13 +65,24 @@ public static async Task<Stream> FileStreamAsync(this ConvertApiFile processedFi
return await AsStreamAsync(processedFile.Url);
}

/// <summary>
/// Save the file as <paramref name="fileName"/>, replacing the file if it exists.
/// </summary>
public static async Task<FileInfo> SaveFileAsync(this ConvertApiFile processedFile, string fileName)
{
return await SaveFileAsync(processedFile.Url, fileName);
}

/// <summary>
/// Save the files into <paramref name="outputDirectory"/>, each under its own name, replacing files that
/// exist. The folder is created if it does not exist.
/// </summary>
public static async Task<List<FileInfo>> SaveFilesAsync(this IEnumerable<ConvertApiFile> 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<FileInfo>();
foreach (var file in processedFile)
{
Expand Down
2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
Loading