Files
duplicati/Duplicati/UnitTest/DirectListHandlerTests.cs
T
Kenneth Skovhede 179d1ceb47 Make controller Async
This PR has a large blast radius because it takes the final step and bumps up the Controller to be fully async.

We have historically done a piece-by-piece update, so all operations were already async but the controller interface was kept synchronous.

With this update, the controller is now fully async and all tests are updated.

Most places where the new C# compiler warns about function names not ending in `Async` were also adressed, giving a massive refactor change.

Functionally, no changes are done.
2026-05-13 15:04:13 +02:00

860 lines
39 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Library.Main;
using Duplicati.Library.Main.Database;
using Duplicati.Library.Utility;
using NUnit.Framework;
namespace Duplicati.UnitTest
{
[TestFixture]
public class DirectListHandlerTests : BasicSetupHelper
{
[Test]
public async Task ListFilesetsAsync()
{
var options = new Dictionary<string, string>(this.TestOptions)
{
["upload-unchanged-backups"] = "true"
};
for (var i = 0; i < 3; i++)
{
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER }));
var sets = await c.ListFilesetsAsync();
Assert.That(sets.Filesets.Count(), Is.EqualTo(i + 1));
}
}
}
[Test]
public async Task ListFolderContentsAsync()
{
var options = new Dictionary<string, string>(this.TestOptions)
{
["upload-unchanged-backups"] = "true"
};
var initial = new[] {
"folder1/",
"folder2/",
"file1",
"folder1/file1.txt",
"folder1/file2.txt",
"folder1/folder1/",
"folder1/folder2/",
"folder1/folder1/file1.txt",
"folder1/folder2/file2.txt",
"folder2/folder2/"
};
var add2 = new[] {
"folder3/",
"folder1/folder1/file3.txt",
};
var add3 = new[] {
"folder1/folder1/file3.txt",
"folder1/folder1/file4.txt"
};
IEnumerable<string> rootItems(IEnumerable<string> items)
=> items.Where(x => !x.Contains('/') || x.IndexOf('/') == x.Length - 1);
void createStructure(IEnumerable<string> paths)
{
foreach (var p in paths)
{
var path = Path.Combine(this.DATAFOLDER, p.Replace("/", Path.DirectorySeparatorChar.ToString()));
if (p.EndsWith("/"))
Directory.CreateDirectory(path);
else
File.WriteAllText(path, p);
}
}
var sources = initial.Take(3).ToArray();
var rounds = new[] {
initial,
initial.Concat(add2),
initial.Concat(add3)
};
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
foreach (var round in rounds)
{
createStructure(round);
TestUtils.AssertResults(await c.BackupAsync(rootItems(round).Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray()));
}
var sets = await c.ListFilesetsAsync();
Assert.That(sets.Filesets.Count(), Is.EqualTo(rounds.Length));
}
var version = 0;
foreach (var round in rounds.Reverse())
{
using (var c = new Controller("file://" + this.TARGETFOLDER, options.Expand(new { version = version }), null))
{
var files = await c.ListFolderAsync([""], 0, 0, false);
var roots = rootItems(round).Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray();
Assert.That(files.Entries.Items.Count(), Is.EqualTo(roots.Length));
Assert.That(files.Entries.Items.Count(x => x.IsDirectory), Is.EqualTo(roots.Count(x => x.EndsWith(Path.DirectorySeparatorChar))));
Assert.That(files.Entries.Items.Count(x => !x.IsDirectory), Is.EqualTo(roots.Count(x => !x.EndsWith(Path.DirectorySeparatorChar))));
Assert.That(files.Entries.Items.Count(x => roots.Any(y => x.Path.EndsWith(y))), Is.EqualTo(roots.Length));
var work = new Queue<string>(roots);
while (work.Count > 0)
{
var path = work.Dequeue();
var files2 = await c.ListFolderAsync(new[] { Path.Combine(this.DATAFOLDER, path.Replace("/", Path.DirectorySeparatorChar.ToString())) }, 0, 0, false);
var matches = round.Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString())))
.Where(x => x.StartsWith(path) && x.Length > path.Length && !x.Substring(path.Length, x.Length - path.Length - 1).Contains(Path.DirectorySeparatorChar))
.ToArray();
Assert.That(files2.Entries.Items.Count(), Is.EqualTo(matches.Length));
Assert.That(files2.Entries.Items.Count(x => x.IsDirectory), Is.EqualTo(matches.Count(x => x.EndsWith(Path.DirectorySeparatorChar))));
Assert.That(files2.Entries.Items.Count(x => !x.IsDirectory), Is.EqualTo(matches.Count(x => !x.EndsWith(Path.DirectorySeparatorChar))));
Assert.That(files2.Entries.Items.Count(x => matches.Contains(x.Path)), Is.EqualTo(matches.Length));
foreach (var match in matches)
if (match.EndsWith("/"))
work.Enqueue(match);
}
}
version++;
}
}
[Test]
public async Task ListFileVersions_LifecycleTestAsync()
{
var options = new Dictionary<string, string>(this.TestOptions)
{
["upload-unchanged-backups"] = "true"
};
var stays = "file_stays.txt";
var modify = "file_modify.txt";
var delete = "file_delete.txt";
var create = "file_create.txt";
void createStructure(IEnumerable<string> files)
{
foreach (var f in files)
{
var path = Path.Combine(this.DATAFOLDER, f.Replace("/", Path.DirectorySeparatorChar.ToString()));
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, f + "-contents");
}
}
void modifyFile(string file, string text)
{
var path = Path.Combine(this.DATAFOLDER, file.Replace("/", Path.DirectorySeparatorChar.ToString()));
File.WriteAllText(path, text);
}
var rounds = new List<string[]> {
new[] { stays, modify, delete }, // Round 0 - initial
new[] { stays, modify, create }, // Round 1 - modify, create, delete 'delete'
new[] { stays, modify, create } // Round 2 - no changes
};
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
// Round 0 - initial
createStructure(rounds[0]);
TestUtils.AssertResults(await c.BackupAsync(rounds[0].Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray()));
// Round 1 - modify and create new file
modifyFile(modify, "modified-content");
createStructure(new[] { create });
File.Delete(Path.Combine(this.DATAFOLDER, delete.Replace("/", Path.DirectorySeparatorChar.ToString()))); // Delete file
TestUtils.AssertResults(await c.BackupAsync(rounds[1].Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray()));
// Round 2 - no changes
TestUtils.AssertResults(await c.BackupAsync(rounds[2].Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray()));
}
var pathsToCheck = new[]
{
Path.Combine(this.DATAFOLDER, stays),
Path.Combine(this.DATAFOLDER, modify),
Path.Combine(this.DATAFOLDER, delete),
Path.Combine(this.DATAFOLDER, create)
};
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
var allVersions = await c.ListFileVersionsAsync(pathsToCheck, 0, 0);
var grouped = allVersions.FileVersions.Items.GroupBy(x => x.Path);
foreach (var group in grouped)
{
var path = group.Key;
var versions = group.OrderByDescending(x => x.Time).ToArray(); // Newest first
if (path.EndsWith(stays))
Assert.That(versions.Length, Is.EqualTo(3), $"File {path} should exist in all 3 versions");
else if (path.EndsWith(modify))
Assert.That(versions.Length, Is.EqualTo(3), $"File {path} should exist in all 3 versions");
else if (path.EndsWith(delete))
Assert.That(versions.Length, Is.EqualTo(1), $"File {path} should exist only in first version before deletion");
else if (path.EndsWith(create))
Assert.That(versions.Length, Is.EqualTo(2), $"File {path} should exist in 2 versions after creation");
else
Assert.Fail($"Unexpected file {path} in file versions list.");
}
}
}
[Test]
public async Task SearchFilesTestAsync()
{
var options = new Dictionary<string, string>(this.TestOptions)
{
["upload-unchanged-backups"] = "true"
};
var initial = new[] {
"file1.txt",
"folder1/",
"folder1/file2.txt",
"folder1/file3.txt",
"folder2/",
"folder2/notes.txt"
};
IEnumerable<string> rootItems(IEnumerable<string> items)
=> items.Where(x => !x.Contains('/') || x.IndexOf('/') == x.Length - 1);
void createStructure(IEnumerable<string> paths)
{
foreach (var p in paths)
{
var path = Path.Combine(this.DATAFOLDER, p.Replace("/", Path.DirectorySeparatorChar.ToString()));
if (p.EndsWith("/"))
Directory.CreateDirectory(path);
else
File.WriteAllText(path, p);
}
}
IFilter ParseFilters(string args)
=> args.Split(";").Select(x => (IFilter)new FilterExpression(x.Substring(1), x.StartsWith("+"))).Aggregate((x, y) => FilterExpression.Combine(x, y));
using (var c = new Controller("file://" + this.TARGETFOLDER, options, null))
{
createStructure(initial);
TestUtils.AssertResults(await c.BackupAsync(rootItems(initial).Select(x => Path.Combine(this.DATAFOLDER, x.Replace("/", Path.DirectorySeparatorChar.ToString()))).ToArray()));
// Simple Search for 'file'
var search = await c.SearchEntriesAsync(null, ParseFilters("+file"), 0, 0, false);
Assert.That(search.FileVersions.Items.Count(), Is.EqualTo(3)); // file1.txt, file2.txt, file3.txt
// Simple Search for 'notes'
var searchNotes = await c.SearchEntriesAsync(null, ParseFilters("+notes"), 0, 0, false);
Assert.That(searchNotes.FileVersions.Items.Count(), Is.EqualTo(1));
Assert.That(searchNotes.FileVersions.Items.First().Path.EndsWith("notes.txt"));
// Simple Search for NOT 'notes'
var searchNotNotes = await c.SearchEntriesAsync(null, ParseFilters("-notes"), 0, 0, false);
Assert.That(searchNotNotes.FileVersions.Items.Count(), Is.EqualTo(5));
Assert.That(searchNotNotes.FileVersions.Items.All(x => !x.Path.Contains("notes")), Is.True);
// Simple Search for 'folder1' folder contents
var searchFolder = await c.SearchEntriesAsync(null, ParseFilters("+folder1"), 0, 0, false);
Assert.That(searchFolder.FileVersions.Items.All(x => x.Path.Contains("folder1")), Is.True);
// Simple Search with exact file name
var searchExact = await c.SearchEntriesAsync(null, ParseFilters("+file1.txt"), 0, 0, false);
Assert.That(searchExact.FileVersions.Items.Count(), Is.EqualTo(1));
Assert.That(searchExact.FileVersions.Items.First().Path.EndsWith("file1.txt"));
// Mixed include and exclude
var searchMixed = await c.SearchEntriesAsync(null, ParseFilters("+file;-file2"), 0, 0, false);
// Include has precedence over exclude, but we include non-matches as well
Assert.That(searchMixed.FileVersions.Items.Count(), Is.EqualTo(6));
Assert.That(searchMixed.FileVersions.Items.Any(x => x.Path.Contains("file2")), Is.True);
// Mixed include and exclude
var searchMixed2 = await c.SearchEntriesAsync(null, ParseFilters("+file2;-file"), 0, 0, false);
// Include has precedence over exclude, but we include non-matches as well
Assert.That(searchMixed2.FileVersions.Items.Count(), Is.EqualTo(4));
Assert.That(searchMixed2.FileVersions.Items.Any(x => x.Path.Contains("file2")), Is.True);
// Mixed include and exclude, wildcards
var searchMixed3 = await c.SearchEntriesAsync(null, ParseFilters("+file2;-*"), 0, 0, false);
// Include has precedence over exclude, but we include non-matches as well
Assert.That(searchMixed3.FileVersions.Items.Count(), Is.EqualTo(1));
Assert.That(searchMixed3.FileVersions.Items.All(x => x.Path.Contains("file2")), Is.True);
// NEW: Search inside specific folder prefix: folder1
var searchInFolder1 = await c.SearchEntriesAsync(
new[] { Path.Combine(this.DATAFOLDER, "folder1") },
ParseFilters("+file"),
0, 0, false);
Assert.That(searchInFolder1.FileVersions.Items.Count(), Is.EqualTo(2)); // file2.txt, file3.txt inside folder1
Assert.That(searchInFolder1.FileVersions.Items.All(x => x.Path.Contains("folder1")), Is.True);
// NEW: Search inside multiple folder prefixes: folder1 and folder2
var searchInFolders = await c.SearchEntriesAsync(
new[]
{
Path.Combine(this.DATAFOLDER, "folder1"),
Path.Combine(this.DATAFOLDER, "folder2")
},
ParseFilters("+file;+notes"),
0, 0, false);
Assert.That(searchInFolders.FileVersions.Items.Count(), Is.EqualTo(3)); // file2.txt, file3.txt, notes.txt
Assert.That(searchInFolders.FileVersions.Items.Any(x => x.Path.Contains("folder1")), Is.True);
Assert.That(searchInFolders.FileVersions.Items.Any(x => x.Path.Contains("folder2")), Is.True);
}
}
[Test]
public async Task GetMinimalUniquePrefixEntries_ShouldReturnCorrectLinuxPrefixesAsync()
{
using var tempFile = new TempFile();
using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None)
.ConfigureAwait(false);
SeedTestData(db, [
"/folder1/",
"/folder1/sub1/",
"/folder2/",
"/folder2/sub2/",
"/folder3/"
]);
var result = await db
.GetMinimalUniquePrefixEntriesAsync(1, CancellationToken.None)
.Select(e => e.Path)
.ToListAsync()
.ConfigureAwait(false);
Assert.That(result, Does.Contain("/folder1/"));
Assert.That(result, Does.Contain("/folder2/"));
Assert.That(result, Does.Contain("/folder3/"));
Assert.That(result.Any(x => x.Contains("sub1")), Is.False);
Assert.That(result.Any(x => x.Contains("sub2")), Is.False);
Assert.That(result.Count, Is.EqualTo(3));
}
[Test]
public async Task GetMinimalUniquePrefixEntries_ShouldReturnCorrectWindowsDrivePrefixes_Async()
{
using var tempFile = new TempFile();
using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None)
.ConfigureAwait(false);
SeedTestData(db, [
"C:\\folder1\\",
"C:\\folder1\\sub1\\",
"C:\\folder2\\",
"D:\\otherfolder\\"
]);
var result = await db
.GetMinimalUniquePrefixEntriesAsync(1, CancellationToken.None)
.Select(e => e.Path)
.ToListAsync()
.ConfigureAwait(false);
Assert.That(result, Does.Contain("C:\\folder1\\"));
Assert.That(result, Does.Contain("C:\\folder2\\"));
Assert.That(result, Does.Contain("D:\\otherfolder\\"));
Assert.That(result.Any(x => x.Contains("sub1")), Is.False);
Assert.That(result.Count, Is.EqualTo(3));
}
[Test]
public async Task GetMinimalUniquePrefixEntries_ShouldReturnCorrectWindowsUncPrefixes_Async()
{
using var tempFile = new TempFile();
using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None)
.ConfigureAwait(false);
SeedTestData(db, [
"\\\\server\\share\\folder\\",
"\\\\server\\share\\folder\\subfolder\\",
"\\\\server\\share\\otherfolder\\"
]);
var result = await db
.GetMinimalUniquePrefixEntriesAsync(1, CancellationToken.None)
.Select(e => e.Path)
.ToListAsync()
.ConfigureAwait(false);
Assert.That(result, Does.Contain("\\\\server\\share\\folder\\"));
Assert.That(result, Does.Contain("\\\\server\\share\\otherfolder\\"));
Assert.That(result.Any(x => x.Contains("subfolder")), Is.False);
Assert.That(result.Count, Is.EqualTo(2));
}
[Test]
public async Task GetMinimalUniquePrefixEntries_ShouldHandleMixedWindowsDriveAndUncPathsAsync()
{
using var tempFile = new TempFile();
using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None)
.ConfigureAwait(false);
SeedTestData(db, [
"C:\\data\\",
"C:\\data\\sub1\\",
"C:\\music\\",
"D:\\videos\\",
"\\\\server\\share\\docs\\",
"\\\\server\\share\\docs\\subdoc\\",
"\\\\server\\share\\pictures\\"
]);
var result = await db
.GetMinimalUniquePrefixEntriesAsync(1, CancellationToken.None)
.Select(e => e.Path)
.ToListAsync()
.ConfigureAwait(false);
Assert.That(result, Does.Contain("C:\\data\\"));
Assert.That(result, Does.Contain("C:\\music\\"));
Assert.That(result, Does.Contain("D:\\videos\\"));
Assert.That(result, Does.Contain("\\\\server\\share\\docs\\"));
Assert.That(result, Does.Contain("\\\\server\\share\\pictures\\"));
Assert.That(result.Any(x => x.Contains("sub1")), Is.False);
Assert.That(result.Any(x => x.Contains("subdoc")), Is.False);
Assert.That(result.Count, Is.EqualTo(5));
}
[Test]
public async Task GetMinimalUniquePrefixEntries_ShouldReturnExpectedMinimalRootsAsync()
{
// Arrange: Prepare prefixes (minimal unique) and contents
var testPrefixes = new[]
{
@"C:\Downloads\testsource\AA\",
@"C:\Downloads\testsource\AA\readme.txt",
@"C:\Downloads\testsource\AAA\",
@"C:\Downloads\testsource\AAA\readme.txt",
@"C:\Downloads\testsource\AAAA\",
@"C:\Downloads\testsource\AAAA\readme.txt",
@"C:\Downloads\Kits\",
@"C:\Downloads\Kits\abc.txt",
@"Y:\source\",
@"Y:\source\123.bin"
};
// Act
using var tempFile = new TempFile();
await using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None)
.ConfigureAwait(false);
SeedTestData(db, testPrefixes);
var resultItems = await db
.GetMinimalUniquePrefixEntriesAsync(1, CancellationToken.None)
.ToListAsync()
.ConfigureAwait(false);
var result = resultItems.Select(e => e.Path).ToList();
Assert.That(result, Does.Contain(@"C:\Downloads\testsource\AA\"));
Assert.That(result, Does.Contain(@"C:\Downloads\testsource\AAA\"));
Assert.That(result, Does.Contain(@"C:\Downloads\testsource\AAAA\"));
Assert.That(result, Does.Contain(@"C:\Downloads\Kits\"));
Assert.That(result, Does.Contain(@"Y:\source\"));
Assert.That(result.Count, Is.EqualTo(5), "Expected 5 unique prefixes");
}
private void SeedTestData(LocalListDatabase db, IEnumerable<string> fullPaths)
{
const long filesetId = 1;
long fileId = 1;
using var cmd = db.Connection.CreateCommand();
// Insert fileset entry
cmd.SetCommandAndParameters(@"
INSERT OR IGNORE INTO Fileset (ID, OperationID, VolumeID, IsFullBackup, Timestamp)
VALUES (@filesetId, 1, 1, 1, 0);")
.SetParameterValue("@filesetId", filesetId)
.ExecuteNonQuery();
foreach (var rawPath in fullPaths)
{
// Normalize path separators but preserve OS style
var path = rawPath.Replace('\\', Path.DirectorySeparatorChar)
.Replace('/', Path.DirectorySeparatorChar);
string prefix;
string relative;
if (rawPath.EndsWith("/") || rawPath.EndsWith("\\"))
{
// Directory: entire path is prefix, no file
prefix = rawPath;
relative = string.Empty;
}
else
{
int lastSep = Math.Max(rawPath.LastIndexOf('/'), rawPath.LastIndexOf('\\'));
if (lastSep < 0)
{
// No separator: file in root
prefix = string.Empty;
relative = rawPath;
}
else
{
prefix = rawPath.Substring(0, lastSep + 1); // include slash
relative = rawPath.Substring(lastSep + 1);
}
}
// Insert prefix if not present
cmd.SetCommandAndParameters(@"
INSERT OR IGNORE INTO PathPrefix (Prefix)
VALUES (@prefix);")
.SetParameterValue("@prefix", prefix)
.ExecuteNonQuery();
long prefixId = cmd.SetCommandAndParameters("SELECT ID FROM PathPrefix WHERE Prefix = @prefix")
.SetParameterValue("@prefix", prefix)
.ExecuteScalarInt64();
// Insert FileLookup
cmd.SetCommandAndParameters(@"
INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID)
VALUES (@fileId, @prefixId, @path, 1, 1);")
.SetParameterValue("@fileId", fileId)
.SetParameterValue("@prefixId", prefixId)
.SetParameterValue("@path", relative)
.ExecuteNonQuery();
// Insert FilesetEntry
cmd.SetCommandAndParameters(@"
INSERT INTO FilesetEntry (FilesetID, FileID, Lastmodified)
VALUES (@filesetId, @fileId, 0);")
.SetParameterValue("@filesetId", filesetId)
.SetParameterValue("@fileId", fileId)
.ExecuteNonQuery();
fileId++;
}
}
/// <summary>
/// Tests that ListFolder works correctly with a large number of prefix IDs
/// that triggers the temporary table code path (when count > CHUNK_SIZE = 128).
/// </summary>
[Test]
[Category("Database")]
public async Task ListFolder_WithLargePrefixIds_UsesTemporaryTableAsync()
{
using var tempFile = new TempFile();
await using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None)
.ConfigureAwait(false);
// Insert fileset entry
using (var cmd = db.Connection.CreateCommand())
{
await cmd.SetCommandAndParameters(@"
INSERT OR IGNORE INTO Fileset (ID, OperationID, VolumeID, IsFullBackup, Timestamp)
VALUES (@filesetId, 1, 1, 1, 0);")
.SetParameterValue("@filesetId", 1L)
.ExecuteNonQueryAsync();
}
// Create 150 prefix IDs (exceeds CHUNK_SIZE of 128) to trigger temporary table path
var prefixIds = new List<long>();
for (int i = 0; i < 150; i++)
{
var prefix = $"/test/prefix/{i}/";
using var cmd = db.Connection.CreateCommand();
// Insert prefix
await cmd.SetCommandAndParameters(@"
INSERT OR IGNORE INTO PathPrefix (Prefix)
VALUES (@prefix);")
.SetParameterValue("@prefix", prefix)
.ExecuteNonQueryAsync();
// Get prefix ID
var prefixId = cmd.SetCommandAndParameters("SELECT ID FROM PathPrefix WHERE Prefix = @prefix")
.SetParameterValue("@prefix", prefix)
.ExecuteScalarInt64();
prefixIds.Add(prefixId);
// Insert FileLookup
await cmd.SetCommandAndParameters(@"
INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID)
VALUES (@fileId, @prefixId, @path, 1, 1);")
.SetParameterValue("@fileId", i + 1)
.SetParameterValue("@prefixId", prefixId)
.SetParameterValue("@path", $"file{i}.txt")
.ExecuteNonQueryAsync();
// Insert FilesetEntry
await cmd.SetCommandAndParameters(@"
INSERT INTO FilesetEntry (FilesetID, FileID, Lastmodified)
VALUES (@filesetId, @fileId, 0);")
.SetParameterValue("@filesetId", 1L)
.SetParameterValue("@fileId", i + 1)
.ExecuteNonQueryAsync();
}
// Act: Call ListFolder with 150 prefix IDs
// This should trigger the temporary table code path
var result = await db.ListFolderAsync(prefixIds, 1, 0, 1000, CancellationToken.None)
.ConfigureAwait(false);
// Assert: Should return all 150 files
Assert.That(result.Items.Count(), Is.EqualTo(150));
}
/// <summary>
/// Tests that <see cref="LocalListDatabase.ListFileVersionsAsync"/> works correctly
/// with a large number of fileset IDs that triggers the temporary table code path
/// (when count > CHUNK_SIZE = 128).
/// </summary>
[Test]
[Category("Database")]
public async Task ListFileVersions_WithLargeFilesetIds_UsesTemporaryTableAsync()
{
// Arrange
using var tempFile = new TempFile();
await using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None)
.ConfigureAwait(false);
// Insert operation entry
using (var cmd = db.Connection.CreateCommand())
{
await cmd.SetCommandAndParameters(@"
INSERT OR IGNORE INTO Operation (ID, Description, Timestamp)
VALUES (1, 'TestOperation', 0);")
.ExecuteNonQueryAsync();
}
// Create 150 filesets (exceeds CHUNK_SIZE of 128) to trigger temporary table path
var filesetIds = new List<long>();
for (int i = 0; i < 150; i++)
{
using var cmd = db.Connection.CreateCommand();
// Insert RemoteVolume for the fileset
await cmd.SetCommandAndParameters(@"
INSERT INTO RemoteVolume (ID, OperationID, Name, Type, State, Size, VerificationCount, DeleteGraceTime, ArchiveTime, LockExpirationTime)
VALUES (@id, @operationId, @name, @type, @state, @size, @verificationCount, @deleteGraceTime, @archiveTime, @lockExpirationTime);")
.SetParameterValue("@id", i + 1)
.SetParameterValue("@operationId", 1L)
.SetParameterValue("@name", $"volume{i}.zip")
.SetParameterValue("@type", "Files")
.SetParameterValue("@state", "Verified")
.SetParameterValue("@size", 1024L)
.SetParameterValue("@verificationCount", 0)
.SetParameterValue("@deleteGraceTime", 0)
.SetParameterValue("@archiveTime", 0)
.SetParameterValue("@lockExpirationTime", 0)
.ExecuteNonQueryAsync();
// Insert Fileset
await cmd.SetCommandAndParameters(@"
INSERT INTO Fileset (ID, OperationID, VolumeID, IsFullBackup, Timestamp)
VALUES (@id, @operationId, @volumeId, @isFullBackup, @timestamp);")
.SetParameterValue("@id", i + 1)
.SetParameterValue("@operationId", 1L)
.SetParameterValue("@volumeId", i + 1)
.SetParameterValue("@isFullBackup", 1)
.SetParameterValue("@timestamp", i)
.ExecuteNonQueryAsync();
filesetIds.Add(i + 1);
}
// Insert a single file that exists in all filesets
using (var cmd = db.Connection.CreateCommand())
{
// Insert Blockset
await cmd.SetCommandAndParameters(@"
INSERT INTO Blockset (ID, Length, FullHash)
VALUES (1, 1024, 'fullhash');")
.ExecuteNonQueryAsync();
// Insert Metadataset
await cmd.SetCommandAndParameters(@"
INSERT INTO Metadataset (ID, BlocksetID)
VALUES (1, 1);")
.ExecuteNonQueryAsync();
// Insert PathPrefix
await cmd.SetCommandAndParameters(@"
INSERT INTO PathPrefix (ID, Prefix)
VALUES (1, '/test/');")
.ExecuteNonQueryAsync();
// Insert FileLookup
await cmd.SetCommandAndParameters(@"
INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID)
VALUES (1, 1, 'file.txt', 1, 1);")
.ExecuteNonQueryAsync();
// Insert FilesetEntry for each fileset
for (int i = 0; i < 150; i++)
{
await cmd.SetCommandAndParameters(@"
INSERT INTO FilesetEntry (FilesetID, FileID, Lastmodified)
VALUES (@filesetId, @fileId, @lastModified);")
.SetParameterValue("@filesetId", i + 1)
.SetParameterValue("@fileId", 1)
.SetParameterValue("@lastModified", 0)
.ExecuteNonQueryAsync();
}
}
// Act: Call ListFileVersions with 150 fileset IDs
// This should trigger the temporary table code path
var result = await db.ListFileVersionsAsync(
new[] { "file.txt" },
filesetIds.ToArray(),
0,
1000,
CancellationToken.None)
.ConfigureAwait(false);
// Assert: Should return all 150 file versions
Assert.That(result.Items.Count(), Is.EqualTo(150));
}
/// <summary>
/// Tests that <see cref="LocalListDatabase.SearchEntriesAsync"/> works correctly
/// with a large number of fileset IDs that triggers the temporary table code path
/// (when count > CHUNK_SIZE = 128).
/// </summary>
[Test]
[Category("Database")]
public async Task SearchEntries_WithLargeFilesetIds_UsesTemporaryTableAsync()
{
// Arrange
using var tempFile = new TempFile();
await using var db = await LocalListDatabase.CreateAsync(tempFile, null, CancellationToken.None)
.ConfigureAwait(false);
// Insert operation entry
using (var cmd = db.Connection.CreateCommand())
{
await cmd.SetCommandAndParameters(@"
INSERT OR IGNORE INTO Operation (ID, Description, Timestamp)
VALUES (1, 'TestOperation', 0);")
.ExecuteNonQueryAsync();
}
// Create 150 filesets (exceeds CHUNK_SIZE of 128) to trigger temporary table path
var filesetIds = new List<long>();
for (int i = 0; i < 150; i++)
{
using var cmd = db.Connection.CreateCommand();
// Insert RemoteVolume for the fileset
await cmd.SetCommandAndParameters(@"
INSERT INTO RemoteVolume (ID, OperationID, Name, Type, State, Size, VerificationCount, DeleteGraceTime, ArchiveTime, LockExpirationTime)
VALUES (@id, @operationId, @name, @type, @state, @size, @verificationCount, @deleteGraceTime, @archiveTime, @lockExpirationTime);")
.SetParameterValue("@id", i + 1)
.SetParameterValue("@operationId", 1L)
.SetParameterValue("@name", $"volume{i}.zip")
.SetParameterValue("@type", "Files")
.SetParameterValue("@state", "Verified")
.SetParameterValue("@size", 1024L)
.SetParameterValue("@verificationCount", 0)
.SetParameterValue("@deleteGraceTime", 0)
.SetParameterValue("@archiveTime", 0)
.SetParameterValue("@lockExpirationTime", 0)
.ExecuteNonQueryAsync();
// Insert Fileset
await cmd.SetCommandAndParameters(@"
INSERT INTO Fileset (ID, OperationID, VolumeID, IsFullBackup, Timestamp)
VALUES (@id, @operationId, @volumeId, @isFullBackup, @timestamp);")
.SetParameterValue("@id", i + 1)
.SetParameterValue("@operationId", 1L)
.SetParameterValue("@volumeId", i + 1)
.SetParameterValue("@isFullBackup", 1)
.SetParameterValue("@timestamp", i)
.ExecuteNonQueryAsync();
filesetIds.Add(i + 1);
}
// Insert files that exist in all filesets
using (var cmd = db.Connection.CreateCommand())
{
// Insert Blockset
await cmd.SetCommandAndParameters(@"
INSERT INTO Blockset (ID, Length, FullHash)
VALUES (1, 1024, 'fullhash');")
.ExecuteNonQueryAsync();
// Insert Metadataset
await cmd.SetCommandAndParameters(@"
INSERT INTO Metadataset (ID, BlocksetID)
VALUES (1, 1);")
.ExecuteNonQueryAsync();
// Insert PathPrefix
await cmd.SetCommandAndParameters(@"
INSERT INTO PathPrefix (ID, Prefix)
VALUES (1, '/test/');")
.ExecuteNonQueryAsync();
// Insert FileLookup entries
for (int i = 0; i < 10; i++)
{
await cmd.SetCommandAndParameters(@"
INSERT INTO FileLookup (ID, PrefixID, Path, BlocksetID, MetadataID)
VALUES (@id, @prefixId, @path, @blocksetId, @metadataId);")
.SetParameterValue("@id", i + 1)
.SetParameterValue("@prefixId", 1)
.SetParameterValue("@path", $"file{i}.txt")
.SetParameterValue("@blocksetId", 1)
.SetParameterValue("@metadataId", 1)
.ExecuteNonQueryAsync();
}
// Insert FilesetEntry for each fileset and file
for (int i = 0; i < 150; i++)
{
for (int j = 0; j < 10; j++)
{
await cmd.SetCommandAndParameters(@"
INSERT INTO FilesetEntry (FilesetID, FileID, Lastmodified)
VALUES (@filesetId, @fileId, @lastModified);")
.SetParameterValue("@filesetId", i + 1)
.SetParameterValue("@fileId", j + 1)
.SetParameterValue("@lastModified", 0)
.ExecuteNonQueryAsync();
}
}
}
// Act: Call SearchEntries with 150 fileset IDs
// This should trigger the temporary table code path
// Use a simple filter that matches all files (empty filter matches everything)
var result = await db.SearchEntriesAsync(
null,
new FilterExpression("*", true),
filesetIds.ToArray(),
0,
2000,
CancellationToken.None)
.ConfigureAwait(false);
// Assert: Should return all 1500 file versions (150 filesets * 10 files)
Assert.That(result.Items.Count(), Is.EqualTo(1500));
}
}
}