A lot of changes

This commit is contained in:
2026-05-14 14:32:19 +02:00
parent d2b83524cf
commit 7215eebb5a
43 changed files with 2941 additions and 444 deletions
+49
View File
@@ -0,0 +1,49 @@
---
name: csharp-async
description: 'Get best practices for C# async programming'
---
# C# Async Programming Best Practices
Your goal is to help me follow best practices for asynchronous programming in C#.
## Naming Conventions
- Use the 'Async' suffix for all async methods
- Match method names with their synchronous counterparts when applicable (e.g., `GetDataAsync()` for `GetData()`)
## Return Types
- Return `Task<T>` when the method returns a value
- Return `Task` when the method doesn't return a value
- Consider `ValueTask<T>` for high-performance scenarios to reduce allocations
- Avoid returning `void` for async methods except for event handlers
## Exception Handling
- Use try/catch blocks around await expressions
- Avoid swallowing exceptions in async methods
- Use `ConfigureAwait(false)` when appropriate to prevent deadlocks in library code
- Propagate exceptions with `Task.FromException()` instead of throwing in async Task returning methods
## Performance
- Use `Task.WhenAll()` for parallel execution of multiple tasks
- Use `Task.WhenAny()` for implementing timeouts or taking the first completed task
- Avoid unnecessary async/await when simply passing through task results
- Consider cancellation tokens for long-running operations
## Common Pitfalls
- Never use `.Wait()`, `.Result`, or `.GetAwaiter().GetResult()` in async code
- Avoid mixing blocking and async code
- Don't create async void methods (except for event handlers)
- Always await Task-returning methods
## Implementation Patterns
- Implement the async command pattern for long-running operations
- Use async streams (IAsyncEnumerable<T>) for processing sequences asynchronously
- Consider the task-based asynchronous pattern (TAP) for public APIs
When reviewing my C# code, identify these issues and suggest improvements that follow these best practices.
+62
View File
@@ -0,0 +1,62 @@
---
name: csharp-docs
description: 'Ensure that C# types are documented with XML comments and follow best practices for documentation.'
---
# C# Documentation Best Practices
- Public members should be documented with XML comments.
- It is encouraged to document internal members as well, especially if they are complex or not self-explanatory.
## Guidance for all APIs
- Use `<summary>` to provide a brief, one sentence, description of what the type or member does. Start the summary with a present-tense, third-person verb.
- Use `<remarks>` for additional information, which can include implementation details, usage notes, or any other relevant context.
- Use `<see langword>` for language-specific keywords like `null`, `true`, `false`, `int`, `bool`, etc.
- Use `<c>` for inline code snippets.
- Use `<example>` for usage examples on how to use the member.
- Use `<code>` for code blocks. `<code>` tags should be placed within an `<example>` tag. Add the language of the code example using the `language` attribute, for example, `<code language="csharp">`.
- Use `<see cref>` to reference other types or members inline (in a sentence).
- Use `<seealso>` for standalone (not in a sentence) references to other types or members in the "See also" section of the online docs.
- Use `<inheritdoc/>` to inherit documentation from base classes or interfaces.
- Unless there is major behavior change, in which case you should document the differences.
## Methods
- Use `<param>` to describe method parameters.
- The description should be a noun phrase that doesn't specify the data type.
- Begin with an introductory article.
- If the parameter is a flag enum, start the description with "A bitwise combination of the enumeration values that specifies...".
- If the parameter is a non-flag enum, start the description with "One of the enumeration values that specifies...".
- If the parameter is a Boolean, the wording should be of the form "`<see langword="true" />` to ...; otherwise, `<see langword="false" />`.".
- If the parameter is an "out" parameter, the wording should be of the form "When this method returns, contains .... This parameter is treated as uninitialized.".
- Use `<paramref>` to reference parameter names in documentation.
- Use `<typeparam>` to describe type parameters in generic types or methods.
- Use `<typeparamref>` to reference type parameters in documentation.
- Use `<returns>` to describe what the method returns.
- The description should be a noun phrase that doesn't specify the data type.
- Begin with an introductory article.
- If the return type is Boolean, the wording should be of the form "`<see langword="true" />` if ...; otherwise, `<see langword="false" />`.".
## Constructors
- The summary wording should be "Initializes a new instance of the <Class> class [or struct].".
## Properties
- The `<summary>` should start with:
- "Gets or sets..." for a read-write property.
- "Gets..." for a read-only property.
- "Gets [or sets] a value that indicates whether..." for properties that return a Boolean value.
- Use `<value>` to describe the value of the property.
- The description should be a noun phrase that doesn't specify the data type.
- If the property has a default value, add it in a separate sentence, for example, "The default is `<see langword="false" />`".
- If the value type is Boolean, the wording should be of the form "`<see langword="true" />` if ...; otherwise, `<see langword="false" />`. The default is ...".
## Exceptions
- Use `<exception cref>` to document exceptions thrown by constructors, properties, indexers, methods, operators, and events.
- Document all exceptions thrown directly by the member.
- For exceptions thrown by nested members, document only the exceptions users are most likely to encounter.
- The description of the exception describes the condition under which it's thrown.
- Omit "Thrown if ..." or "If ..." at the beginning of the sentence. Just state the condition directly, for example "An error occurred when accessing a Message Queuing API."
+478
View File
@@ -0,0 +1,478 @@
---
name: csharp-mstest
description: 'Get best practices for MSTest 3.x/4.x unit testing, including modern assertion APIs and data-driven tests'
---
# MSTest Best Practices (MSTest 3.x/4.x)
Your goal is to help me write effective unit tests with modern MSTest, using current APIs and best practices.
## Project Setup
- Use a separate test project with naming convention `[ProjectName].Tests`
- Reference MSTest 3.x+ NuGet packages (includes analyzers)
- Consider using MSTest.Sdk for simplified project setup
- Run tests with `dotnet test`
## Test Class Structure
- Use `[TestClass]` attribute for test classes
- **Seal test classes by default** for performance and design clarity
- Use `[TestMethod]` for test methods (prefer over `[DataTestMethod]`)
- Follow Arrange-Act-Assert (AAA) pattern
- Name tests using pattern `MethodName_Scenario_ExpectedBehavior`
```csharp
[TestClass]
public sealed class CalculatorTests
{
[TestMethod]
public void Add_TwoPositiveNumbers_ReturnsSum()
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(2, 3);
// Assert
Assert.AreEqual(5, result);
}
}
```
## Test Lifecycle
- **Prefer constructors over `[TestInitialize]`** - enables `readonly` fields and follows standard C# patterns
- Use `[TestCleanup]` for cleanup that must run even if test fails
- Combine constructor with async `[TestInitialize]` when async setup is needed
```csharp
[TestClass]
public sealed class ServiceTests
{
private readonly MyService _service; // readonly enabled by constructor
public ServiceTests()
{
_service = new MyService();
}
[TestInitialize]
public async Task InitAsync()
{
// Use for async initialization only
await _service.WarmupAsync();
}
[TestCleanup]
public void Cleanup() => _service.Reset();
}
```
### Execution Order
1. **Assembly Initialization** - `[AssemblyInitialize]` (once per test assembly)
2. **Class Initialization** - `[ClassInitialize]` (once per test class)
3. **Test Initialization** (for every test method):
1. Constructor
2. Set `TestContext` property
3. `[TestInitialize]`
4. **Test Execution** - test method runs
5. **Test Cleanup** (for every test method):
1. `[TestCleanup]`
2. `DisposeAsync` (if implemented)
3. `Dispose` (if implemented)
6. **Class Cleanup** - `[ClassCleanup]` (once per test class)
7. **Assembly Cleanup** - `[AssemblyCleanup]` (once per test assembly)
## Modern Assertion APIs
MSTest provides three assertion classes: `Assert`, `StringAssert`, and `CollectionAssert`.
### Assert Class - Core Assertions
```csharp
// Equality
Assert.AreEqual(expected, actual);
Assert.AreNotEqual(notExpected, actual);
Assert.AreSame(expectedObject, actualObject); // Reference equality
Assert.AreNotSame(notExpectedObject, actualObject);
// Null checks
Assert.IsNull(value);
Assert.IsNotNull(value);
// Boolean
Assert.IsTrue(condition);
Assert.IsFalse(condition);
// Fail/Inconclusive
Assert.Fail("Test failed due to...");
Assert.Inconclusive("Test cannot be completed because...");
```
### Exception Testing (Prefer over `[ExpectedException]`)
```csharp
// Assert.Throws - matches TException or derived types
var ex = Assert.Throws<ArgumentException>(() => Method(null));
Assert.AreEqual("Value cannot be null.", ex.Message);
// Assert.ThrowsExactly - matches exact type only
var ex = Assert.ThrowsExactly<InvalidOperationException>(() => Method());
// Async versions
var ex = await Assert.ThrowsAsync<HttpRequestException>(async () => await client.GetAsync(url));
var ex = await Assert.ThrowsExactlyAsync<InvalidOperationException>(async () => await Method());
```
### Collection Assertions (Assert class)
```csharp
Assert.Contains(expectedItem, collection);
Assert.DoesNotContain(unexpectedItem, collection);
Assert.ContainsSingle(collection); // exactly one element
Assert.HasCount(5, collection);
Assert.IsEmpty(collection);
Assert.IsNotEmpty(collection);
```
### String Assertions (Assert class)
```csharp
Assert.Contains("expected", actualString);
Assert.StartsWith("prefix", actualString);
Assert.EndsWith("suffix", actualString);
Assert.DoesNotStartWith("prefix", actualString);
Assert.DoesNotEndWith("suffix", actualString);
Assert.MatchesRegex(@"\d{3}-\d{4}", phoneNumber);
Assert.DoesNotMatchRegex(@"\d+", textOnly);
```
### Comparison Assertions
```csharp
Assert.IsGreaterThan(lowerBound, actual);
Assert.IsGreaterThanOrEqualTo(lowerBound, actual);
Assert.IsLessThan(upperBound, actual);
Assert.IsLessThanOrEqualTo(upperBound, actual);
Assert.IsInRange(actual, low, high);
Assert.IsPositive(number);
Assert.IsNegative(number);
```
### Type Assertions
```csharp
// MSTest 3.x - uses out parameter
Assert.IsInstanceOfType<MyClass>(obj, out var typed);
typed.DoSomething();
// MSTest 4.x - returns typed result directly
var typed = Assert.IsInstanceOfType<MyClass>(obj);
typed.DoSomething();
Assert.IsNotInstanceOfType<WrongType>(obj);
```
### Assert.That (MSTest 4.0+)
```csharp
Assert.That(result.Count > 0); // Auto-captures expression in failure message
```
### StringAssert Class
> **Note:** Prefer `Assert` class equivalents when available (e.g., `Assert.Contains("expected", actual)` over `StringAssert.Contains(actual, "expected")`).
```csharp
StringAssert.Contains(actualString, "expected");
StringAssert.StartsWith(actualString, "prefix");
StringAssert.EndsWith(actualString, "suffix");
StringAssert.Matches(actualString, new Regex(@"\d{3}-\d{4}"));
StringAssert.DoesNotMatch(actualString, new Regex(@"\d+"));
```
### CollectionAssert Class
> **Note:** Prefer `Assert` class equivalents when available (e.g., `Assert.Contains`).
```csharp
// Containment
CollectionAssert.Contains(collection, expectedItem);
CollectionAssert.DoesNotContain(collection, unexpectedItem);
// Equality (same elements, same order)
CollectionAssert.AreEqual(expectedCollection, actualCollection);
CollectionAssert.AreNotEqual(unexpectedCollection, actualCollection);
// Equivalence (same elements, any order)
CollectionAssert.AreEquivalent(expectedCollection, actualCollection);
CollectionAssert.AreNotEquivalent(unexpectedCollection, actualCollection);
// Subset checks
CollectionAssert.IsSubsetOf(subset, superset);
CollectionAssert.IsNotSubsetOf(notSubset, collection);
// Element validation
CollectionAssert.AllItemsAreInstancesOfType(collection, typeof(MyClass));
CollectionAssert.AllItemsAreNotNull(collection);
CollectionAssert.AllItemsAreUnique(collection);
```
## Data-Driven Tests
### DataRow
```csharp
[TestMethod]
[DataRow(1, 2, 3)]
[DataRow(0, 0, 0, DisplayName = "Zeros")]
[DataRow(-1, 1, 0, IgnoreMessage = "Known issue #123")] // MSTest 3.8+
public void Add_ReturnsSum(int a, int b, int expected)
{
Assert.AreEqual(expected, Calculator.Add(a, b));
}
```
### DynamicData
The data source can return any of the following types:
- `IEnumerable<(T1, T2, ...)>` (ValueTuple) - **preferred**, provides type safety (MSTest 3.7+)
- `IEnumerable<Tuple<T1, T2, ...>>` - provides type safety
- `IEnumerable<TestDataRow>` - provides type safety plus control over test metadata (display name, categories)
- `IEnumerable<object[]>` - **least preferred**, no type safety
> **Note:** When creating new test data methods, prefer `ValueTuple` or `TestDataRow` over `IEnumerable<object[]>`. The `object[]` approach provides no compile-time type checking and can lead to runtime errors from type mismatches.
```csharp
[TestMethod]
[DynamicData(nameof(TestData))]
public void DynamicTest(int a, int b, int expected)
{
Assert.AreEqual(expected, Calculator.Add(a, b));
}
// ValueTuple - preferred (MSTest 3.7+)
public static IEnumerable<(int a, int b, int expected)> TestData =>
[
(1, 2, 3),
(0, 0, 0),
];
// TestDataRow - when you need custom display names or metadata
public static IEnumerable<TestDataRow<(int a, int b, int expected)>> TestDataWithMetadata =>
[
new((1, 2, 3)) { DisplayName = "Positive numbers" },
new((0, 0, 0)) { DisplayName = "Zeros" },
new((-1, 1, 0)) { DisplayName = "Mixed signs", IgnoreMessage = "Known issue #123" },
];
// IEnumerable<object[]> - avoid for new code (no type safety)
public static IEnumerable<object[]> LegacyTestData =>
[
[1, 2, 3],
[0, 0, 0],
];
```
## TestContext
The `TestContext` class provides test run information, cancellation support, and output methods.
See [TestContext documentation](https://learn.microsoft.com/dotnet/core/testing/unit-testing-mstest-writing-tests-testcontext) for complete reference.
### Accessing TestContext
```csharp
// Property (MSTest suppresses CS8618 - don't use nullable or = null!)
public TestContext TestContext { get; set; }
// Constructor injection (MSTest 3.6+) - preferred for immutability
[TestClass]
public sealed class MyTests
{
private readonly TestContext _testContext;
public MyTests(TestContext testContext)
{
_testContext = testContext;
}
}
// Static methods receive it as parameter
[ClassInitialize]
public static void ClassInit(TestContext context) { }
// Optional for cleanup methods (MSTest 3.6+)
[ClassCleanup]
public static void ClassCleanup(TestContext context) { }
[AssemblyCleanup]
public static void AssemblyCleanup(TestContext context) { }
```
### Cancellation Token
Always use `TestContext.CancellationToken` for cooperative cancellation with `[Timeout]`:
```csharp
[TestMethod]
[Timeout(5000)]
public async Task LongRunningTest()
{
await _httpClient.GetAsync(url, TestContext.CancellationToken);
}
```
### Test Run Properties
```csharp
TestContext.TestName // Current test method name
TestContext.TestDisplayName // Display name (3.7+)
TestContext.CurrentTestOutcome // Pass/Fail/InProgress
TestContext.TestData // Parameterized test data (3.7+, in TestInitialize/Cleanup)
TestContext.TestException // Exception if test failed (3.7+, in TestCleanup)
TestContext.DeploymentDirectory // Directory with deployment items
```
### Output and Result Files
```csharp
// Write to test output (useful for debugging)
TestContext.WriteLine("Processing item {0}", itemId);
// Attach files to test results (logs, screenshots)
TestContext.AddResultFile(screenshotPath);
// Store/retrieve data across test methods
TestContext.Properties["SharedKey"] = computedValue;
```
## Advanced Features
### Retry for Flaky Tests (MSTest 3.9+)
```csharp
[TestMethod]
[Retry(3)]
public void FlakyTest() { }
```
### Conditional Execution (MSTest 3.10+)
Skip or run tests based on OS or CI environment:
```csharp
// OS-specific tests
[TestMethod]
[OSCondition(OperatingSystems.Windows)]
public void WindowsOnlyTest() { }
[TestMethod]
[OSCondition(OperatingSystems.Linux | OperatingSystems.MacOS)]
public void UnixOnlyTest() { }
[TestMethod]
[OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)]
public void SkipOnWindowsTest() { }
// CI environment tests
[TestMethod]
[CICondition] // Runs only in CI (default: ConditionMode.Include)
public void CIOnlyTest() { }
[TestMethod]
[CICondition(ConditionMode.Exclude)] // Skips in CI, runs locally
public void LocalOnlyTest() { }
```
### Parallelization
```csharp
// Assembly level
[assembly: Parallelize(Workers = 4, Scope = ExecutionScope.MethodLevel)]
// Disable for specific class
[TestClass]
[DoNotParallelize]
public sealed class SequentialTests { }
```
### Work Item Traceability (MSTest 3.8+)
Link tests to work items for traceability in test reports:
```csharp
// Azure DevOps work items
[TestMethod]
[WorkItem(12345)] // Links to work item #12345
public void Feature_Scenario_ExpectedBehavior() { }
// Multiple work items
[TestMethod]
[WorkItem(12345)]
[WorkItem(67890)]
public void Feature_CoversMultipleRequirements() { }
// GitHub issues (MSTest 3.8+)
[TestMethod]
[GitHubWorkItem("https://github.com/owner/repo/issues/42")]
public void BugFix_Issue42_IsResolved() { }
```
Work item associations appear in test results and can be used for:
- Tracing test coverage to requirements
- Linking bug fixes to regression tests
- Generating traceability reports in CI/CD pipelines
## Common Mistakes to Avoid
```csharp
// ❌ Wrong argument order
Assert.AreEqual(actual, expected);
// ✅ Correct
Assert.AreEqual(expected, actual);
// ❌ Using ExpectedException (obsolete)
[ExpectedException(typeof(ArgumentException))]
// ✅ Use Assert.Throws
Assert.Throws<ArgumentException>(() => Method());
// ❌ Using LINQ Single() - unclear exception
var item = items.Single();
// ✅ Use ContainsSingle - better failure message
var item = Assert.ContainsSingle(items);
// ❌ Hard cast - unclear exception
var handler = (MyHandler)result;
// ✅ Type assertion - shows actual type on failure
var handler = Assert.IsInstanceOfType<MyHandler>(result);
// ❌ Ignoring cancellation token
await client.GetAsync(url, CancellationToken.None);
// ✅ Flow test cancellation
await client.GetAsync(url, TestContext.CancellationToken);
// ❌ Making TestContext nullable - leads to unnecessary null checks
public TestContext? TestContext { get; set; }
// ❌ Using null! - MSTest already suppresses CS8618 for this property
public TestContext TestContext { get; set; } = null!;
// ✅ Declare without nullable or initializer - MSTest handles the warning
public TestContext TestContext { get; set; }
```
## Test Organization
- Group tests by feature or component
- Use `[TestCategory("Category")]` for filtering
- Use `[TestProperty("Name", "Value")]` for custom metadata (e.g., `[TestProperty("Bug", "12345")]`)
- Use `[Priority(1)]` for critical tests
- Enable relevant MSTest analyzers (MSTEST0020 for constructor preference)
## Mocking and Isolation
- Use Moq or NSubstitute for mocking dependencies
- Use interfaces to facilitate mocking
- Mock dependencies to isolate units under test
+71
View File
@@ -0,0 +1,71 @@
---
name: csharp-nunit
description: 'Get best practices for NUnit unit testing, including data-driven tests'
---
# NUnit Best Practices
Your goal is to help me write effective unit tests with NUnit, covering both standard and data-driven testing approaches.
## Project Setup
- Use a separate test project with naming convention `[ProjectName].Tests`
- Reference Microsoft.NET.Test.Sdk, NUnit, and NUnit3TestAdapter packages
- Create test classes that match the classes being tested (e.g., `CalculatorTests` for `Calculator`)
- Use .NET SDK test commands: `dotnet test` for running tests
## Test Structure
- Apply `[TestFixture]` attribute to test classes
- Use `[Test]` attribute for test methods
- Follow the Arrange-Act-Assert (AAA) pattern
- Name tests using the pattern `MethodName_Scenario_ExpectedBehavior`
- Use `[SetUp]` and `[TearDown]` for per-test setup and teardown
- Use `[OneTimeSetUp]` and `[OneTimeTearDown]` for per-class setup and teardown
- Use `[SetUpFixture]` for assembly-level setup and teardown
## Standard Tests
- Keep tests focused on a single behavior
- Avoid testing multiple behaviors in one test method
- Use clear assertions that express intent
- Include only the assertions needed to verify the test case
- Make tests independent and idempotent (can run in any order)
- Avoid test interdependencies
## Data-Driven Tests
- Use `[TestCase]` for inline test data
- Use `[TestCaseSource]` for programmatically generated test data
- Use `[Values]` for simple parameter combinations
- Use `[ValueSource]` for property or method-based data sources
- Use `[Random]` for random numeric test values
- Use `[Range]` for sequential numeric test values
- Use `[Combinatorial]` or `[Pairwise]` for combining multiple parameters
## Assertions
- Use `Assert.That` with constraint model (preferred NUnit style)
- Use constraints like `Is.EqualTo`, `Is.SameAs`, `Contains.Item`
- Use `Assert.AreEqual` for simple value equality (classic style)
- Use `CollectionAssert` for collection comparisons
- Use `StringAssert` for string-specific assertions
- Use `Assert.Throws<T>` or `Assert.ThrowsAsync<T>` to test exceptions
- Use descriptive messages in assertions for clarity on failure
## Mocking and Isolation
- Consider using Moq or NSubstitute alongside NUnit
- Mock dependencies to isolate units under test
- Use interfaces to facilitate mocking
- Consider using a DI container for complex test setups
## Test Organization
- Group tests by feature or component
- Use categories with `[Category("CategoryName")]`
- Use `[Order]` to control test execution order when necessary
- Use `[Author("DeveloperName")]` to indicate ownership
- Use `[Description]` to provide additional test information
- Consider `[Explicit]` for tests that shouldn't run automatically
- Use `[Ignore("Reason")]` to temporarily skip tests
+100
View File
@@ -0,0 +1,100 @@
---
name: csharp-tunit
description: 'Get best practices for TUnit unit testing, including data-driven tests'
---
# TUnit Best Practices
Your goal is to help me write effective unit tests with TUnit, covering both standard and data-driven testing approaches.
## Project Setup
- Use a separate test project with naming convention `[ProjectName].Tests`
- Reference TUnit package and TUnit.Assertions for fluent assertions
- Create test classes that match the classes being tested (e.g., `CalculatorTests` for `Calculator`)
- Use .NET SDK test commands: `dotnet test` for running tests
- TUnit requires .NET 8.0 or higher
## Test Structure
- No test class attributes required (like xUnit/NUnit)
- Use `[Test]` attribute for test methods (not `[Fact]` like xUnit)
- Follow the Arrange-Act-Assert (AAA) pattern
- Name tests using the pattern `MethodName_Scenario_ExpectedBehavior`
- Use lifecycle hooks: `[Before(Test)]` for setup and `[After(Test)]` for teardown
- Use `[Before(Class)]` and `[After(Class)]` for shared context between tests in a class
- Use `[Before(Assembly)]` and `[After(Assembly)]` for shared context across test classes
- TUnit supports advanced lifecycle hooks like `[Before(TestSession)]` and `[After(TestSession)]`
## Standard Tests
- Keep tests focused on a single behavior
- Avoid testing multiple behaviors in one test method
- Use TUnit's fluent assertion syntax with `await Assert.That()`
- Include only the assertions needed to verify the test case
- Make tests independent and idempotent (can run in any order)
- Avoid test interdependencies (use `[DependsOn]` attribute if needed)
## Data-Driven Tests
- Use `[Arguments]` attribute for inline test data (equivalent to xUnit's `[InlineData]`)
- Use `[MethodData]` for method-based test data (equivalent to xUnit's `[MemberData]`)
- Use `[ClassData]` for class-based test data
- Create custom data sources by implementing `ITestDataSource`
- Use meaningful parameter names in data-driven tests
- Multiple `[Arguments]` attributes can be applied to the same test method
## Assertions
- Use `await Assert.That(value).IsEqualTo(expected)` for value equality
- Use `await Assert.That(value).IsSameReferenceAs(expected)` for reference equality
- Use `await Assert.That(value).IsTrue()` or `await Assert.That(value).IsFalse()` for boolean conditions
- Use `await Assert.That(collection).Contains(item)` or `await Assert.That(collection).DoesNotContain(item)` for collections
- Use `await Assert.That(value).Matches(pattern)` for regex pattern matching
- Use `await Assert.That(action).Throws<TException>()` or `await Assert.That(asyncAction).ThrowsAsync<TException>()` to test exceptions
- Chain assertions with `.And` operator: `await Assert.That(value).IsNotNull().And.IsEqualTo(expected)`
- Use `.Or` operator for alternative conditions: `await Assert.That(value).IsEqualTo(1).Or.IsEqualTo(2)`
- Use `.Within(tolerance)` for DateTime and numeric comparisons with tolerance
- All assertions are asynchronous and must be awaited
## Advanced Features
- Use `[Repeat(n)]` to repeat tests multiple times
- Use `[Retry(n)]` for automatic retry on failure
- Use `[ParallelLimit<T>]` to control parallel execution limits
- Use `[Skip("reason")]` to skip tests conditionally
- Use `[DependsOn(nameof(OtherTest))]` to create test dependencies
- Use `[Timeout(milliseconds)]` to set test timeouts
- Create custom attributes by extending TUnit's base attributes
## Test Organization
- Group tests by feature or component
- Use `[Category("CategoryName")]` for test categorization
- Use `[DisplayName("Custom Test Name")]` for custom test names
- Consider using `TestContext` for test diagnostics and information
- Use conditional attributes like custom `[WindowsOnly]` for platform-specific tests
## Performance and Parallel Execution
- TUnit runs tests in parallel by default (unlike xUnit which requires explicit configuration)
- Use `[NotInParallel]` to disable parallel execution for specific tests
- Use `[ParallelLimit<T>]` with custom limit classes to control concurrency
- Tests within the same class run sequentially by default
- Use `[Repeat(n)]` with `[ParallelLimit<T>]` for load testing scenarios
## Migration from xUnit
- Replace `[Fact]` with `[Test]`
- Replace `[Theory]` with `[Test]` and use `[Arguments]` for data
- Replace `[InlineData]` with `[Arguments]`
- Replace `[MemberData]` with `[MethodData]`
- Replace `Assert.Equal` with `await Assert.That(actual).IsEqualTo(expected)`
- Replace `Assert.True` with `await Assert.That(condition).IsTrue()`
- Replace `Assert.Throws<T>` with `await Assert.That(action).Throws<T>()`
- Replace constructor/IDisposable with `[Before(Test)]`/`[After(Test)]`
- Replace `IClassFixture<T>` with `[Before(Class)]`/`[After(Class)]`
**Why TUnit over xUnit?**
TUnit offers a modern, fast, and flexible testing experience with advanced features not present in xUnit, such as asynchronous assertions, more refined lifecycle hooks, and improved data-driven testing capabilities. TUnit's fluent assertions provide clearer and more expressive test validation, making it especially suitable for complex .NET projects.
+68
View File
@@ -0,0 +1,68 @@
---
name: csharp-xunit
description: 'Get best practices for XUnit unit testing, including data-driven tests'
---
# XUnit Best Practices
Your goal is to help me write effective unit tests with XUnit, covering both standard and data-driven testing approaches.
## Project Setup
- Use a separate test project with naming convention `[ProjectName].Tests`
- Reference Microsoft.NET.Test.Sdk, xunit, and xunit.runner.visualstudio packages
- Create test classes that match the classes being tested (e.g., `CalculatorTests` for `Calculator`)
- Use .NET SDK test commands: `dotnet test` for running tests
## Test Structure
- No test class attributes required (unlike MSTest/NUnit)
- Use fact-based tests with `[Fact]` attribute for simple tests
- Follow the Arrange-Act-Assert (AAA) pattern
- Name tests using the pattern `MethodName_Scenario_ExpectedBehavior`
- Use constructor for setup and `IDisposable.Dispose()` for teardown
- Use `IClassFixture<T>` for shared context between tests in a class
- Use `ICollectionFixture<T>` for shared context between multiple test classes
## Standard Tests
- Keep tests focused on a single behavior
- Avoid testing multiple behaviors in one test method
- Use clear assertions that express intent
- Include only the assertions needed to verify the test case
- Make tests independent and idempotent (can run in any order)
- Avoid test interdependencies
## Data-Driven Tests
- Use `[Theory]` combined with data source attributes
- Use `[InlineData]` for inline test data
- Use `[MemberData]` for method-based test data
- Use `[ClassData]` for class-based test data
- Create custom data attributes by implementing `DataAttribute`
- Use meaningful parameter names in data-driven tests
## Assertions
- Use `Assert.Equal` for value equality
- Use `Assert.Same` for reference equality
- Use `Assert.True`/`Assert.False` for boolean conditions
- Use `Assert.Contains`/`Assert.DoesNotContain` for collections
- Use `Assert.Matches`/`Assert.DoesNotMatch` for regex pattern matching
- Use `Assert.Throws<T>` or `await Assert.ThrowsAsync<T>` to test exceptions
- Use fluent assertions library for more readable assertions
## Mocking and Isolation
- Consider using Moq or NSubstitute alongside XUnit
- Mock dependencies to isolate units under test
- Use interfaces to facilitate mocking
- Consider using a DI container for complex test setups
## Test Organization
- Group tests by feature or component
- Use `[Trait("Category", "CategoryName")]` for categorization
- Use collection fixtures to group tests with shared dependencies
- Consider output helpers (`ITestOutputHelper`) for test diagnostics
- Skip tests conditionally with `Skip = "reason"` in fact/theory attributes
@@ -0,0 +1,85 @@
---
name: dotnet-best-practices
description: 'Ensure .NET/C# code meets best practices for the solution/project.'
---
# .NET/C# Best Practices
Your task is to ensure .NET/C# code in ${selection} meets the best practices specific to this solution/project. This includes:
## Documentation & Structure
- Create comprehensive XML documentation comments for all public classes, interfaces, methods, and properties
- Include parameter descriptions and return value descriptions in XML comments
- Follow the established namespace structure: {Core|Console|App|Service}.{Feature}
## Design Patterns & Architecture
- Use primary constructor syntax for dependency injection (e.g., `public class MyClass(IDependency dependency)`)
- Implement the Command Handler pattern with generic base classes (e.g., `CommandHandler<TOptions>`)
- Use interface segregation with clear naming conventions (prefix interfaces with 'I')
- Follow the Factory pattern for complex object creation.
## Dependency Injection & Services
- Use constructor dependency injection with null checks via ArgumentNullException
- Register services with appropriate lifetimes (Singleton, Scoped, Transient)
- Use Microsoft.Extensions.DependencyInjection patterns
- Implement service interfaces for testability
## Resource Management & Localization
- Use ResourceManager for localized messages and error strings
- Separate LogMessages and ErrorMessages resource files
- Access resources via `_resourceManager.GetString("MessageKey")`
## Async/Await Patterns
- Use async/await for all I/O operations and long-running tasks
- Return Task or Task<T> from async methods
- Use ConfigureAwait(false) where appropriate
- Handle async exceptions properly
## Testing Standards
- Use MSTest framework with FluentAssertions for assertions
- Follow AAA pattern (Arrange, Act, Assert)
- Use Moq for mocking dependencies
- Test both success and failure scenarios
- Include null parameter validation tests
## Configuration & Settings
- Use strongly-typed configuration classes with data annotations
- Implement validation attributes (Required, NotEmptyOrWhitespace)
- Use IConfiguration binding for settings
- Support appsettings.json configuration files
## Semantic Kernel & AI Integration
- Use Microsoft.SemanticKernel for AI operations
- Implement proper kernel configuration and service registration
- Handle AI model settings (ChatCompletion, Embedding, etc.)
- Use structured output patterns for reliable AI responses
## Error Handling & Logging
- Use structured logging with Microsoft.Extensions.Logging
- Include scoped logging with meaningful context
- Throw specific exceptions with descriptive messages
- Use try-catch blocks for expected failure scenarios
## Performance & Security
- Use C# 12+ features and .NET 8 optimizations where applicable
- Implement proper input validation and sanitization
- Use parameterized queries for database operations
- Follow secure coding practices for AI/ML operations
## Code Quality
- Ensure SOLID principles compliance
- Avoid code duplication through base classes and utilities
- Use meaningful names that reflect domain concepts
- Keep methods focused and cohesive
- Implement proper disposal patterns for resources
@@ -0,0 +1,42 @@
---
name: dotnet-design-pattern-review
description: 'Review the C#/.NET code for design pattern implementation and suggest improvements.'
---
# .NET/C# Design Pattern Review
Review the C#/.NET code in ${selection} for design pattern implementation and suggest improvements for the solution/project. Do not make any changes to the code, just provide a review.
## Required Design Patterns
- **Command Pattern**: Generic base classes (`CommandHandler<TOptions>`), `ICommandHandler<TOptions>` interface, `CommandHandlerOptions` inheritance, static `SetupCommand(IHost host)` methods
- **Factory Pattern**: Complex object creation service provider integration
- **Dependency Injection**: Primary constructor syntax, `ArgumentNullException` null checks, interface abstractions, proper service lifetimes
- **Repository Pattern**: Async data access interfaces provider abstractions for connections
- **Provider Pattern**: External service abstractions (database, AI), clear contracts, configuration handling
- **Resource Pattern**: ResourceManager for localized messages, separate .resx files (LogMessages, ErrorMessages)
## Review Checklist
- **Design Patterns**: Identify patterns used. Are Command Handler, Factory, Provider, and Repository patterns correctly implemented? Missing beneficial patterns?
- **Architecture**: Follow namespace conventions (`{Core|Console|App|Service}.{Feature}`)? Proper separation between Core/Console projects? Modular and readable?
- **.NET Best Practices**: Primary constructors, async/await with Task returns, ResourceManager usage, structured logging, strongly-typed configuration?
- **GoF Patterns**: Command, Factory, Template Method, Strategy patterns correctly implemented?
- **SOLID Principles**: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion violations?
- **Performance**: Proper async/await, resource disposal, ConfigureAwait(false), parallel processing opportunities?
- **Maintainability**: Clear separation of concerns, consistent error handling, proper configuration usage?
- **Testability**: Dependencies abstracted via interfaces, mockable components, async testability, AAA pattern compatibility?
- **Security**: Input validation, secure credential handling, parameterized queries, safe exception handling?
- **Documentation**: XML docs for public APIs, parameter/return descriptions, resource file organization?
- **Code Clarity**: Meaningful names reflecting domain concepts, clear intent through patterns, self-explanatory structure?
- **Clean Code**: Consistent style, appropriate method/class size, minimal complexity, eliminated duplication?
## Improvement Focus Areas
- **Command Handlers**: Validation in base class, consistent error handling, proper resource management
- **Factories**: Dependency configuration, service provider integration, disposal patterns
- **Providers**: Connection management, async patterns, exception handling and logging
- **Configuration**: Data annotations, validation attributes, secure sensitive value handling
- **AI/ML Integration**: Semantic Kernel patterns, structured output handling, model configuration
Provide specific, actionable recommendations for improvements aligned with the project's architecture and .NET best practices.
+116
View File
@@ -0,0 +1,116 @@
---
name: dotnet-upgrade
description: 'Ready-to-use prompts for comprehensive .NET framework upgrade analysis and execution'
---
# Project Discovery & Assessment
- name: "Project Classification Analysis"
prompt: "Identify all projects in the solution and classify them by type (`.NET Framework`, `.NET Core`, `.NET Standard`). Analyze each `.csproj` for its current `TargetFramework` and SDK usage."
- name: "Dependency Compatibility Review"
prompt: "Review external and internal dependencies for framework compatibility. Determine the upgrade complexity based on dependency graph depth."
- name: "Legacy Package Detection"
prompt: "Identify legacy `packages.config` projects needing migration to `PackageReference` format."
# Upgrade Strategy & Sequencing
- name: "Project Upgrade Ordering"
prompt: "Recommend a project upgrade order from least to most dependent components. Suggest how to isolate class library upgrades before API or Azure Function migrations."
- name: "Incremental Strategy Planning"
prompt: "Propose an incremental upgrade strategy with rollback checkpoints. Evaluate the use of **Upgrade Assistant** or **manual upgrades** based on project structure."
- name: "Progress Tracking Setup"
prompt: "Generate an upgrade checklist for tracking build, test, and deployment readiness across all projects."
# Framework Targeting & Code Adjustments
- name: "Target Framework Selection"
prompt: "Suggest the correct `TargetFramework` for each project (e.g., `net8.0`). Review and update deprecated SDK or build configurations."
- name: "Code Modernization Analysis"
prompt: "Identify code patterns needing modernization (e.g., `WebHostBuilder` → `HostBuilder`). Suggest replacements for deprecated .NET APIs and third-party libraries."
- name: "Async Pattern Conversion"
prompt: "Recommend conversion of synchronous calls to async where appropriate for improved performance and scalability."
# NuGet & Dependency Management
- name: "Package Compatibility Analysis"
prompt: "Analyze outdated or incompatible NuGet packages and suggest compatible versions. Identify third-party libraries that lack .NET 8 support and provide migration paths."
- name: "Shared Dependency Strategy"
prompt: "Recommend strategies for handling shared dependency upgrades across projects. Evaluate usage of legacy packages and suggest alternatives in Microsoft-supported namespaces."
- name: "Transitive Dependency Review"
prompt: "Review transitive dependencies and potential version conflicts after upgrade. Suggest resolution strategies for dependency conflicts."
# CI/CD & Build Pipeline Updates
- name: "Pipeline Configuration Analysis"
prompt: "Analyze YAML build definitions for SDK version pinning and recommend updates. Suggest modifications for `UseDotNet@2` and `NuGetToolInstaller` tasks."
- name: "Build Pipeline Modernization"
prompt: "Generate updated build pipeline snippets for .NET 8 migration. Recommend validation builds on feature branches before merging to main."
- name: "CI Automation Enhancement"
prompt: "Identify opportunities to automate test and build verification in CI pipelines. Suggest strategies for continuous integration validation."
# Testing & Validation
- name: "Build Validation Strategy"
prompt: "Propose validation checks to ensure the upgraded solution builds and runs successfully. Recommend automated test execution for unit and integration suites post-upgrade."
- name: "Service Integration Verification"
prompt: "Generate validation steps to verify logging, telemetry, and service connectivity. Suggest strategies for verifying backward compatibility and runtime behavior."
- name: "Deployment Readiness Check"
prompt: "Recommend UAT deployment verification steps before production rollout. Create comprehensive testing scenarios for upgraded components."
# Breaking Change Analysis
- name: "API Deprecation Detection"
prompt: "Identify deprecated APIs or removed namespaces between target versions. Suggest automated scanning using `.NET Upgrade Assistant` and API Analyzer."
- name: "API Replacement Strategy"
prompt: "Recommend replacement APIs or libraries for known breaking areas. Review configuration changes such as `Startup.cs` → `Program.cs` refactoring."
- name: "Regression Testing Focus"
prompt: "Suggest regression testing scenarios focused on upgraded API endpoints or services. Create test plans for critical functionality validation."
# Version Control & Commit Strategy
- name: "Branching Strategy Planning"
prompt: "Recommend branching strategy for safe upgrade with rollback capability. Generate commit templates for partial and complete project upgrades."
- name: "PR Structure Optimization"
prompt: "Suggest best practices for creating structured PRs (`Upgrade to .NET [Version]`). Identify tagging strategies for PRs involving breaking changes."
- name: "Code Review Guidelines"
prompt: "Recommend peer review focus areas (build, test, and dependency validation). Create checklists for effective upgrade reviews."
# Documentation & Communication
- name: "Upgrade Documentation Strategy"
prompt: "Suggest how to document each project's framework change in the PR. Propose automated release note generation summarizing upgrades and test results."
- name: "Stakeholder Communication"
prompt: "Recommend communicating version upgrades and migration timelines to consumers. Generate documentation templates for dependency updates and validation results."
- name: "Progress Tracking Systems"
prompt: "Suggest maintaining an upgrade summary dashboard or markdown checklist. Create templates for tracking upgrade progress across multiple projects."
# Tools & Automation
- name: "Upgrade Tool Selection"
prompt: "Recommend when and how to use: `.NET Upgrade Assistant`, `dotnet list package --outdated`, `dotnet migrate`, and `graph.json` dependency visualization."
- name: "Analysis Script Generation"
prompt: "Generate scripts or prompts for analyzing dependency graphs before upgrading. Propose AI-assisted prompts for Copilot to identify upgrade issues automatically."
- name: "Multi-Repository Validation"
prompt: "Suggest how to validate automation output across multiple repositories. Create standardized validation workflows for enterprise-scale upgrades."
# Final Validation & Delivery
- name: "Final Solution Validation"
prompt: "Generate validation steps to confirm the final upgraded solution passes all validation checks. Suggest production deployment verification steps post-upgrade."
- name: "Deployment Readiness Confirmation"
prompt: "Recommend generating final test results and build artifacts. Create a checklist summarizing completion across projects (builds/tests/deployment)."
- name: "Release Documentation"
prompt: "Generate a release note summarizing framework changes and CI/CD updates. Create comprehensive upgrade summary documentation."
---
@@ -2,6 +2,6 @@
{
public interface IEventBus
{
Task PublishAsync<T>(T message, CancellationToken cancellationToken);
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
}
}
@@ -6,6 +6,8 @@
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken);
Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
@@ -15,6 +15,9 @@ namespace Musify.Application.Configuration
public class PlayListRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "PlayLists";
@@ -42,27 +45,36 @@ namespace Musify.Application.Configuration
[Required]
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
public string SmallPicturesPath => Path.Combine(ParentFolders, SmallPicturesFolder);
public string SmallPicturesPath => CombineKey(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => Path.Combine(ParentFolders, MediumPicturesFolder);
public string MediumPicturesPath => CombineKey(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => Path.Combine(ParentFolders, LargePicturesFolder);
public string LargePicturesPath => CombineKey(ParentFolders, LargePicturesFolder);
public string PresetSmallPicturePath => Path.Combine(ParentFolders, PresetSmallPicture);
public string PresetSmallPicturePath => CombineKey(ParentFolders, PresetSmallPicture);
public string PresetMediumPicturePath => Path.Combine(ParentFolders, PresetMediumPicture);
public string PresetMediumPicturePath => CombineKey(ParentFolders, PresetMediumPicture);
public string PresetLargePicturePath => Path.Combine(ParentFolders, PresetLargePicture);
public string PresetLargePicturePath => CombineKey(ParentFolders, PresetLargePicture);
public string OriginalPicturesPath => Path.Combine(ParentFolders, OriginalPicturesFolder);
public string OriginalPicturesPath => CombineKey(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => Path.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildOriginalPicturePath(string pictureName) => CombineKey(ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildSmallPicturePath(string pictureName) => Path.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildOriginalPicturePath(Guid userId, string pictureName) => CombineKey(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => Path.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildSmallPicturePath(string pictureName) => CombineKey(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => Path.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => CombineKey(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => CombineKey(ParentFolders, LargePicturesFolder, pictureName);
static string CombineKey(params string[] segments)
{
return string.Join('/', segments
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim().Trim('/', '\\')));
}
}
public class PlayListPicturesSizes
+29 -15
View File
@@ -15,6 +15,9 @@ namespace Musify.Application.Configuration
public class TrackRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolder { get; set; } = "Tracks";
@@ -48,35 +51,46 @@ namespace Musify.Application.Configuration
[Required]
public string ProcessedAudioFolder { get; set; } = "ProcessedAudios";
public string SmallPicturesPath => Path.Combine(ParentFolder, SmallPicturesFolder);
public string SmallPicturesPath => CombineKey(ParentFolder, SmallPicturesFolder);
public string MediumPicturesPath => Path.Combine(ParentFolder, MediumPicturesFolder);
public string MediumPicturesPath => CombineKey(ParentFolder, MediumPicturesFolder);
public string LargePicturesPath => Path.Combine(ParentFolder, LargePicturesFolder);
public string LargePicturesPath => CombineKey(ParentFolder, LargePicturesFolder);
public string PresetSmallPicturePath => Path.Combine(ParentFolder, PresetSmallPicture);
public string PresetSmallPicturePath => CombineKey(ParentFolder, PresetSmallPicture);
public string PresetMediumPicturePath => Path.Combine(ParentFolder, PresetMediumPicture);
public string PresetMediumPicturePath => CombineKey(ParentFolder, PresetMediumPicture);
public string PresetLargePicturePath => Path.Combine(ParentFolder, PresetLargePicture);
public string PresetLargePicturePath => CombineKey(ParentFolder, PresetLargePicture);
public string OriginalPicturesPath => Path.Combine(ParentFolder, OriginalPicturesFolder);
public string OriginalPicturesPath => CombineKey(ParentFolder, OriginalPicturesFolder);
public string OriginalAudiosPath => Path.Combine(ParentFolder, OriginalAudiosFolder);
public string OriginalAudiosPath => CombineKey(ParentFolder, OriginalAudiosFolder);
public string ProcessedAudiosPath => Path.Combine(ParentFolder, ProcessedAudioFolder);
public string ProcessedAudiosPath => CombineKey(ParentFolder, ProcessedAudioFolder);
public string BuildOriginalPicturePath(string pictureName) => Path.Combine(OriginalPicturesPath, pictureName);
public string BuildOriginalPicturePath(string pictureName) => CombineKey(OriginalPicturesPath, pictureName);
public string BuildSmallPicturePath(string pictureName) => Path.Combine(SmallPicturesPath, pictureName);
public string BuildOriginalPicturePath(Guid userId, string pictureName) => CombineKey(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName);
public string BuildMediumPicturePath(string pictureName) => Path.Combine(MediumPicturesPath, pictureName);
public string BuildSmallPicturePath(string pictureName) => CombineKey(SmallPicturesPath, pictureName);
public string BuildLargePicturePath(string pictureName) => Path.Combine(LargePicturesPath, pictureName);
public string BuildMediumPicturePath(string pictureName) => CombineKey(MediumPicturesPath, pictureName);
public string BuildOriginalAudioPath(string audioName) => Path.Combine(OriginalAudiosPath, audioName);
public string BuildLargePicturePath(string pictureName) => CombineKey(LargePicturesPath, pictureName);
public string BuildProcessedAudioPath(string audioName) => Path.Combine(ProcessedAudiosPath, audioName);
public string BuildOriginalAudioPath(string audioName) => CombineKey(OriginalAudiosPath, audioName);
public string BuildOriginalAudioPath(Guid userId, string audioName) => CombineKey(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName);
public string BuildProcessedAudioPath(string audioName) => CombineKey(ProcessedAudiosPath, audioName);
static string CombineKey(params string[] segments)
{
return string.Join('/', segments
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim().Trim('/', '\\')));
}
}
public class TrackPicturesSizes
@@ -1,8 +1,6 @@
using Ardalis.Result;
using Mediator;
using Musify.Application.Abstractions.Application;
using Musify.Application.PlayLists.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists.Commands
{
@@ -10,22 +8,6 @@ namespace Musify.Application.PlayLists.Commands
Guid UserId,
string Name,
string Description,
IFileData? Picture)
: ICommand<Result<PlayListApplicationResponse>>
{
public static PlayList ToEntity(CreatePlayListCommand command, string originalKey, string smallKey, string mediumKey, string largeKey)
{
return new PlayList
{
UserId = command.UserId,
Name = command.Name,
NormalizedName = command.Name.Trim().ToUpperInvariant(),
Description = command.Description,
OriginalPictureName = originalKey,
SmallPictureName = smallKey,
MediumPictureName = mediumKey,
LargePictureName = largeKey,
};
}
}
string? OriginalPictureName)
: ICommand<Result<PlayListApplicationResponse>>;
}
@@ -0,0 +1,12 @@
using Ardalis.Result;
using Mediator;
using Musify.Application.PlayLists.Responses;
namespace Musify.Application.PlayLists.Commands
{
public record RequestPlayListPictureUploadCommand(
Guid UserId,
string FileType,
string ContentType)
: ICommand<Result<PlayListPictureUploadResponse>>;
}
@@ -1,6 +1,5 @@
using Ardalis.Result;
using Mediator;
using Musify.Application.Abstractions.Application;
using Musify.Application.PlayLists.Responses;
namespace Musify.Application.PlayLists.Commands
@@ -10,6 +9,6 @@ namespace Musify.Application.PlayLists.Commands
Guid PlayListId,
string? NewName,
string? NewDescription,
IFileData? NewPicture)
string? NewOriginalPictureName)
: ICommand<Result<PlayListApplicationResponse>>;
}
@@ -3,7 +3,6 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Abstractions.Application;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Events;
using Musify.Application.PlayLists.Commands;
@@ -15,7 +14,6 @@ namespace Musify.Application.PlayLists.Handlers
public class CreatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
IStorageService storageService,
ILogger<CreatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
@@ -32,79 +30,43 @@ namespace Musify.Application.PlayLists.Handlers
return Result.NotFound($"User {request.UserId} not found");
}
var playList = CreatePlayListCommand.ToEntity(
request,
playListConfiguration.Routes.PresetOriginalPicture,
playListConfiguration.Routes.PresetSmallPicture,
playListConfiguration.Routes.PresetMediumPicture,
playListConfiguration.Routes.PresetLargePicture);
var originalPictureName = request.OriginalPictureName ?? playListConfiguration.Routes.PresetOriginalPicture;
if (request.Picture is not null)
var playList = new PlayList
{
var uploadResult = await UploadPictureAsync(playList, request.Picture, cancellationToken);
if (!uploadResult.IsSuccess)
return uploadResult;
UserId = request.UserId,
Name = request.Name,
NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = request.Description,
OriginalPictureName = originalPictureName,
SmallPictureName = playListConfiguration.Routes.PresetSmallPicture,
MediumPictureName = playListConfiguration.Routes.PresetMediumPicture,
LargePictureName = playListConfiguration.Routes.PresetLargePicture
};
await database.PlayLists.AddAsync(playList, cancellationToken);
if (request.OriginalPictureName is not null)
{
var publishResult = await PublishPictureUpdateEventAsync(playList, cancellationToken);
if (!publishResult.IsSuccess)
return publishResult;
}
try
{
await database.PlayLists.AddAsync(playList, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
await RollbackAllPicturesAsync(playList, cancellationToken);
return Result.Error($"Failed to create playlist for user {request.UserId}");
}
if (request.Picture is not null)
{
var publishResult = await PublishPictureUpdateEventAsync(playList, cancellationToken);
if (!publishResult.IsSuccess)
{
await TryRollbackOnPublishFailureAsync(playList, cancellationToken);
return publishResult;
}
}
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return Result.Created(PlayListApplicationResponse.FromEntity(playList));
}
async Task TryRollbackOnPublishFailureAsync(PlayList playList, CancellationToken cancellationToken)
{
try
{
database.PlayLists.Remove(playList);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback playlist creation after publish failure for playlist {PlayListId}", playList.Id);
}
await RollbackAllPicturesAsync(playList, cancellationToken);
}
async Task<Result> UploadPictureAsync(PlayList playList, IFileData picture, CancellationToken cancellationToken)
{
var pictureName = Guid.NewGuid() + picture.FileType;
var pictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(pictureName);
try
{
await storageService.UploadFileAsync(picture.FileStream, picture.ContentType, storageConfiguration.Bucket, pictureKey, cancellationToken);
playList.OriginalPictureName = pictureName;
return Result.Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to upload picture for playlist {PlayListId}", playList.Id);
return Result.Error($"Failed to upload picture for playlist {playList.Id}");
}
}
async Task<Result> PublishPictureUpdateEventAsync(PlayList playList, CancellationToken cancellationToken)
{
try
@@ -112,7 +74,7 @@ namespace Musify.Application.PlayLists.Handlers
var updateEvent = new UpdatePlayListPictureEvent(
playList.Id,
storageConfiguration.Bucket,
playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName),
playListConfiguration.Routes.BuildOriginalPicturePath(playList.UserId, playList.OriginalPictureName),
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
@@ -135,25 +97,5 @@ namespace Musify.Application.PlayLists.Handlers
return Result.Error($"Failed to publish picture update event for playlist {playList.Id}");
}
}
async Task RollbackAllPicturesAsync(PlayList playList, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(playList.OriginalPictureName))
return;
if (playList.OriginalPictureName == playListConfiguration.Routes.PresetOriginalPicture)
return;
var originalPicturePath = playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName);
try
{
await storageService.RemoveFileAsync(storageConfiguration.Bucket, originalPicturePath, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback picture upload for playlist {PlayListId}", playList.Id);
}
}
}
}
@@ -33,23 +33,12 @@ namespace Musify.Application.PlayLists.Handlers
var mediumPictureName = playList.MediumPictureName;
var largePictureName = playList.LargePictureName;
var originalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName);
var originalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(playList.UserId, playList.OriginalPictureName);
var smallPictureKey = playListConfiguration.Routes.BuildSmallPicturePath(playList.SmallPictureName);
var mediumPictureKey = playListConfiguration.Routes.BuildMediumPicturePath(playList.MediumPictureName);
var largePictureKey = playListConfiguration.Routes.BuildLargePicturePath(playList.LargePictureName);
try
{
database.PlayLists.Remove(playList);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete playlist {PlayListId}", request.PlayListId);
return Result.Error($"Failed to delete playlist {request.PlayListId}");
}
await PublishRemoveFileEventsBestEffortAsync(
var publishResult = await PublishRemoveFileEventsAsync(
playList.Id,
originalPictureName,
smallPictureName,
@@ -62,10 +51,24 @@ namespace Musify.Application.PlayLists.Handlers
playListConfiguration.Routes,
cancellationToken);
if (!publishResult.IsSuccess)
return publishResult;
try
{
database.PlayLists.Remove(playList);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to delete playlist {PlayListId}", request.PlayListId);
return Result.Error($"Failed to delete playlist {request.PlayListId}");
}
return Result.NoContent();
}
async Task PublishRemoveFileEventsBestEffortAsync(
async Task<Result> PublishRemoveFileEventsAsync(
Guid playListId,
string originalPictureName,
string smallPictureName,
@@ -79,20 +82,31 @@ namespace Musify.Application.PlayLists.Handlers
CancellationToken cancellationToken)
{
if (originalPictureName != playListRoutes.PresetOriginalPicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, originalPictureKey, cancellationToken);
{
var result = await PublishRemoveFileEventAsync(playListId, originalPictureKey, cancellationToken);
if (!result.IsSuccess)
return result;
}
if (smallPictureName != playListRoutes.PresetSmallPicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, smallPictureKey, cancellationToken);
{
var result = await PublishRemoveFileEventAsync(playListId, smallPictureKey, cancellationToken);
if (!result.IsSuccess)
return result;
}
if (mediumPictureName != playListRoutes.PresetMediumPicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, mediumPictureKey, cancellationToken);
{
var result = await PublishRemoveFileEventAsync(playListId, mediumPictureKey, cancellationToken);
if (!result.IsSuccess)
return result;
}
if (largePictureName != playListRoutes.PresetLargePicture)
await PublishRemoveFileEventIgnoringErrorsAsync(playListId, largePictureKey, cancellationToken);
{
var result = await PublishRemoveFileEventAsync(playListId, largePictureKey, cancellationToken);
if (!result.IsSuccess)
return result;
}
async Task PublishRemoveFileEventIgnoringErrorsAsync(Guid playListId, string key, CancellationToken cancellationToken)
{
var result = await PublishRemoveFileEventAsync(playListId, key, cancellationToken);
if (!result.IsSuccess)
logger.LogWarning("Remove file event publish failed for playlist {PlayListId} with key {Key}", playListId, key);
return Result.Success();
}
async Task<Result> PublishRemoveFileEventAsync(Guid playListId, string key, CancellationToken cancellationToken)
@@ -0,0 +1,88 @@
using Ardalis.Result;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Configuration;
using Musify.Application.PlayLists.Commands;
using Musify.Application.PlayLists.Responses;
namespace Musify.Application.PlayLists.Handlers
{
public class RequestPlayListPictureUploadCommandHandler(
IDatabase database,
IStorageService storageService,
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, Result<PlayListPictureUploadResponse>>
{
static readonly string[] AllowedExtensions = [".webp", ".png", ".jpg", ".jpeg"];
const int ExpiresInSeconds = 600;
public async ValueTask<Result<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Result.NotFound($"User {request.UserId} not found");
}
var extensionResult = ValidateExtension(request.FileType);
if (!extensionResult.IsSuccess)
return Result.Invalid(extensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
if (string.IsNullOrWhiteSpace(request.ContentType))
return Result.Invalid(new ValidationError("ContentType is required"));
var pictureName = Guid.NewGuid() + extensionResult.Value;
var key = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureName);
try
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
key,
request.ContentType,
TimeSpan.FromSeconds(ExpiresInSeconds),
cancellationToken);
return Result.Success(new PlayListPictureUploadResponse(
storageConfiguration.Bucket,
key,
pictureName,
request.ContentType,
ExpiresInSeconds,
uploadUrl));
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
return Result.Error("Failed to generate upload URL");
}
}
static Result<string> ValidateExtension(string fileType)
{
if (string.IsNullOrWhiteSpace(fileType))
return Result.Error("FileType is required");
var extension = fileType.Trim();
if (!extension.StartsWith('.'))
extension = "." + extension;
extension = extension.ToLowerInvariant();
if (extension.Contains('/') || extension.Contains('\\'))
return Result.Error("Invalid FileType");
if (!AllowedExtensions.Contains(extension))
return Result.Error($"Unsupported FileType '{extension}'");
return Result.Success(extension);
}
}
}
@@ -3,23 +3,21 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Abstractions.Application;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Events;
using Musify.Application.PlayLists.Commands;
using Musify.Application.PlayLists.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists.Handlers
{
public class UpdatePlayListCommandHandler(IEventBus eventBus, IDatabase database, IStorageService storageHandler,
ILogger<UpdatePlayListCommandHandler> logger, ApplicationStorageConfiguration storageConfiguration,
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
ILogger<UpdatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: ICommandHandler<UpdatePlayListCommand, Result<PlayListApplicationResponse>>
{
record UpdatePictureOutcome(Result Result, bool PictureUpdated, string? UploadedPictureKey);
record PreviousPlayListState(string Name, string NormalizedName, string Description, string OriginalPictureName);
public async ValueTask<Result<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
{
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
@@ -35,14 +33,25 @@ namespace Musify.Application.PlayLists.Handlers
return Result.Unauthorized();
}
var previousState = CapturePreviousState(playListEntity);
if (request.NewName is not null)
{
playListEntity.Name = request.NewName;
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
}
UpdateName(playListEntity, request.NewName);
UpdateDescription(playListEntity, request.NewDescription);
if (request.NewDescription is not null)
{
playListEntity.Description = request.NewDescription;
}
var pictureOutcome = await UpdatePicture(playListEntity, request.NewPicture, cancellationToken);
if (!pictureOutcome.Result.IsSuccess)
return pictureOutcome.Result;
if (request.NewOriginalPictureName is not null)
{
playListEntity.OriginalPictureName = request.NewOriginalPictureName;
var publishResult = await PublishPlayListPictureEvent(playListEntity.UserId, playListEntity.Id, playListEntity.OriginalPictureName, cancellationToken);
if (!publishResult.IsSuccess)
return publishResult;
}
try
{
@@ -51,86 +60,22 @@ namespace Musify.Application.PlayLists.Handlers
}
catch (Exception exception)
{
if (pictureOutcome.PictureUpdated && pictureOutcome.UploadedPictureKey is not null)
await Rollback(pictureOutcome.UploadedPictureKey, cancellationToken);
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
return Result.Error($"Failed to update playlist {playListEntity.Id}");
}
if (pictureOutcome.PictureUpdated)
{
var publishResult = await PublishPlayListPictureEvent(playListEntity, cancellationToken);
if (!publishResult.IsSuccess)
{
await TryRollbackAfterPublishFailureAsync(
playListEntity,
previousState,
pictureOutcome.UploadedPictureKey,
cancellationToken);
return Result.Error($"Failed to update playlist {playListEntity.Id}");
}
}
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
return Result.Success(PlayListApplicationResponse.FromEntity(playListEntity));
}
void UpdateName(PlayList playList, string? newName)
{
if (newName is null)
return;
playList.Name = newName;
playList.NormalizedName = newName.Trim().ToUpperInvariant();
}
void UpdateDescription(PlayList playList, string? newDescription)
{
if (newDescription is null)
return;
playList.Description = newDescription;
}
static PreviousPlayListState CapturePreviousState(PlayList playList)
=> new(playList.Name, playList.NormalizedName, playList.Description, playList.OriginalPictureName);
async Task<UpdatePictureOutcome> UpdatePicture(
PlayList playList,
IFileData? newPicture,
CancellationToken cancellationToken)
{
if (newPicture is null)
return new UpdatePictureOutcome(Result.Success(), false, null);
var pictureName = Guid.NewGuid() + newPicture.FileType;
var pictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(pictureName);
try
{
await storageHandler.UploadFileAsync(
newPicture.FileStream,
newPicture.ContentType,
storageConfiguration.Bucket,
pictureKey,
cancellationToken);
}
catch
{
return new UpdatePictureOutcome(Result.Error("Failed to upload picture to storage"), false, null);
}
playList.OriginalPictureName = pictureName;
return new UpdatePictureOutcome(Result.Success(), true, pictureKey);
}
async Task<Result> PublishPlayListPictureEvent(PlayList playList, CancellationToken cancellationToken)
async Task<Result> PublishPlayListPictureEvent(Guid userId, Guid playListId, string originalPictureName, CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(new UpdatePlayListPictureEvent(
playList.Id,
playListId,
storageConfiguration.Bucket,
playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName),
playListConfiguration.Routes.BuildOriginalPicturePath(userId, originalPictureName),
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
@@ -148,50 +93,8 @@ namespace Musify.Application.PlayLists.Handlers
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish playlist picture update event for playlist {PlayListId}", playList.Id);
return Result.Error($"Failed to publish playlist {playList.Id} picture update event");
}
}
async Task Rollback(string pictureKey, CancellationToken cancellationToken)
{
await storageHandler.RemoveFileAsync(
storageConfiguration.Bucket,
pictureKey,
cancellationToken);
logger.LogInformation("Rolled back uploaded picture {PictureKey} from storage", pictureKey);
}
async Task TryRollbackAfterPublishFailureAsync(
PlayList playList,
PreviousPlayListState previousState,
string? uploadedPictureKey,
CancellationToken cancellationToken)
{
playList.Name = previousState.Name;
playList.NormalizedName = previousState.NormalizedName;
playList.Description = previousState.Description;
playList.OriginalPictureName = previousState.OriginalPictureName;
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback playlist state after publish failure for playlist {PlayListId}", playList.Id);
}
if (uploadedPictureKey is null)
return;
try
{
await Rollback(uploadedPictureKey, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback uploaded picture after publish failure for playlist {PlayListId}", playList.Id);
logger.LogError(exception, "Failed to publish playlist picture update event for playlist {PlayListId}", playListId);
return Result.Error($"Failed to publish playlist {playListId} picture update event");
}
}
}
@@ -0,0 +1,10 @@
namespace Musify.Application.PlayLists.Responses
{
public record PlayListPictureUploadResponse(
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
}
@@ -1,30 +1,13 @@
using Ardalis.Result;
using Mediator;
using Musify.Application.Abstractions.Application;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Tracks.Commands
{
public record CreateTrackCommand(
Guid UserId,
string Title,
IFileData Picture,
IFileData Audio)
: ICommand<Result<TrackApplicationResponse> >
{
public static Track ToEntity(CreateTrackCommand command, string smallPictureName, string mediumPictureName, string largePictureName)
{
return new Track
{
Title = command.Title,
NormalizedTitle = command.Title.ToUpperInvariant(),
OriginalPictureName = Guid.NewGuid() + command.Picture.FileType,
OriginalAudioName = Guid.NewGuid() + command.Audio.FileType,
SmallPictureName = smallPictureName,
MediumPictureName = mediumPictureName,
LargePictureName = largePictureName
};
}
}
string OriginalPictureName,
string OriginalAudioName)
: ICommand<Result<TrackApplicationResponse>>;
}
@@ -0,0 +1,14 @@
using Ardalis.Result;
using Mediator;
using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks.Commands
{
public record RequestTrackUploadUrlsCommand(
Guid UserId,
string PictureFileType,
string PictureContentType,
string AudioFileType,
string AudioContentType)
: ICommand<Result<TrackUploadUrlsResponse>>;
}
@@ -11,8 +11,12 @@ using Musify.Domain.Entities;
namespace Musify.Application.Tracks.Handler
{
public class CreateTrackCommandHandler(IDatabase database, IEventBus eventBus, IStorageService storageHandler,
ILogger<CreateTrackCommandHandler> logger, ApplicationStorageConfiguration storageConfiguration, TrackConfiguration trackConfiguration)
public class CreateTrackCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<CreateTrackCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration)
: ICommandHandler<CreateTrackCommand, Result<TrackApplicationResponse>>
{
public async ValueTask<Result<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
@@ -25,102 +29,50 @@ namespace Musify.Application.Tracks.Handler
return Result.NotFound($"User {request.UserId} not found");
}
var trackEntity = CreateTrackCommand.ToEntity(
request,
trackConfiguration.Routes.PresetSmallPicture,
trackConfiguration.Routes.PresetMediumPicture,
trackConfiguration.Routes.PresetLargePicture);
var trackEntity = new Track
{
Title = request.Title,
NormalizedTitle = request.Title.ToUpperInvariant(),
OriginalPictureName = request.OriginalPictureName,
OriginalAudioName = request.OriginalAudioName,
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
PicturesProcessingStatus = ProcessingStatus.Pending,
AudioTranscodeProcessingStatus = ProcessingStatus.Pending
};
var fullPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(trackEntity.OriginalPictureName);
var fullAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(trackEntity.OriginalAudioName);
await database.Tracks.AddAsync(trackEntity, cancellationToken);
var uploadResult = await UploadFilesAsync(request, fullPictureKey, fullAudioKey, cancellationToken);
if (!uploadResult.IsSuccess)
return uploadResult;
var publishPictureResult = await PublishUpdateEvent(request.UserId, trackEntity, cancellationToken);
if (!publishPictureResult.IsSuccess)
return Result.Error($"Failed to create track for {request.Title}");
var publishTranscodeResult = await PublishTranscodeEvent(request.UserId, trackEntity, cancellationToken);
if (!publishTranscodeResult.IsSuccess)
return Result.Error($"Failed to create track for {request.Title}");
try
{
await database.Tracks.AddAsync(trackEntity, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save track {Title} after uploading files", request.Title);
await RollbackFilesAsync(fullPictureKey, fullAudioKey, cancellationToken);
return Result.Error($"Failed to create track for {request.Title}");
}
var publishPictureResult = await PublishUpdateEvent(trackEntity, cancellationToken);
if (!publishPictureResult.IsSuccess)
{
await RollbackTrackAfterPublishFailureAsync(trackEntity, fullPictureKey, fullAudioKey, cancellationToken);
return Result.Error($"Failed to create track for {request.Title}");
}
var publishTranscodeResult = await PublishTranscodeEvent(trackEntity, cancellationToken);
if (!publishTranscodeResult.IsSuccess)
{
await RollbackTrackAfterPublishFailureAsync(trackEntity, fullPictureKey, fullAudioKey, cancellationToken);
logger.LogError(exception, "Failed to save track {Title}", request.Title);
return Result.Error($"Failed to create track for {request.Title}");
}
return Result.Created(TrackApplicationResponse.FromEntity(trackEntity));
}
async Task<Result> UploadFilesAsync(
CreateTrackCommand request,
string pictureKey,
string audioKey,
CancellationToken cancellationToken)
{
try
{
await storageHandler.UploadFileAsync(
request.Picture.FileStream,
request.Picture.ContentType,
storageConfiguration.Bucket,
pictureKey,
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to upload picture for track {Title}", request.Title);
return Result.Error($"Failed to upload picture for {request.Title}");
}
try
{
await storageHandler.UploadFileAsync(
request.Audio.FileStream,
request.Audio.ContentType,
storageConfiguration.Bucket,
audioKey,
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to upload audio for track {Title}", request.Title);
await RollbackFilesAsync(pictureKey, audioKey, cancellationToken);
return Result.Error($"Failed to upload audio for {request.Title}");
}
return Result.Success();
}
async Task RollbackFilesAsync(string pictureKey, string audioKey, CancellationToken cancellationToken)
{
await storageHandler.RemoveFileAsync(storageConfiguration.Bucket, pictureKey, cancellationToken);
await storageHandler.RemoveFileAsync(storageConfiguration.Bucket, audioKey, cancellationToken);
logger.LogInformation("Rolled back uploaded files {PictureKey}, {AudioKey}", pictureKey, audioKey);
}
async Task<Result> PublishUpdateEvent(Track track, CancellationToken cancellationToken)
async Task<Result> PublishUpdateEvent(Guid userId, Track track, CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(new UpdateTrackPictureEvent(
track.Id,
storageConfiguration.Bucket,
trackConfiguration.Routes.BuildOriginalPicturePath(track.OriginalPictureName),
trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.OriginalPictureName),
new ImageSize(
trackConfiguration.Routes.SmallPicturesPath,
trackConfiguration.PicturesSizes.SmallPictureWidth,
@@ -142,7 +94,7 @@ namespace Musify.Application.Tracks.Handler
}
}
async Task<Result> PublishTranscodeEvent(Track track, CancellationToken cancellationToken)
async Task<Result> PublishTranscodeEvent(Guid userId, Track track, CancellationToken cancellationToken)
{
var destinationFolderAudio = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
@@ -151,7 +103,7 @@ namespace Musify.Application.Tracks.Handler
await eventBus.PublishAsync(new UpdateTrackAudioEvent(
track.Id,
storageConfiguration.Bucket,
trackConfiguration.Routes.BuildOriginalAudioPath(track.OriginalAudioName),
trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.OriginalAudioName),
storageConfiguration.Bucket,
destinationFolderAudio), cancellationToken);
return Result.Success();
@@ -163,30 +115,5 @@ namespace Musify.Application.Tracks.Handler
}
}
async Task RollbackTrackAfterPublishFailureAsync(
Track track,
string pictureKey,
string audioKey,
CancellationToken cancellationToken)
{
try
{
database.Tracks.Remove(track);
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback track {TrackId} persistence after publish failure", track.Id);
}
try
{
await RollbackFilesAsync(pictureKey, audioKey, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to rollback uploaded files after publish failure for track {TrackId}", track.Id);
}
}
}
}
@@ -40,12 +40,21 @@ namespace Musify.Application.Tracks.Handler
return Result.Conflict("Track is currently being processed and cannot be deleted");
}
var previousPicturesStatus = track.PicturesProcessingStatus;
var previousAudioStatus = track.AudioTranscodeProcessingStatus;
track.PicturesProcessingStatus = ProcessingStatus.Processing;
track.AudioTranscodeProcessingStatus = ProcessingStatus.Processing;
try
{
await eventBus.PublishAsync(
new DeleteTrackEvent(track.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId);
return Result.Error($"Failed to delete track {request.TrackId}");
}
try
{
await database.SaveChangesAsync(cancellationToken);
@@ -56,31 +65,6 @@ namespace Musify.Application.Tracks.Handler
return Result.Error($"Failed to delete track {request.TrackId}");
}
try
{
await eventBus.PublishAsync(
new DeleteTrackEvent(track.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId);
track.PicturesProcessingStatus = previousPicturesStatus;
track.AudioTranscodeProcessingStatus = previousAudioStatus;
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception rollbackException)
{
logger.LogError(rollbackException, "Failed to rollback track {TrackId} processing flags after publish failure", request.TrackId);
}
return Result.Error($"Failed to delete track {request.TrackId}");
}
return Result.NoContent();
}
}
@@ -0,0 +1,110 @@
using Ardalis.Result;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Application.Configuration;
using Musify.Application.Tracks.Commands;
using Musify.Application.Tracks.Responses;
namespace Musify.Application.Tracks.Handler
{
public class RequestTrackUploadUrlsCommandHandler(
IDatabase database,
IStorageService storageService,
ILogger<RequestTrackUploadUrlsCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration)
: ICommandHandler<RequestTrackUploadUrlsCommand, Result<TrackUploadUrlsResponse>>
{
static readonly string[] AllowedPictureExtensions = [".webp", ".png", ".jpg", ".jpeg"];
static readonly string[] AllowedAudioExtensions = [".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".opus"];
const int ExpiresInSeconds = 600;
public async ValueTask<Result<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Result.NotFound($"User {request.UserId} not found");
}
var pictureExtensionResult = ValidateExtension(request.PictureFileType, AllowedPictureExtensions);
if (!pictureExtensionResult.IsSuccess)
return Result.Invalid(pictureExtensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
var audioExtensionResult = ValidateExtension(request.AudioFileType, AllowedAudioExtensions);
if (!audioExtensionResult.IsSuccess)
return Result.Invalid(audioExtensionResult.Errors.Select(error => new ValidationError(error)).ToArray());
if (string.IsNullOrWhiteSpace(request.PictureContentType))
return Result.Invalid(new ValidationError("PictureContentType is required"));
if (string.IsNullOrWhiteSpace(request.AudioContentType))
return Result.Invalid(new ValidationError("AudioContentType is required"));
var pictureName = Guid.NewGuid() + pictureExtensionResult.Value;
var audioName = Guid.NewGuid() + audioExtensionResult.Value;
var pictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureName);
var audioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioName);
try
{
var pictureUploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
pictureKey,
request.PictureContentType,
TimeSpan.FromSeconds(ExpiresInSeconds),
cancellationToken);
var audioUploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
audioKey,
request.AudioContentType,
TimeSpan.FromSeconds(ExpiresInSeconds),
cancellationToken);
return Result.Success(new TrackUploadUrlsResponse(
storageConfiguration.Bucket,
pictureKey,
pictureName,
request.PictureContentType,
pictureUploadUrl,
audioKey,
audioName,
request.AudioContentType,
audioUploadUrl,
ExpiresInSeconds));
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate track upload URLs for user {UserId}", request.UserId);
return Result.Error("Failed to generate upload URLs");
}
}
static Result<string> ValidateExtension(string fileType, string[] allowedExtensions)
{
if (string.IsNullOrWhiteSpace(fileType))
return Result.Error("FileType is required");
var extension = fileType.Trim();
if (!extension.StartsWith('.'))
extension = "." + extension;
extension = extension.ToLowerInvariant();
if (extension.Contains('/') || extension.Contains('\\'))
return Result.Error("Invalid FileType");
if (!allowedExtensions.Contains(extension))
return Result.Error($"Unsupported FileType '{extension}'");
return Result.Success(extension);
}
}
}
@@ -0,0 +1,14 @@
namespace Musify.Application.Tracks.Responses
{
public record TrackUploadUrlsResponse(
string Bucket,
string PictureKey,
string PictureName,
string PictureContentType,
string PictureUploadUrl,
string AudioKey,
string AudioName,
string AudioContentType,
string AudioUploadUrl,
int ExpiresInSeconds);
}
+1
View File
@@ -16,6 +16,7 @@
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.19" />
<PackageReference Include="MassTransit" Version="9.0.1" />
<PackageReference Include="MassTransit.EntityFrameworkCore" Version="9.0.1" />
<PackageReference Include="MassTransit.RabbitMQ" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
@@ -17,17 +17,20 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files
var sourceFilePath = executeContext.GetVariable<string>(executeContext.Arguments.FilePathVariable);
ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath));
var fileName = Path.GetFileName(sourceFilePath);
var destinationKey = string.Join('/', new[] { executeContext.Arguments.DestinationRoute, fileName }
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim().Trim('/', '\\')));
try
{
await storageHandler.UploadFileAsync(
sourceFilePath,
executeContext.Arguments.DestinationBucket,
Path.Combine(executeContext.Arguments.DestinationRoute, fileName),
destinationKey,
executeContext.CancellationToken);
return executeContext.CompletedWithVariables(new UploadFileToBucketLog(
executeContext.Arguments.DestinationBucket,
executeContext.Arguments.DestinationRoute));
destinationKey));
}
catch (Exception exception)
{
@@ -33,11 +33,11 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
try
{
await RemovePicturesAsync(track, executeContext.CancellationToken);
await RemovePicturesAsync(track, executeContext.Arguments.UserId, executeContext.CancellationToken);
track.PicturesProcessingStatus = ProcessingStatus.Completed;
await database.SaveChangesAsync(executeContext.CancellationToken);
await RemoveAudiosAsync(track, executeContext.CancellationToken);
await RemoveAudiosAsync(track, executeContext.Arguments.UserId, executeContext.CancellationToken);
track.AudioTranscodeProcessingStatus = ProcessingStatus.Completed;
await database.SaveChangesAsync(executeContext.CancellationToken);
@@ -66,9 +66,9 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
}
}
async Task RemovePicturesAsync(Track track, CancellationToken cancellationToken)
async Task RemovePicturesAsync(Track track, Guid userId, CancellationToken cancellationToken)
{
await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalPicturePath(track.OriginalPictureName), cancellationToken);
await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.OriginalPictureName), cancellationToken);
if (!track.IsPicturesProcessed)
{
@@ -83,9 +83,9 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
await RemoveFileAsync(trackConfiguration.Routes.BuildLargePicturePath(track.LargePictureName), cancellationToken);
}
async Task RemoveAudiosAsync(Track track, CancellationToken cancellationToken)
async Task RemoveAudiosAsync(Track track, Guid userId, CancellationToken cancellationToken)
{
await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalAudioPath(track.OriginalAudioName), cancellationToken);
await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.OriginalAudioName), cancellationToken);
if (!track.IsAudioProcessed)
{
@@ -1,5 +1,6 @@
using MassTransit;
using Musify.Infrastructure.MassTransit.Consumers;
using Musify.Infrastructure.Persistence;
namespace Musify.Infrastructure.MassTransit
{
@@ -12,7 +13,18 @@ namespace Musify.Infrastructure.MassTransit
where TConsumer : class, IConsumer
{
busFactoryConfigurator.ReceiveEndpoint(queueName, endpointConfigurator =>
endpointConfigurator.ConfigureConsumer<TConsumer>(busRegistrationContext));
{
endpointConfigurator.UseMessageRetry(retryConfigurator =>
retryConfigurator.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromSeconds(30),
intervalDelta: TimeSpan.FromSeconds(2)));
endpointConfigurator.UseEntityFrameworkOutbox<Database>(busRegistrationContext);
endpointConfigurator.ConfigureConsumer<TConsumer>(busRegistrationContext);
});
}
static void ConfigureExecuteActivityEndpoint<TActivity, TArguments>(
@@ -24,6 +36,15 @@ namespace Musify.Infrastructure.MassTransit
{
busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.ExecuteQueue(endpointName), endpointConfigurator =>
{
endpointConfigurator.UseMessageRetry(retryConfigurator =>
retryConfigurator.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromSeconds(30),
intervalDelta: TimeSpan.FromSeconds(2)));
endpointConfigurator.UseEntityFrameworkOutbox<Database>(busRegistrationContext);
endpointConfigurator.ExecuteActivityHost<TActivity, TArguments>(busRegistrationContext);
});
}
@@ -37,14 +58,34 @@ namespace Musify.Infrastructure.MassTransit
where TLog : class
{
busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.ExecuteQueue(endpointName), endpointConfigurator =>
{
endpointConfigurator.UseMessageRetry(retryConfigurator =>
retryConfigurator.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromSeconds(30),
intervalDelta: TimeSpan.FromSeconds(2)));
endpointConfigurator.UseEntityFrameworkOutbox<Database>(busRegistrationContext);
endpointConfigurator.ExecuteActivityHost<TActivity, TArguments>(
EndpointHelper.BuildCompensateActivityUri(endpointName),
busRegistrationContext)
);
busRegistrationContext);
});
busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.CompensateQueue(endpointName), endpointConfigurator =>
endpointConfigurator.CompensateActivityHost<TActivity, TLog>(busRegistrationContext)
);
{
endpointConfigurator.UseMessageRetry(retryConfigurator =>
retryConfigurator.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromSeconds(30),
intervalDelta: TimeSpan.FromSeconds(2)));
endpointConfigurator.UseEntityFrameworkOutbox<Database>(busRegistrationContext);
endpointConfigurator.CompensateActivityHost<TActivity, TLog>(busRegistrationContext);
});
}
}
}
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.Persistence;
namespace Musify.Infrastructure.MassTransit
{
@@ -18,6 +19,15 @@ namespace Musify.Infrastructure.MassTransit
serviceDescriptors.AddScoped<IEventBus, MassTransitEventBus>();
serviceDescriptors.AddMassTransit(options =>
{
options.AddEntityFrameworkOutbox<Database>(outbox =>
{
outbox.UsePostgres();
outbox.UseBusOutbox();
outbox.QueryDelay = TimeSpan.FromSeconds(1);
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
});
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
ConfigureRabbitMqHost(
busFactoryConfigurator,
@@ -43,6 +53,15 @@ namespace Musify.Infrastructure.MassTransit
serviceDescriptors.AddMassTransit(options =>
{
options.AddEntityFrameworkOutbox<Database>(outbox =>
{
outbox.UsePostgres();
outbox.UseBusOutbox();
outbox.QueryDelay = TimeSpan.FromSeconds(1);
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
});
RegisterConsumersAndActivities(options);
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
@@ -6,7 +6,7 @@ namespace Musify.Infrastructure.MassTransit
public class MassTransitEventBus(IPublishEndpoint publishEndpoint)
: IEventBus
{
public async Task PublishAsync<T>(T message, CancellationToken cancellationToken)
public async Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class
{
ArgumentNullException.ThrowIfNull(message, nameof(message));
@@ -99,7 +99,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
AddResizeActivity(
routingSlipBuilder,
ActivityNames.ResizeMedium,
ActivityNames.ResizeMedium,
ActivityNames.UploadMedium,
bucket,
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
medium);
+10
View File
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using MassTransit;
using Musify.Application.Abstractions.Infrastructure;
using Musify.Domain.Entities;
using Musify.Infrastructure.Configuration;
@@ -13,6 +14,15 @@ namespace Musify.Infrastructure.Persistence
optionsBuilder.UseNpgsql(configuration.ConnectionString);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddInboxStateEntity();
modelBuilder.AddOutboxMessageEntity();
modelBuilder.AddOutboxStateEntity();
}
public DbSet<User> Users => Set<User>();
public DbSet<Track> Tracks => Set<Track>();
@@ -0,0 +1,512 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Musify.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(Database))]
[Migration("20260514095113_AddMassTransitOutbox")]
partial class AddMassTransitOutbox
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("Consumed")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ConsumerId")
.HasColumnType("uuid");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<int>("ReceiveCount")
.HasColumnType("integer");
b.Property<DateTime>("Received")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("Delivered");
b.ToTable("InboxState");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.Property<long>("SequenceNumber")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ConversationId")
.HasColumnType("uuid");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("DestinationAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("EnqueueTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("FaultAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Headers")
.HasColumnType("text");
b.Property<Guid?>("InboxConsumerId")
.HasColumnType("uuid");
b.Property<Guid?>("InboxMessageId")
.HasColumnType("uuid");
b.Property<Guid?>("InitiatorId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<string>("MessageType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("OutboxId")
.HasColumnType("uuid");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<Guid?>("RequestId")
.HasColumnType("uuid");
b.Property<string>("ResponseAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("SentTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("SourceAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("SequenceNumber");
b.HasIndex("EnqueueTime");
b.HasIndex("ExpirationTime");
b.HasIndex("OutboxId", "SequenceNumber")
.IsUnique();
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
.IsUnique();
b.ToTable("OutboxMessage");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
{
b.Property<Guid>("OutboxId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("OutboxId");
b.HasIndex("Created");
b.ToTable("OutboxState");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LargePictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MediumPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("SmallPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("UpdatedDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("PlayLists");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("AddedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("PlayListId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PlayListId");
b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AudioFolderName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("AudioTranscodeProcessingStatus")
.HasColumnType("integer");
b.Property<DateTime>("CreatedDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("LargePictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime?>("LastRetryAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MediumPictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NormalizedTitle")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OriginalAudioName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("OriginalPictureName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("PicturesProcessingStatus")
.HasColumnType("integer");
b.Property<int>("RetryCount")
.HasColumnType("integer");
b.Property<string>("SmallPictureName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedDate")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Tracks");
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<int>("State")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Upload");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<string>("SecondName")
.HasMaxLength(48)
.HasColumnType("character varying(48)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TrackId");
b.HasIndex("UserId");
b.ToTable("UserHasTrack");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
.WithMany()
.HasForeignKey("OutboxId");
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
.WithMany()
.HasForeignKey("InboxMessageId", "InboxConsumerId")
.HasPrincipalKey("MessageId", "ConsumerId");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany("PlayLists")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
{
b.HasOne("Musify.Domain.Entities.PlayList", "PlayList")
.WithMany("PlayListTracks")
.HasForeignKey("PlayListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany("PlayListTracks")
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlayList");
b.Navigation("Track");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany("UserTracks")
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany("UserTracks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Track");
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Navigation("PlayListTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
{
b.Navigation("PlayListTracks");
b.Navigation("UserTracks");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
{
b.Navigation("PlayLists");
b.Navigation("UserTracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,142 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddMassTransitOutbox : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "InboxState",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MessageId = table.Column<Guid>(type: "uuid", nullable: false),
ConsumerId = table.Column<Guid>(type: "uuid", nullable: false),
LockId = table.Column<Guid>(type: "uuid", nullable: false),
RowVersion = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
Received = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ReceiveCount = table.Column<int>(type: "integer", nullable: false),
ExpirationTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Consumed = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
LastSequenceNumber = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_InboxState", x => x.Id);
table.UniqueConstraint("AK_InboxState_MessageId_ConsumerId", x => new { x.MessageId, x.ConsumerId });
});
migrationBuilder.CreateTable(
name: "OutboxState",
columns: table => new
{
OutboxId = table.Column<Guid>(type: "uuid", nullable: false),
LockId = table.Column<Guid>(type: "uuid", nullable: false),
RowVersion = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
Created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
LastSequenceNumber = table.Column<long>(type: "bigint", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OutboxState", x => x.OutboxId);
});
migrationBuilder.CreateTable(
name: "OutboxMessage",
columns: table => new
{
SequenceNumber = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
EnqueueTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
SentTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Headers = table.Column<string>(type: "text", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
InboxMessageId = table.Column<Guid>(type: "uuid", nullable: true),
InboxConsumerId = table.Column<Guid>(type: "uuid", nullable: true),
OutboxId = table.Column<Guid>(type: "uuid", nullable: true),
MessageId = table.Column<Guid>(type: "uuid", nullable: false),
ContentType = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
MessageType = table.Column<string>(type: "text", nullable: false),
Body = table.Column<string>(type: "text", nullable: false),
ConversationId = table.Column<Guid>(type: "uuid", nullable: true),
CorrelationId = table.Column<Guid>(type: "uuid", nullable: true),
InitiatorId = table.Column<Guid>(type: "uuid", nullable: true),
RequestId = table.Column<Guid>(type: "uuid", nullable: true),
SourceAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
DestinationAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ResponseAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
FaultAddress = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ExpirationTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OutboxMessage", x => x.SequenceNumber);
table.ForeignKey(
name: "FK_OutboxMessage_InboxState_InboxMessageId_InboxConsumerId",
columns: x => new { x.InboxMessageId, x.InboxConsumerId },
principalTable: "InboxState",
principalColumns: new[] { "MessageId", "ConsumerId" });
table.ForeignKey(
name: "FK_OutboxMessage_OutboxState_OutboxId",
column: x => x.OutboxId,
principalTable: "OutboxState",
principalColumn: "OutboxId");
});
migrationBuilder.CreateIndex(
name: "IX_InboxState_Delivered",
table: "InboxState",
column: "Delivered");
migrationBuilder.CreateIndex(
name: "IX_OutboxMessage_EnqueueTime",
table: "OutboxMessage",
column: "EnqueueTime");
migrationBuilder.CreateIndex(
name: "IX_OutboxMessage_ExpirationTime",
table: "OutboxMessage",
column: "ExpirationTime");
migrationBuilder.CreateIndex(
name: "IX_OutboxMessage_InboxMessageId_InboxConsumerId_SequenceNumber",
table: "OutboxMessage",
columns: new[] { "InboxMessageId", "InboxConsumerId", "SequenceNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OutboxMessage_OutboxId_SequenceNumber",
table: "OutboxMessage",
columns: new[] { "OutboxId", "SequenceNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OutboxState_Created",
table: "OutboxState",
column: "Created");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OutboxMessage");
migrationBuilder.DropTable(
name: "InboxState");
migrationBuilder.DropTable(
name: "OutboxState");
}
}
}
@@ -22,6 +22,174 @@ namespace Musify.Infrastructure.Persistence.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("Consumed")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("ConsumerId")
.HasColumnType("uuid");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<int>("ReceiveCount")
.HasColumnType("integer");
b.Property<DateTime>("Received")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("Delivered");
b.ToTable("InboxState");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.Property<long>("SequenceNumber")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("ConversationId")
.HasColumnType("uuid");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uuid");
b.Property<string>("DestinationAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime?>("EnqueueTime")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("FaultAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Headers")
.HasColumnType("text");
b.Property<Guid?>("InboxConsumerId")
.HasColumnType("uuid");
b.Property<Guid?>("InboxMessageId")
.HasColumnType("uuid");
b.Property<Guid?>("InitiatorId")
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<string>("MessageType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("OutboxId")
.HasColumnType("uuid");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<Guid?>("RequestId")
.HasColumnType("uuid");
b.Property<string>("ResponseAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTime>("SentTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("SourceAddress")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("SequenceNumber");
b.HasIndex("EnqueueTime");
b.HasIndex("ExpirationTime");
b.HasIndex("OutboxId", "SequenceNumber")
.IsUnique();
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
.IsUnique();
b.ToTable("OutboxMessage");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
{
b.Property<Guid>("OutboxId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Delivered")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LastSequenceNumber")
.HasColumnType("bigint");
b.Property<Guid>("LockId")
.HasColumnType("uuid");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea");
b.HasKey("OutboxId");
b.HasIndex("Created");
b.ToTable("OutboxState");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.Property<Guid>("Id")
@@ -256,6 +424,18 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("UserHasTrack");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
{
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
.WithMany()
.HasForeignKey("OutboxId");
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
.WithMany()
.HasForeignKey("InboxMessageId", "InboxConsumerId")
.HasPrincipalKey("MessageId", "ConsumerId");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
{
b.HasOne("Musify.Domain.Entities.User", "User")
+24 -1
View File
@@ -48,6 +48,27 @@ namespace Musify.Infrastructure.Services
return await amazonS3.GetPreSignedURLAsync(request);
}
public async Task<string> GetUploadUrlAsync(
string bucket,
string key,
string contentType,
TimeSpan expirationTime,
CancellationToken cancellationToken)
{
var request = new GetPreSignedUrlRequest
{
BucketName = bucket,
Key = key,
Expires = DateTime.UtcNow + expirationTime,
Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS,
Verb = HttpVerb.PUT,
ContentType = contentType
};
logger.LogDebug("Generating pre-signed upload URL for {Bucket}/{Key}", bucket, key);
return await amazonS3.GetPreSignedURLAsync(request);
}
public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken)
{
var request = new DeleteObjectRequest
@@ -74,7 +95,9 @@ namespace Musify.Infrastructure.Services
string fileName = Path.GetFileName(file);
string filePath = Path.Combine(sourceDirectory, file);
var key = Path.Combine(route, fileName);
var key = string.Join('/', new[] { route, fileName }
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim().Trim('/', '\\')));
var upload = new Upload
{
Id = Guid.NewGuid(),
+165
View File
@@ -0,0 +1,165 @@
# MusifyBackend — Resumen de cambios (2026-05-14)
Este documento resume **todos los cambios aplicados hasta ahora** en la rama `master` durante esta iteración.
> Nota: El proyecto compila en `Release` al cierre de estos cambios.
---
## 1) Mensajería robusta: MassTransit + EF Outbox
**Objetivo**: hacer la publicación de eventos confiable y transaccional (DB + mensajes) y tolerar redelivery en consumidores.
### Cambios principales
- Se integró **MassTransit EntityFramework Outbox** con PostgreSQL (`UsePostgres` + `UseBusOutbox`).
- Se añadieron entidades/tablas de **Inbox/Outbox** en el `DbContext`.
- Se generó y añadió una **migración** para las tablas de outbox/inbox.
- Se ajustó la configuración de endpoints para aplicar:
- **Retry** (exponential backoff)
- **Idempotencia** en consumo usando `UseEntityFrameworkOutbox<Database>()`
- Se ajustó el orden de operaciones para el patrón outbox: **publicar evento y luego `SaveChanges`**.
### Archivos involucrados (principalmente)
- `Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs`
- `Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Endpoints.cs`
- `Infrastructure/Persistence/Database.cs`
- `Infrastructure/Persistence/Migrations/20260514095113_AddMassTransitOutbox.cs`
- `Application/Abstractions/Infrastructure/IEventBus.cs`
- `Infrastructure/MassTransit/EventBus/MassTransitEventBus.cs`
---
## 2) Workflows: Courier RoutingSlip para imágenes y audio
**Objetivo**: mantener el pipeline asíncrono (descarga, resize/transcode, upload, update de entidades) con reintentos e idempotencia.
### Fix importante en workflow de imágenes
- Se corrigió el nombre de actividad del paso de **Upload del tamaño Medium** (antes duplicaba el nombre de `ResizeMedium`).
Archivos:
- `Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs`
- `Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs`
### Fix importante en compensación de uploads
- `UploadFileToBucketActivity` ahora registra correctamente el **object key completo** (ruta + filename) para que la compensación borre el objeto correcto.
Archivo:
- `Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs`
---
## 3) Uploads (Opción B): Presigned PUT URL + Create/Update con nombres
**Objetivo**: separar la subida de binarios (S3) de la operación de dominio (Create/Update), evitando rollbacks complejos y haciendo el flujo más robusto.
### Endpoint/Commands añadidos (request presigned URL)
- Playlist picture:
- `Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs`
- `Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs`
- `Application/PlayLists/Responses/PlayListPictureUploadResponse.cs`
- Track picture + audio:
- `Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs`
- `Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs`
- `Application/Tracks/Responses/TrackUploadUrlsResponse.cs`
### Storage: presigned PUT
- Se añadió soporte para generar **presigned PUT URLs**:
- `Application/Abstractions/Infrastructure/IStorageService.cs`
- `Infrastructure/Services/StorageService.cs` (`GetUploadUrlAsync`)
---
## 4) Limpieza: eliminar V1 y normalizar nombres (V2 → “normal”)
**Objetivo**: eliminar comandos/handlers “V1” (subida directa por stream/multipart) y dejar un único set de comandos “normales” con el contrato final.
### Resultado
- `CreatePlayListCommand`, `UpdatePlayListCommand` y `CreateTrackCommand` quedaron con el contrato nuevo (nombres/metadata, no streams).
- Se eliminaron archivos `*V2*` ya innecesarios.
Archivos clave:
- `Application/PlayLists/Commands/CreatePlayListCommand.cs`
- `Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs`
- `Application/PlayLists/Commands/UpdatePlayListCommand.cs`
- `Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs`
- `Application/Tracks/Commands/CreateTrackCommand.cs`
- `Application/Tracks/Handler/CreateTrackCommandHandler.cs`
---
## 5) Prefijo de seguridad: `uploads/{userId}/...` para originales
**Objetivo**: reducir superficie (y colisiones) haciendo que los objetos originales queden naturalmente “namespaced” por usuario.
### Cambios
- Las **keys de originales** (playlist original picture, track original picture, track original audio) ahora se generan bajo:
- `uploads/{userId}/...`
- Se añadieron overloads de rutas para construir keys con `userId`.
- Los handlers de request presigned URL ahora generan keys usando el prefijo con `userId`.
- Los eventos de workflows (sourceKey) se publican con la key prefijada.
- El borrado de playlist borra la key original prefijada.
- El borrado de track (`DeleteTrackActivity`) borra originales usando el `userId` presente en `DeleteTrackArguments`.
Archivos:
- `Application/Configuration/PlayListConfiguration.cs`
- `Application/Configuration/TrackConfiguration.cs`
- `Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs`
- `Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs`
- `Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs`
- `Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs`
- `Application/Tracks/Handler/CreateTrackCommandHandler.cs`
- `Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs`
- `Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs`
---
## 6) Normalización de separadores en keys S3 (evitar `\\` en Windows)
**Objetivo**: evitar que `Path.Combine` introduzca separadores `\\` en keys S3 (lo cual es incorrecto/inesperado en S3, y rompe interoperabilidad).
### Cambios
- Se reemplazaron combinaciones de keys que usaban `Path.Combine` por joins con `/` en puntos críticos.
Archivos:
- `Application/Configuration/PlayListConfiguration.cs`
- `Application/Configuration/TrackConfiguration.cs`
- `Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs`
- `Infrastructure/Services/StorageService.cs`
---
## 7) Validaciones en Application eliminadas (delegadas a API)
**Objetivo**: evitar duplicar validaciones de formato (extensiones/strings) si la capa API ya se encarga.
### Cambios
- Se eliminó el helper de validación creado inicialmente y los checks en handlers.
Archivos:
- Eliminado: `Application/Validation/UploadObjectNameValidator.cs`
- Ajustados:
- `Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs`
- `Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs`
- `Application/Tracks/Handler/CreateTrackCommandHandler.cs`
---
## 8) Documento de mitigaciones (presigned uploads)
Se añadió documentación de hardening/mitigaciones contra abuso/spam en endpoints presigned.
Archivo:
- `docs/presigned-upload-hardening.md`
---
## 9) Notas de compatibilidad / impacto
- Este cambio **rompe compatibilidad** con la API anterior si existían endpoints que aceptaban multipart/streams para Create/Update (ahora se espera el flujo presigned y luego Create/Update con nombres).
- Los objetos originales ahora viven bajo `uploads/{userId}/...`. Objetos antiguos fuera de ese prefijo pueden quedar huérfanos si ya existían de antes.
---
## 10) Estado final
- `dotnet build MusifyBackend.slnx -c Release` ✅
+175
View File
@@ -0,0 +1,175 @@
# Plan — Unificar Deletes con Workflows (RoutingSlip)
## Contexto
Actualmente hay dos problemas de diseño en los deletes:
**PlayLists:** el borrado dispara múltiples `RemoveFileEvent` sueltos desde `DeletePlayListCommandHandler` (4 llamadas separadas para original/small/medium/large) y luego elimina la entidad en DB. Sin workflow, sin trazabilidad.
**Tracks:** ya existe un workflow (`DeleteTrackEvent` → consumer → RoutingSlip → `DeleteTrackActivity`), pero `DeleteTrackActivity` hace **demasiado en un único paso**: carga la entidad, borra todos los archivos de S3 (pictures + audios), y elimina de DB. Esto rompe el principio de actividades granulares y reutilizables, hace que el slip no sea "seguible" paso a paso, y dificulta los reintentos parciales.
La idea es **homogeneizar y corregir ambos**: que los deletes de Track y PlayList sigan un RoutingSlip con actividades pequeñas, ordenadas, repetibles e independientes.
---
## Objetivos
- **Un único camino** de borrado por aggregate (Track / PlayList), basado en **RoutingSlip con actividades granulares**.
- El handler de comando hace lo mínimo: validar ownership/reglas, cambiar estado a `Removing`, y publicar el evento de borrado (outbox).
- El workflow ejecuta pasos **ordenados, reutilizables y reiniciables** individualmente.
- **Reutilizar** `RemoveFileFromBucketActivity` (ya existente) para cada archivo a borrar, en lugar de lógica ad-hoc dentro de una activity monolítica.
- Eliminar `RemoveFileEvent`, `RemoveFileConsumer` y su registration si no tienen otros usos.
No objetivos (por ahora):
- No introducir observabilidad extra (tracing/telemetría) fuera de lo ya existente.
---
## Problemas del enfoque actual
### PlayLists
- El handler implementa orquestación (decide qué archivos borrar y cómo) → acoplamiento alto.
- Publica múltiples `RemoveFileEvent` sueltos, sin agruparlos en un proceso único.
- No hay log de RoutingSlip que permita seguir el progreso/estado.
### Tracks ← **nuevo problema identificado**
- `DeleteTrackActivity` es monolítica: carga entidad, borra pictures, borra audios, elimina de DB, todo en un solo `Execute`.
- No reutiliza `RemoveFileFromBucketActivity` ya existente.
- Si falla a mitad, no hay granularidad de reintento (no se sabe qué paso falló).
- El RoutingSlip tiene un único nodo → no es realmente un "slip" con pasos.
---
## Diseño propuesto
### Actividades compartidas / reutilizables
Ya existe (y se mantiene):
- `RemoveFileFromBucketActivity` — borra un único objeto de S3. Se usará N veces en cada slip.
Se añaden actividades de DB compartibles por aggregate:
- `MarkAsRemovingActivity<TArgs>` — cambia estado de la entidad a `Removing` y persiste. (O una por aggregate si la lógica difiere.)
- `DeleteTrackFromDbActivity` — elimina el Track de EF (cascade elimina relaciones).
- `DeletePlayListFromDbActivity` — elimina la PlayList de EF (cascade elimina relaciones).
### Estructura del RoutingSlip para Track
```
DeleteTrackEvent
└─► DeleteTrackConsumer
└─► DeleteTrackRoutingSlipBuilder
├─ MarkTrackAsRemovingActivity (cambia estado → Removing, persiste)
├─ RemoveFileFromBucketActivity (original picture)
├─ RemoveFileFromBucketActivity (small picture) ← solo si IsPicturesProcessed
├─ RemoveFileFromBucketActivity (medium picture) ← solo si IsPicturesProcessed
├─ RemoveFileFromBucketActivity (large picture) ← solo si IsPicturesProcessed
├─ RemoveFileFromBucketActivity (original audio)
├─ RemoveFileFromBucketActivity (processed audio) ← solo si IsAudioProcessed
└─ DeleteTrackFromDbActivity (Remove() + SaveChanges)
```
> El builder carga la entidad para construir el plan (keys condicionales) antes de arrancar el slip. Las actividades de S3 son idempotentes (NotFound → log Information + continue).
### Estructura del RoutingSlip para PlayList
```
DeletePlayListEvent
└─► DeletePlayListConsumer
└─► DeletePlayListRoutingSlipBuilder
├─ MarkPlayListAsRemovingActivity (cambia estado → Removing, persiste)
├─ RemoveFileFromBucketActivity (original — uploads/{userId}/...) ← solo si no es preset
├─ RemoveFileFromBucketActivity (small) ← solo si no es preset
├─ RemoveFileFromBucketActivity (medium) ← solo si no es preset
├─ RemoveFileFromBucketActivity (large) ← solo si no es preset
└─ DeletePlayListFromDbActivity (Remove() + SaveChanges, cascade elimina PlayListHasTrack)
```
### Handler de comando (ambos aggregates)
```csharp
// DeletePlayListCommandHandler — ANTES
await PublishRemoveFileEventsAsync(...); // 4 eventos sueltos
database.PlayLists.Remove(playlist);
await database.SaveChangesAsync();
// DESPUÉS
await eventBus.PublishAsync(new DeletePlayListEvent(command.PlayListId, command.UserId));
// Nada más. El workflow se encarga de todo.
```
```csharp
// DeleteTrackCommandHandler — ANTES
// (ya publicaba DeleteTrackEvent, se mantiene igual)
await eventBus.PublishAsync(new DeleteTrackEvent(command.TrackId, command.UserId));
// La diferencia es que ahora el slip tiene pasos granulares, no una activity monolítica.
```
---
## Reglas de negocio a preservar
- Ownership se valida en el handler del comando (igual que ahora), antes de publicar el evento.
- PlayList: solo borrar original/sizes si no son presets.
- PlayList: el original está bajo `uploads/{userId}/...` (cambio reciente).
- Track: respetar estados de procesamiento — si audio está `Pending/Processing`, el handler ya bloquea el delete antes de llegar al workflow.
- EF cascade elimina `PlayListHasTrack` al borrar la PlayList → no hace falta activity explícita para ello.
---
## Idempotencia / reintentos
- RoutingSlip + reintentos de endpoint ya configurados en MassTransit.
- Operaciones S3: idempotentes por diseño. Si el key no existe → log `Information` y `Completed()` (no throw).
- DB: si la entidad ya no existe cuando llega `DeleteTrackFromDbActivity` / `DeletePlayListFromDbActivity`, el workflow se considera `Completed` (misma lógica que ahora).
- Estado `Removing`: permite identificar entidades en proceso de borrado si el sistema se reinicia a mitad del slip.
---
## Plan de implementación (paso a paso)
### 1. Inventario
- Confirmar todos los usos de `RemoveFileEvent` / `RemoveFileConsumer` en la solución.
- Si no tienen otros usos fuera de playlist delete → marcarlos para eliminación en el paso 7.
### 2. Refactorizar Track delete ← **nuevo**
- Crear `MarkTrackAsRemovingActivity` (execute-activity): carga Track, setea estado `Removing`, persiste.
- Crear `DeleteTrackFromDbActivity` (execute-activity): carga Track, llama `Remove()`, persiste.
- Actualizar `DeleteTrackRoutingSlipBuilder`:
- Carga el Track para calcular el plan (keys condicionales según `IsPicturesProcessed`, `IsAudioProcessed`).
- Encadena: `MarkTrackAsRemoving` → N × `RemoveFileFromBucket` → `DeleteTrackFromDb`.
- Eliminar `DeleteTrackActivity` monolítica (ya no se usa).
- Ajustar registration DI de MassTransit (quitar activity vieja, registrar las nuevas).
### 3. Crear PlayList delete: evento + consumer + builder
- Añadir `DeletePlayListEvent(PlayListId, UserId)` en `Application/Events`.
- Crear `DeletePlayListConsumer` en Infrastructure: recibe el evento y ejecuta el builder.
- Crear `DeletePlayListRoutingSlipBuilder`:
- Carga la PlayList para calcular el plan (keys condicionales por presets, ruta de original).
- Encadena: `MarkPlayListAsRemoving` → N × `RemoveFileFromBucket` → `DeletePlayListFromDb`.
### 4. Crear actividades de PlayList
- `MarkPlayListAsRemovingActivity` (execute-activity): carga PlayList, setea `Removing`, persiste.
- `DeletePlayListFromDbActivity` (execute-activity): carga PlayList, llama `Remove()`, persiste (cascade).
### 5. Actualizar `RemoveFileFromBucketActivity` para NotFound
- Si el SDK lanza excepción por key inexistente → capturar, log `Information("Key {Key} not found, skipping")`, retornar `Completed()`.
- Si es otro error → log `Error` + throw (reintento normal).
### 6. Cambiar `DeletePlayListCommandHandler`
- Reemplazar `PublishRemoveFileEventsAsync` por `eventBus.PublishAsync(new DeletePlayListEvent(...))`.
- No publicar más `RemoveFileEvent` para playlist delete.
### 7. Ajustar DI / registration MassTransit
- Registrar: `DeletePlayListConsumer`, `MarkPlayListAsRemovingActivity`, `DeletePlayListFromDbActivity`.
- Registrar: `MarkTrackAsRemovingActivity`, `DeleteTrackFromDbActivity`.
- Desregistrar: `DeleteTrackActivity` (monolítica), y si procede `RemoveFileConsumer`.
### 8. Eliminar código muerto
- `DeleteTrackActivity` monolítica.
- `RemoveFileEvent` y `RemoveFileConsumer` (si no tienen otros usos).
- Endpoints y registrations asociados.
### 9. Build y verificación
- `dotnet build -c Release` sin warnings de referencias muertas.
- Smoke test manual: borrar un Track y una PlayList, verificar logs de cada activity en orden.
+95
View File
@@ -0,0 +1,95 @@
# Hardening: abuso de pre-signed uploads (Opción B)
## Contexto
Con la Opción B el backend genera URLs pre-firmadas (pre-signed) para que el cliente suba ficheros directamente a S3, y luego el backend crea/actualiza entidades en DB referenciando `pictureName/audioName`.
Esto mejora latencia y descarga la API, pero abre un vector de abuso: un usuario puede llamar muchas veces a `RequestPlayListPictureUpload` / `RequestTrackUploadUrls` y subir muchos objetos a S3 sin llegar a crear la entidad final (objetos “huérfanos”).
## Qué pasa hoy si el usuario spamea
- Se generan muchas URLs pre-firmadas (costo bajo pero no cero).
- El usuario puede subir muchos objetos a S3 sin relación con una entidad en DB.
- El impacto principal es:
- coste/uso de S3 (storage + requests)
- posible saturación del endpoint de generación de URLs
## Objetivo
Reducir abuso y coste, manteniendo:
- UX simple
- backend sin streams
- workflows asíncronos + MassTransit Outbox
## Mitigaciones recomendadas (pueden coexistir)
### 1) Rate limiting por usuario/IP (primera línea de defensa)
Aplicar limitación en el host/API (no en `Application`). Ejemplos:
- `N` requests/min por `UserId`
- opcional: `M` requests/min por IP
Ventajas:
- Corta abuso sin tocar DB/S3.
- No cambia dominio.
Nota: requiere acceso al proyecto host (controllers/minimal API) para aplicarlo.
### 2) “Upload intents” en DB (control y conciliación)
Persistir una intención de upload cuando se genera un pre-signed URL.
Modelo sugerido (mínimo):
- `UploadIntent`
- `Id`
- `UserId`
- `Bucket`
- `Key`
- `ContentType`
- `ExpiresAt`
- `Status`: `Issued | Consumed | Expired`
- `CreatedAt`
Reglas:
- Al pedir URL (Request*Upload*):
- insertar intent `Issued`
- imponer límite por usuario: p.ej. `MaxActiveIntentsPerUser = 5`
- si supera el límite: devolver `429 Too Many Requests` (o `Conflict`) según convención del API
- Al crear/actualizar entidad (Create*/Update* V2):
- validar que el `pictureName/audioName` recibido corresponde a un intent `Issued` del mismo `UserId` y no expirado
- marcar intent como `Consumed`
Ventajas:
- Previene spam con reglas duras y auditables.
- Permite cleanup de huérfanos con certeza.
Coste:
- entidad + migración + lógica de validación.
### 3) Limitar el scope del pre-signed URL (reducción de daño)
Aplicar restricciones conservadoras:
- Reducir TTL:
- `ExpiresInSeconds` más bajo (p.ej. 60–120s en vez de 600s)
- Validar/limitar:
- `ContentType` permitido
- extensiones permitidas (`FileType`)
- Usar keys temporales por usuario:
- `PlayLists/TempUploads/{userId}/{guid}.{ext}`
- `Tracks/TempUploads/{userId}/{guid}.{ext}`
Luego, en el workflow/worker:
- mover/copiar de `TempUploads/...` a la ruta final (`OriginalPictures/...` / `OriginalAudios/...`)
- borrar el objeto temporal
Ventajas:
- Cleanup masivo sencillo (por prefijo `TempUploads/{userId}`)
- Reduce el impacto de huérfanos.
## Orden de implementación sugerido
1) Rate limiting en API (rápido + mayor impacto)
2) Upload intents en DB (límite de activos + validación al crear/actualizar)
3) Reducir TTL y mover a rutas temporales (`TempUploads/...`) + cleanup
## Parámetros recomendados (config)
- `UploadUrlExpiresInSeconds` (default: 120)
- `MaxActiveUploadIntentsPerUser` (default: 5)
- `AllowedPictureExtensions` / `AllowedAudioExtensions`
- `AllowedContentTypes` (lista blanca)
## Notas
- Estas defensas complementan el Outbox: el Outbox garantiza entrega de eventos tras commit, pero no evita el abuso de generación de URLs o almacenamiento de objetos huérfanos.
+50
View File
@@ -0,0 +1,50 @@
{
"version": 1,
"skills": {
"csharp-async": {
"source": "github/awesome-copilot",
"sourceType": "autoskills-registry",
"computedHash": "213d4b0921836fdf1ea3d218bc3625f48d149463f9bc765aac0eb343f31cfe37"
},
"csharp-docs": {
"source": "github/awesome-copilot",
"sourceType": "autoskills-registry",
"computedHash": "03ba5f2f605625202803d1cc79353e10b4f3ff720b93992aeecc8d88a1e44720"
},
"csharp-mstest": {
"source": "github/awesome-copilot",
"sourceType": "autoskills-registry",
"computedHash": "abb51cb248cb42f92716dc6568da8c33a9ea23af9a34155ef3ba156a61d9693d"
},
"csharp-nunit": {
"source": "github/awesome-copilot",
"sourceType": "autoskills-registry",
"computedHash": "0493f7b95a519b13685335a32c9ff86e4eaca53e44a4f6795d11f72f899c683d"
},
"csharp-tunit": {
"source": "github/awesome-copilot",
"sourceType": "autoskills-registry",
"computedHash": "5c6139f8cf75687df89a7c8c8fe8834676ffb496b632433973c84e128b52c2f6"
},
"csharp-xunit": {
"source": "github/awesome-copilot",
"sourceType": "autoskills-registry",
"computedHash": "808c2d86afd16bccaba5f6d499361eb1da4d3e687a3305aa376ad4d40382e27d"
},
"dotnet-best-practices": {
"source": "github/awesome-copilot",
"sourceType": "autoskills-registry",
"computedHash": "98bd60f0529ffbb6b6319e61e952ce11ae9deb99106212d1070007bc8ae6708f"
},
"dotnet-design-pattern-review": {
"source": "github/awesome-copilot",
"sourceType": "autoskills-registry",
"computedHash": "60e201c6f3bd2832ed3834a8bb75143b2e6647e6026c5cbdad62f7706700c496"
},
"dotnet-upgrade": {
"source": "github/awesome-copilot",
"sourceType": "autoskills-registry",
"computedHash": "77925fe0898ceeab04c574390815e7500a2426a26036a357325ce751f23cce71"
}
}
}