File: tests\Shared\TestInteractionFileUploadStore.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Concurrent;
 
namespace Aspire.Hosting.Utils;
 
/// <summary>
/// An in-memory implementation of <see cref="IInteractionFileUploadStore"/> for tests.
/// Does not write to disk or implement IDisposable.
/// </summary>
internal sealed class TestInteractionFileUploadStore : IInteractionFileUploadStore
{
    private readonly ConcurrentDictionary<string, FileEntry> _files = new(StringComparer.Ordinal);
 
    public ConcurrentQueue<int> StartedInteractions { get; } = new();
    public ConcurrentQueue<IReadOnlyList<(string InputName, int MaxFileCount)>> StartedFileInputs { get; } = new();
    public ConcurrentQueue<int> CompletedInteractions { get; } = new();
    public ConcurrentQueue<int> CanceledInteractions { get; } = new();
    public Action<int>? CompleteInteractionCallback { get; set; }
    public Action<int>? CancelInteractionCallback { get; set; }
 
    public void StartInteraction(int interactionId, IReadOnlyList<(string InputName, int MaxFileCount)> fileInputs)
    {
        StartedInteractions.Enqueue(interactionId);
        StartedFileInputs.Enqueue(fileInputs.ToArray());
    }
 
    public (string FileId, string FilePath) CreateEntry(string originalFileName, int interactionId, string inputName)
    {
        var fileId = Guid.NewGuid().ToString("N");
        // Use a synthetic path that won't conflict with real files.
        var filePath = Path.Combine("memory", fileId);
 
        _files[fileId] = new FileEntry(filePath, originalFileName, interactionId, inputName);
        return (fileId, filePath);
    }
 
    public void CompleteUpload(int interactionId, string fileId)
    {
        if (_files.TryGetValue(fileId, out var entry) && entry.InteractionId == interactionId)
        {
            bool removeEntry;
            lock (entry)
            {
                removeEntry = entry.State == FileEntryState.DiscardWhenComplete;
                if (entry.State == FileEntryState.Uploading)
                {
                    entry.State = FileEntryState.Uploaded;
                }
            }
 
            if (removeEntry)
            {
                _files.TryRemove(fileId, out _);
            }
        }
    }
 
    public IReadOnlyList<InteractionFileUpload> GetCompletedFiles(int interactionId, string inputName)
    {
        var files = new List<InteractionFileUpload>();
        foreach (var (fileId, entry) in _files)
        {
            lock (entry)
            {
                if (entry.State is FileEntryState.Uploaded or FileEntryState.Accepted &&
                    entry.InteractionId == interactionId &&
                    string.Equals(entry.InputName, inputName, StringComparisons.InteractionInputName))
                {
                    files.Add(new InteractionFileUpload(fileId, entry.OriginalFileName, entry.FilePath));
                }
            }
        }
 
        return files;
    }
 
    public void MarkFilesAccepted(int interactionId, string inputName, IReadOnlyList<string> fileIds)
    {
        foreach (var fileId in fileIds)
        {
            if (!_files.TryGetValue(fileId, out var entry) || entry.InteractionId != interactionId)
            {
                throw new InvalidOperationException($"Submitted files for input '{inputName}' do not match the completed uploads.");
            }
 
            lock (entry)
            {
                if (entry.State is not (FileEntryState.Uploaded or FileEntryState.Accepted) ||
                    !string.Equals(entry.InputName, inputName, StringComparisons.InteractionInputName))
                {
                    throw new InvalidOperationException($"Submitted files for input '{inputName}' do not match the completed uploads.");
                }
 
                entry.State = FileEntryState.Accepted;
            }
        }
    }
 
    public void RemoveEntry(int interactionId, string fileId)
    {
        if (_files.TryGetValue(fileId, out var entry) && entry.InteractionId == interactionId)
        {
            _files.TryRemove(fileId, out _);
        }
    }
 
    public void CompleteInteraction(int interactionId)
    {
        CompleteInteractionCallback?.Invoke(interactionId);
        CompletedInteractions.Enqueue(interactionId);
 
        foreach (var (fileId, entry) in _files)
        {
            bool removeEntry;
            lock (entry)
            {
                removeEntry = entry.InteractionId == interactionId && entry.State == FileEntryState.Uploaded;
                if (entry.InteractionId == interactionId)
                {
                    entry.State = entry.State switch
                    {
                        FileEntryState.Uploading => FileEntryState.DiscardWhenComplete,
                        _ => entry.State
                    };
                }
            }
 
            if (removeEntry)
            {
                _files.TryRemove(fileId, out _);
            }
        }
    }
 
    public void CancelInteraction(int interactionId)
    {
        CancelInteractionCallback?.Invoke(interactionId);
        CanceledInteractions.Enqueue(interactionId);
 
        foreach (var (fileId, entry) in _files)
        {
            bool removeEntry;
            lock (entry)
            {
                removeEntry = entry.InteractionId == interactionId &&
                    entry.State is FileEntryState.Uploaded or FileEntryState.Accepted;
                if (entry.InteractionId == interactionId)
                {
                    entry.State = FileEntryState.DiscardWhenComplete;
                }
            }
 
            if (removeEntry)
            {
                _files.TryRemove(fileId, out _);
            }
        }
    }
 
    private sealed class FileEntry(string filePath, string originalFileName, int interactionId, string inputName)
    {
        public string FilePath { get; } = filePath;
        public string OriginalFileName { get; } = originalFileName;
        public int InteractionId { get; } = interactionId;
        public string InputName { get; } = inputName;
        public FileEntryState State { get; set; }
    }
 
    private enum FileEntryState
    {
        Uploading,
        Uploaded,
        Accepted,
        DiscardWhenComplete
    }
}
 
internal static class InteractionFileUploadStoreTestExtensions
{
    public static string? GetFilePath(this IInteractionFileUploadStore store, string fileId, int interactionId, string inputName)
    {
        return store.GetCompletedFiles(interactionId, inputName).SingleOrDefault(file => file.Id == fileId)?.FilePath;
    }
 
}