A lot of changes
This commit is contained in:
@@ -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.
|
||||||
@@ -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."
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
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> 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(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task UploadFileAsync(Stream sourceStream, string contentType, 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
|
public class PlayListRoutes
|
||||||
{
|
{
|
||||||
|
[Required]
|
||||||
|
public string UploadsFolder { get; set; } = "uploads";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string ParentFolders { get; set; } = "PlayLists";
|
public string ParentFolders { get; set; } = "PlayLists";
|
||||||
|
|
||||||
@@ -42,27 +45,36 @@ namespace Musify.Application.Configuration
|
|||||||
[Required]
|
[Required]
|
||||||
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
|
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
|
public class PlayListPicturesSizes
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ namespace Musify.Application.Configuration
|
|||||||
|
|
||||||
public class TrackRoutes
|
public class TrackRoutes
|
||||||
{
|
{
|
||||||
|
[Required]
|
||||||
|
public string UploadsFolder { get; set; } = "uploads";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string ParentFolder { get; set; } = "Tracks";
|
public string ParentFolder { get; set; } = "Tracks";
|
||||||
|
|
||||||
@@ -48,35 +51,46 @@ namespace Musify.Application.Configuration
|
|||||||
[Required]
|
[Required]
|
||||||
public string ProcessedAudioFolder { get; set; } = "ProcessedAudios";
|
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
|
public class TrackPicturesSizes
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
using Ardalis.Result;
|
using Ardalis.Result;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Musify.Application.Abstractions.Application;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Commands
|
namespace Musify.Application.PlayLists.Commands
|
||||||
{
|
{
|
||||||
@@ -10,22 +8,6 @@ namespace Musify.Application.PlayLists.Commands
|
|||||||
Guid UserId,
|
Guid UserId,
|
||||||
string Name,
|
string Name,
|
||||||
string Description,
|
string Description,
|
||||||
IFileData? Picture)
|
string? OriginalPictureName)
|
||||||
: ICommand<Result<PlayListApplicationResponse>>
|
: 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 Ardalis.Result;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Musify.Application.Abstractions.Application;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Commands
|
namespace Musify.Application.PlayLists.Commands
|
||||||
@@ -10,6 +9,6 @@ namespace Musify.Application.PlayLists.Commands
|
|||||||
Guid PlayListId,
|
Guid PlayListId,
|
||||||
string? NewName,
|
string? NewName,
|
||||||
string? NewDescription,
|
string? NewDescription,
|
||||||
IFileData? NewPicture)
|
string? NewOriginalPictureName)
|
||||||
: ICommand<Result<PlayListApplicationResponse>>;
|
: ICommand<Result<PlayListApplicationResponse>>;
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Abstractions.Application;
|
|
||||||
using Musify.Application.Abstractions.Infrastructure;
|
using Musify.Application.Abstractions.Infrastructure;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
using Musify.Application.PlayLists.Commands;
|
using Musify.Application.PlayLists.Commands;
|
||||||
@@ -15,7 +14,6 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
public class CreatePlayListCommandHandler(
|
public class CreatePlayListCommandHandler(
|
||||||
IEventBus eventBus,
|
IEventBus eventBus,
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
IStorageService storageService,
|
|
||||||
ILogger<CreatePlayListCommandHandler> logger,
|
ILogger<CreatePlayListCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
PlayListConfiguration playListConfiguration)
|
PlayListConfiguration playListConfiguration)
|
||||||
@@ -32,79 +30,43 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
return Result.NotFound($"User {request.UserId} not found");
|
return Result.NotFound($"User {request.UserId} not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var playList = CreatePlayListCommand.ToEntity(
|
var originalPictureName = request.OriginalPictureName ?? playListConfiguration.Routes.PresetOriginalPicture;
|
||||||
request,
|
|
||||||
playListConfiguration.Routes.PresetOriginalPicture,
|
|
||||||
playListConfiguration.Routes.PresetSmallPicture,
|
|
||||||
playListConfiguration.Routes.PresetMediumPicture,
|
|
||||||
playListConfiguration.Routes.PresetLargePicture);
|
|
||||||
|
|
||||||
if (request.Picture is not null)
|
var playList = new PlayList
|
||||||
{
|
{
|
||||||
var uploadResult = await UploadPictureAsync(playList, request.Picture, cancellationToken);
|
UserId = request.UserId,
|
||||||
if (!uploadResult.IsSuccess)
|
Name = request.Name,
|
||||||
return uploadResult;
|
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
|
try
|
||||||
{
|
{
|
||||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
|
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}");
|
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);
|
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
|
||||||
return Result.Created(PlayListApplicationResponse.FromEntity(playList));
|
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)
|
async Task<Result> PublishPictureUpdateEventAsync(PlayList playList, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -112,7 +74,7 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
var updateEvent = new UpdatePlayListPictureEvent(
|
var updateEvent = new UpdatePlayListPictureEvent(
|
||||||
playList.Id,
|
playList.Id,
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName),
|
playListConfiguration.Routes.BuildOriginalPicturePath(playList.UserId, playList.OriginalPictureName),
|
||||||
new ImageSize(
|
new ImageSize(
|
||||||
playListConfiguration.Routes.SmallPicturesPath,
|
playListConfiguration.Routes.SmallPicturesPath,
|
||||||
playListConfiguration.PicturesSizes.SmallPictureWidth,
|
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}");
|
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 mediumPictureName = playList.MediumPictureName;
|
||||||
var largePictureName = playList.LargePictureName;
|
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 smallPictureKey = playListConfiguration.Routes.BuildSmallPicturePath(playList.SmallPictureName);
|
||||||
var mediumPictureKey = playListConfiguration.Routes.BuildMediumPicturePath(playList.MediumPictureName);
|
var mediumPictureKey = playListConfiguration.Routes.BuildMediumPicturePath(playList.MediumPictureName);
|
||||||
var largePictureKey = playListConfiguration.Routes.BuildLargePicturePath(playList.LargePictureName);
|
var largePictureKey = playListConfiguration.Routes.BuildLargePicturePath(playList.LargePictureName);
|
||||||
|
|
||||||
try
|
var publishResult = await PublishRemoveFileEventsAsync(
|
||||||
{
|
|
||||||
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(
|
|
||||||
playList.Id,
|
playList.Id,
|
||||||
originalPictureName,
|
originalPictureName,
|
||||||
smallPictureName,
|
smallPictureName,
|
||||||
@@ -62,10 +51,24 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
playListConfiguration.Routes,
|
playListConfiguration.Routes,
|
||||||
cancellationToken);
|
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();
|
return Result.NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task PublishRemoveFileEventsBestEffortAsync(
|
async Task<Result> PublishRemoveFileEventsAsync(
|
||||||
Guid playListId,
|
Guid playListId,
|
||||||
string originalPictureName,
|
string originalPictureName,
|
||||||
string smallPictureName,
|
string smallPictureName,
|
||||||
@@ -79,20 +82,31 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (originalPictureName != playListRoutes.PresetOriginalPicture)
|
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)
|
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)
|
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)
|
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)
|
return Result.Success();
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<Result> PublishRemoveFileEventAsync(Guid playListId, string key, CancellationToken cancellationToken)
|
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.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Abstractions.Application;
|
|
||||||
using Musify.Application.Abstractions.Infrastructure;
|
using Musify.Application.Abstractions.Infrastructure;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
using Musify.Application.PlayLists.Commands;
|
using Musify.Application.PlayLists.Commands;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Handlers
|
namespace Musify.Application.PlayLists.Handlers
|
||||||
{
|
{
|
||||||
public class UpdatePlayListCommandHandler(IEventBus eventBus, IDatabase database, IStorageService storageHandler,
|
public class UpdatePlayListCommandHandler(
|
||||||
ILogger<UpdatePlayListCommandHandler> logger, ApplicationStorageConfiguration storageConfiguration,
|
IEventBus eventBus,
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<UpdatePlayListCommandHandler> logger,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
PlayListConfiguration playListConfiguration)
|
PlayListConfiguration playListConfiguration)
|
||||||
: ICommandHandler<UpdatePlayListCommand, Result<PlayListApplicationResponse>>
|
: 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)
|
public async ValueTask<Result<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, 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();
|
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);
|
if (request.NewDescription is not null)
|
||||||
UpdateDescription(playListEntity, request.NewDescription);
|
{
|
||||||
|
playListEntity.Description = request.NewDescription;
|
||||||
|
}
|
||||||
|
|
||||||
var pictureOutcome = await UpdatePicture(playListEntity, request.NewPicture, cancellationToken);
|
if (request.NewOriginalPictureName is not null)
|
||||||
if (!pictureOutcome.Result.IsSuccess)
|
{
|
||||||
return pictureOutcome.Result;
|
playListEntity.OriginalPictureName = request.NewOriginalPictureName;
|
||||||
|
|
||||||
|
var publishResult = await PublishPlayListPictureEvent(playListEntity.UserId, playListEntity.Id, playListEntity.OriginalPictureName, cancellationToken);
|
||||||
|
if (!publishResult.IsSuccess)
|
||||||
|
return publishResult;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -51,86 +60,22 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
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);
|
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
|
||||||
return Result.Error($"Failed to update playlist {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);
|
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
|
||||||
return Result.Success(PlayListApplicationResponse.FromEntity(playListEntity));
|
return Result.Success(PlayListApplicationResponse.FromEntity(playListEntity));
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdateName(PlayList playList, string? newName)
|
async Task<Result> PublishPlayListPictureEvent(Guid userId, Guid playListId, string originalPictureName, CancellationToken cancellationToken)
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await eventBus.PublishAsync(new UpdatePlayListPictureEvent(
|
await eventBus.PublishAsync(new UpdatePlayListPictureEvent(
|
||||||
playList.Id,
|
playListId,
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName),
|
playListConfiguration.Routes.BuildOriginalPicturePath(userId, originalPictureName),
|
||||||
new ImageSize(
|
new ImageSize(
|
||||||
playListConfiguration.Routes.SmallPicturesPath,
|
playListConfiguration.Routes.SmallPicturesPath,
|
||||||
playListConfiguration.PicturesSizes.SmallPictureWidth,
|
playListConfiguration.PicturesSizes.SmallPictureWidth,
|
||||||
@@ -148,50 +93,8 @@ namespace Musify.Application.PlayLists.Handlers
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
logger.LogError(exception, "Failed to publish playlist picture update event 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 {playList.Id} picture update event");
|
return Result.Error($"Failed to publish playlist {playListId} 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Ardalis.Result;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Musify.Application.Abstractions.Application;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks.Commands
|
namespace Musify.Application.Tracks.Commands
|
||||||
{
|
{
|
||||||
public record CreateTrackCommand(
|
public record CreateTrackCommand(
|
||||||
Guid UserId,
|
Guid UserId,
|
||||||
string Title,
|
string Title,
|
||||||
IFileData Picture,
|
string OriginalPictureName,
|
||||||
IFileData Audio)
|
string OriginalAudioName)
|
||||||
: ICommand<Result<TrackApplicationResponse> >
|
: 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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
namespace Musify.Application.Tracks.Handler
|
||||||
{
|
{
|
||||||
public class CreateTrackCommandHandler(IDatabase database, IEventBus eventBus, IStorageService storageHandler,
|
public class CreateTrackCommandHandler(
|
||||||
ILogger<CreateTrackCommandHandler> logger, ApplicationStorageConfiguration storageConfiguration, TrackConfiguration trackConfiguration)
|
IDatabase database,
|
||||||
|
IEventBus eventBus,
|
||||||
|
ILogger<CreateTrackCommandHandler> logger,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
|
TrackConfiguration trackConfiguration)
|
||||||
: ICommandHandler<CreateTrackCommand, Result<TrackApplicationResponse>>
|
: ICommandHandler<CreateTrackCommand, Result<TrackApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<Result<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
|
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");
|
return Result.NotFound($"User {request.UserId} not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var trackEntity = CreateTrackCommand.ToEntity(
|
var trackEntity = new Track
|
||||||
request,
|
{
|
||||||
trackConfiguration.Routes.PresetSmallPicture,
|
Title = request.Title,
|
||||||
trackConfiguration.Routes.PresetMediumPicture,
|
NormalizedTitle = request.Title.ToUpperInvariant(),
|
||||||
trackConfiguration.Routes.PresetLargePicture);
|
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);
|
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||||
var fullAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(trackEntity.OriginalAudioName);
|
|
||||||
|
|
||||||
var uploadResult = await UploadFilesAsync(request, fullPictureKey, fullAudioKey, cancellationToken);
|
var publishPictureResult = await PublishUpdateEvent(request.UserId, trackEntity, cancellationToken);
|
||||||
if (!uploadResult.IsSuccess)
|
if (!publishPictureResult.IsSuccess)
|
||||||
return uploadResult;
|
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
|
try
|
||||||
{
|
{
|
||||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
logger.LogError(exception, "Failed to save track {Title} after uploading files", request.Title);
|
logger.LogError(exception, "Failed to save track {Title}", 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);
|
|
||||||
return Result.Error($"Failed to create track for {request.Title}");
|
return Result.Error($"Failed to create track for {request.Title}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Created(TrackApplicationResponse.FromEntity(trackEntity));
|
return Result.Created(TrackApplicationResponse.FromEntity(trackEntity));
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<Result> UploadFilesAsync(
|
async Task<Result> PublishUpdateEvent(Guid userId, Track track, CancellationToken cancellationToken)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await eventBus.PublishAsync(new UpdateTrackPictureEvent(
|
await eventBus.PublishAsync(new UpdateTrackPictureEvent(
|
||||||
track.Id,
|
track.Id,
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
trackConfiguration.Routes.BuildOriginalPicturePath(track.OriginalPictureName),
|
trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.OriginalPictureName),
|
||||||
new ImageSize(
|
new ImageSize(
|
||||||
trackConfiguration.Routes.SmallPicturesPath,
|
trackConfiguration.Routes.SmallPicturesPath,
|
||||||
trackConfiguration.PicturesSizes.SmallPictureWidth,
|
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());
|
var destinationFolderAudio = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
||||||
|
|
||||||
@@ -151,7 +103,7 @@ namespace Musify.Application.Tracks.Handler
|
|||||||
await eventBus.PublishAsync(new UpdateTrackAudioEvent(
|
await eventBus.PublishAsync(new UpdateTrackAudioEvent(
|
||||||
track.Id,
|
track.Id,
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
trackConfiguration.Routes.BuildOriginalAudioPath(track.OriginalAudioName),
|
trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.OriginalAudioName),
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
destinationFolderAudio), cancellationToken);
|
destinationFolderAudio), cancellationToken);
|
||||||
return Result.Success();
|
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");
|
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.PicturesProcessingStatus = ProcessingStatus.Processing;
|
||||||
track.AudioTranscodeProcessingStatus = 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
|
try
|
||||||
{
|
{
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
@@ -56,31 +65,6 @@ namespace Musify.Application.Tracks.Handler
|
|||||||
return Result.Error($"Failed to delete track {request.TrackId}");
|
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();
|
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);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AWSSDK.S3" Version="4.0.19" />
|
<PackageReference Include="AWSSDK.S3" Version="4.0.19" />
|
||||||
<PackageReference Include="MassTransit" Version="9.0.1" />
|
<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="MassTransit.RabbitMQ" Version="9.0.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" 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);
|
var sourceFilePath = executeContext.GetVariable<string>(executeContext.Arguments.FilePathVariable);
|
||||||
ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath));
|
ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath));
|
||||||
var fileName = Path.GetFileName(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
|
try
|
||||||
{
|
{
|
||||||
await storageHandler.UploadFileAsync(
|
await storageHandler.UploadFileAsync(
|
||||||
sourceFilePath,
|
sourceFilePath,
|
||||||
executeContext.Arguments.DestinationBucket,
|
executeContext.Arguments.DestinationBucket,
|
||||||
Path.Combine(executeContext.Arguments.DestinationRoute, fileName),
|
destinationKey,
|
||||||
executeContext.CancellationToken);
|
executeContext.CancellationToken);
|
||||||
return executeContext.CompletedWithVariables(new UploadFileToBucketLog(
|
return executeContext.CompletedWithVariables(new UploadFileToBucketLog(
|
||||||
executeContext.Arguments.DestinationBucket,
|
executeContext.Arguments.DestinationBucket,
|
||||||
executeContext.Arguments.DestinationRoute));
|
destinationKey));
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await RemovePicturesAsync(track, executeContext.CancellationToken);
|
await RemovePicturesAsync(track, executeContext.Arguments.UserId, executeContext.CancellationToken);
|
||||||
track.PicturesProcessingStatus = ProcessingStatus.Completed;
|
track.PicturesProcessingStatus = ProcessingStatus.Completed;
|
||||||
await database.SaveChangesAsync(executeContext.CancellationToken);
|
await database.SaveChangesAsync(executeContext.CancellationToken);
|
||||||
|
|
||||||
await RemoveAudiosAsync(track, executeContext.CancellationToken);
|
await RemoveAudiosAsync(track, executeContext.Arguments.UserId, executeContext.CancellationToken);
|
||||||
track.AudioTranscodeProcessingStatus = ProcessingStatus.Completed;
|
track.AudioTranscodeProcessingStatus = ProcessingStatus.Completed;
|
||||||
await database.SaveChangesAsync(executeContext.CancellationToken);
|
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)
|
if (!track.IsPicturesProcessed)
|
||||||
{
|
{
|
||||||
@@ -83,9 +83,9 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
|
|||||||
await RemoveFileAsync(trackConfiguration.Routes.BuildLargePicturePath(track.LargePictureName), cancellationToken);
|
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)
|
if (!track.IsAudioProcessed)
|
||||||
{
|
{
|
||||||
|
|||||||
+46
-5
@@ -1,5 +1,6 @@
|
|||||||
using MassTransit;
|
using MassTransit;
|
||||||
using Musify.Infrastructure.MassTransit.Consumers;
|
using Musify.Infrastructure.MassTransit.Consumers;
|
||||||
|
using Musify.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit
|
namespace Musify.Infrastructure.MassTransit
|
||||||
{
|
{
|
||||||
@@ -12,7 +13,18 @@ namespace Musify.Infrastructure.MassTransit
|
|||||||
where TConsumer : class, IConsumer
|
where TConsumer : class, IConsumer
|
||||||
{
|
{
|
||||||
busFactoryConfigurator.ReceiveEndpoint(queueName, endpointConfigurator =>
|
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>(
|
static void ConfigureExecuteActivityEndpoint<TActivity, TArguments>(
|
||||||
@@ -24,6 +36,15 @@ namespace Musify.Infrastructure.MassTransit
|
|||||||
{
|
{
|
||||||
busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.ExecuteQueue(endpointName), endpointConfigurator =>
|
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);
|
endpointConfigurator.ExecuteActivityHost<TActivity, TArguments>(busRegistrationContext);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -37,14 +58,34 @@ namespace Musify.Infrastructure.MassTransit
|
|||||||
where TLog : class
|
where TLog : class
|
||||||
{
|
{
|
||||||
busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.ExecuteQueue(endpointName), endpointConfigurator =>
|
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>(
|
endpointConfigurator.ExecuteActivityHost<TActivity, TArguments>(
|
||||||
EndpointHelper.BuildCompensateActivityUri(endpointName),
|
EndpointHelper.BuildCompensateActivityUri(endpointName),
|
||||||
busRegistrationContext)
|
busRegistrationContext);
|
||||||
);
|
});
|
||||||
|
|
||||||
busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.CompensateQueue(endpointName), endpointConfigurator =>
|
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 Microsoft.Extensions.DependencyInjection;
|
||||||
using Musify.Application.Abstractions.Infrastructure;
|
using Musify.Application.Abstractions.Infrastructure;
|
||||||
using Musify.Infrastructure.Configuration;
|
using Musify.Infrastructure.Configuration;
|
||||||
|
using Musify.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit
|
namespace Musify.Infrastructure.MassTransit
|
||||||
{
|
{
|
||||||
@@ -18,6 +19,15 @@ namespace Musify.Infrastructure.MassTransit
|
|||||||
serviceDescriptors.AddScoped<IEventBus, MassTransitEventBus>();
|
serviceDescriptors.AddScoped<IEventBus, MassTransitEventBus>();
|
||||||
serviceDescriptors.AddMassTransit(options =>
|
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) =>
|
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
|
||||||
ConfigureRabbitMqHost(
|
ConfigureRabbitMqHost(
|
||||||
busFactoryConfigurator,
|
busFactoryConfigurator,
|
||||||
@@ -43,6 +53,15 @@ namespace Musify.Infrastructure.MassTransit
|
|||||||
|
|
||||||
serviceDescriptors.AddMassTransit(options =>
|
serviceDescriptors.AddMassTransit(options =>
|
||||||
{
|
{
|
||||||
|
options.AddEntityFrameworkOutbox<Database>(outbox =>
|
||||||
|
{
|
||||||
|
outbox.UsePostgres();
|
||||||
|
outbox.UseBusOutbox();
|
||||||
|
|
||||||
|
outbox.QueryDelay = TimeSpan.FromSeconds(1);
|
||||||
|
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
|
||||||
|
});
|
||||||
|
|
||||||
RegisterConsumersAndActivities(options);
|
RegisterConsumersAndActivities(options);
|
||||||
|
|
||||||
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
|
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace Musify.Infrastructure.MassTransit
|
|||||||
public class MassTransitEventBus(IPublishEndpoint publishEndpoint)
|
public class MassTransitEventBus(IPublishEndpoint publishEndpoint)
|
||||||
: IEventBus
|
: 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));
|
ArgumentNullException.ThrowIfNull(message, nameof(message));
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -99,7 +99,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
|||||||
AddResizeActivity(
|
AddResizeActivity(
|
||||||
routingSlipBuilder,
|
routingSlipBuilder,
|
||||||
ActivityNames.ResizeMedium,
|
ActivityNames.ResizeMedium,
|
||||||
ActivityNames.ResizeMedium,
|
ActivityNames.UploadMedium,
|
||||||
bucket,
|
bucket,
|
||||||
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
|
RoutingSlipVariableNames.Picture.MediumResizedFilePath,
|
||||||
medium);
|
medium);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MassTransit;
|
||||||
using Musify.Application.Abstractions.Infrastructure;
|
using Musify.Application.Abstractions.Infrastructure;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Infrastructure.Configuration;
|
using Musify.Infrastructure.Configuration;
|
||||||
@@ -13,6 +14,15 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
optionsBuilder.UseNpgsql(configuration.ConnectionString);
|
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<User> Users => Set<User>();
|
||||||
|
|
||||||
public DbSet<Track> Tracks => Set<Track>();
|
public DbSet<Track> Tracks => Set<Track>();
|
||||||
|
|||||||
+512
@@ -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);
|
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 =>
|
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -256,6 +424,18 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("UserHasTrack");
|
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 =>
|
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Musify.Domain.Entities.User", "User")
|
b.HasOne("Musify.Domain.Entities.User", "User")
|
||||||
|
|||||||
@@ -48,6 +48,27 @@ namespace Musify.Infrastructure.Services
|
|||||||
return await amazonS3.GetPreSignedURLAsync(request);
|
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)
|
public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var request = new DeleteObjectRequest
|
var request = new DeleteObjectRequest
|
||||||
@@ -74,7 +95,9 @@ namespace Musify.Infrastructure.Services
|
|||||||
string fileName = Path.GetFileName(file);
|
string fileName = Path.GetFileName(file);
|
||||||
string filePath = Path.Combine(sourceDirectory, 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
|
var upload = new Upload
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
|||||||
@@ -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` ✅
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user