Skip to content

Commit c2710c0

Browse files
Add conversion API and tests
- Implement `GET /api/conversions` endpoint with pagination and filtering by file path. - Add `ConversionResponse` and `PaginatedResponse` models. - Update API documentation and examples. - Create extensive unit tests for `ConversionsController`.
1 parent 7af9174 commit c2710c0

6 files changed

Lines changed: 456 additions & 1 deletion

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
namespace Muxarr.Core.Api.Models;
2+
3+
public class ConversionResponse
4+
{
5+
public int Id { get; init; }
6+
public required string Name { get; init; }
7+
public required string State { get; init; }
8+
public int Progress { get; init; }
9+
public long SizeBefore { get; init; }
10+
public long SizeAfter { get; init; }
11+
public long SizeDifference { get; init; }
12+
public string? FilePath { get; init; }
13+
public bool IsCustomConversion { get; init; }
14+
public DateTime? StartedDate { get; init; }
15+
public DateTime CreatedDate { get; init; }
16+
public DateTime UpdatedDate { get; init; }
17+
18+
public static PaginatedResponse<ConversionResponse> Example => new()
19+
{
20+
Page = 1,
21+
PageSize = 25,
22+
TotalItems = 1,
23+
TotalPages = 1,
24+
Items =
25+
[
26+
new ConversionResponse
27+
{
28+
Id = 42,
29+
Name = "Movie Title",
30+
State = "Completed",
31+
Progress = 100,
32+
SizeBefore = 5368709120,
33+
SizeAfter = 3758096384,
34+
SizeDifference = 1610612736,
35+
FilePath = "/media/movies/movie.mkv",
36+
IsCustomConversion = false,
37+
StartedDate = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc),
38+
CreatedDate = new DateTime(2026, 8, 20, 9, 55, 0, DateTimeKind.Utc),
39+
UpdatedDate = new DateTime(2026, 8, 20, 10, 5, 0, DateTimeKind.Utc)
40+
}
41+
]
42+
};
43+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Muxarr.Core.Api.Models;
2+
3+
public class PaginatedResponse<T>
4+
{
5+
public int Page { get; init; }
6+
public int PageSize { get; init; }
7+
public int TotalItems { get; init; }
8+
public int TotalPages { get; init; }
9+
public required List<T> Items { get; init; }
10+
}
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.EntityFrameworkCore;
3+
using Muxarr.Core.Api.Models;
4+
using Muxarr.Data;
5+
using Muxarr.Data.Entities;
6+
using Muxarr.Web.Controllers;
7+
8+
namespace Muxarr.Tests;
9+
10+
[TestClass]
11+
public class ConversionsControllerTests
12+
{
13+
private DbContextOptions<AppDbContext> _dbOptions = null!;
14+
15+
[TestInitialize]
16+
public void Setup()
17+
{
18+
_dbOptions = new DbContextOptionsBuilder<AppDbContext>()
19+
.UseSqlite($"Data Source={Path.Combine(Path.GetTempPath(), $"muxarr_conv_test_{Guid.NewGuid():N}.db")}")
20+
.Options;
21+
22+
using var context = new AppDbContext(_dbOptions);
23+
context.Database.EnsureCreated();
24+
}
25+
26+
[TestCleanup]
27+
public void Cleanup()
28+
{
29+
using var context = new AppDbContext(_dbOptions);
30+
context.Database.EnsureDeleted();
31+
}
32+
33+
[TestMethod]
34+
public async Task ReturnsAllConversionStates()
35+
{
36+
var states = new[] { ConversionState.New, ConversionState.Processing, ConversionState.Completed, ConversionState.Failed, ConversionState.Cancelled };
37+
using (var context = new AppDbContext(_dbOptions))
38+
{
39+
foreach (var state in states)
40+
{
41+
context.MediaConversions.Add(new MediaConversion
42+
{
43+
Name = $"Test {state}",
44+
State = state,
45+
CreatedDate = DateTime.UtcNow,
46+
UpdatedDate = DateTime.UtcNow
47+
});
48+
}
49+
50+
await context.SaveChangesAsync();
51+
}
52+
53+
var result = await GetConversions();
54+
55+
Assert.AreEqual(5, result.TotalItems);
56+
Assert.AreEqual(5, result.Items.Count);
57+
58+
var returnedStates = result.Items.Select(i => i.State).OrderBy(s => s).ToList();
59+
var expectedStates = states.Select(s => s.ToString()).OrderBy(s => s).ToList();
60+
CollectionAssert.AreEqual(expectedStates, returnedStates);
61+
}
62+
63+
[TestMethod]
64+
public async Task FieldMappingIsCorrect()
65+
{
66+
var startedDate = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
67+
68+
int conversionId;
69+
using (var context = new AppDbContext(_dbOptions))
70+
{
71+
var profile = CreateProfile(context);
72+
var file = new MediaFile { Path = "/media/movies/movie.mkv", Profile = profile };
73+
context.MediaFiles.Add(file);
74+
75+
var conversion = new MediaConversion
76+
{
77+
Name = "Test Movie",
78+
State = ConversionState.Completed,
79+
Progress = 100,
80+
SizeBefore = 5368709120,
81+
SizeAfter = 3758096384,
82+
SizeDifference = 1610612736,
83+
IsCustomConversion = true,
84+
StartedDate = startedDate,
85+
MediaFile = file
86+
};
87+
context.MediaConversions.Add(conversion);
88+
89+
await context.SaveChangesAsync();
90+
conversionId = conversion.Id;
91+
}
92+
93+
// Read back the actual audit dates set by EF
94+
DateTime savedCreatedDate, savedUpdatedDate;
95+
using (var context = new AppDbContext(_dbOptions))
96+
{
97+
var saved = await context.MediaConversions.AsNoTracking().SingleAsync(c => c.Id == conversionId);
98+
savedCreatedDate = saved.CreatedDate;
99+
savedUpdatedDate = saved.UpdatedDate;
100+
}
101+
102+
var result = await GetConversions();
103+
104+
Assert.AreEqual(1, result.Items.Count);
105+
var item = result.Items[0];
106+
Assert.AreEqual(conversionId, item.Id);
107+
Assert.AreEqual("Test Movie", item.Name);
108+
Assert.AreEqual("Completed", item.State);
109+
Assert.AreEqual(100, item.Progress);
110+
Assert.AreEqual(5368709120, item.SizeBefore);
111+
Assert.AreEqual(3758096384, item.SizeAfter);
112+
Assert.AreEqual(1610612736, item.SizeDifference);
113+
Assert.AreEqual("/media/movies/movie.mkv", item.FilePath);
114+
Assert.IsTrue(item.IsCustomConversion);
115+
Assert.AreEqual(startedDate, item.StartedDate);
116+
Assert.AreEqual(savedCreatedDate, item.CreatedDate);
117+
Assert.AreEqual(savedUpdatedDate, item.UpdatedDate);
118+
}
119+
120+
[TestMethod]
121+
public async Task DefaultPagination()
122+
{
123+
using (var context = new AppDbContext(_dbOptions))
124+
{
125+
for (var i = 0; i < 30; i++)
126+
{
127+
context.MediaConversions.Add(new MediaConversion
128+
{
129+
Name = $"Conversion {i}",
130+
State = ConversionState.Completed,
131+
CreatedDate = DateTime.UtcNow,
132+
UpdatedDate = DateTime.UtcNow
133+
});
134+
}
135+
136+
await context.SaveChangesAsync();
137+
}
138+
139+
var result = await GetConversions();
140+
141+
Assert.AreEqual(1, result.Page);
142+
Assert.AreEqual(25, result.PageSize);
143+
Assert.AreEqual(30, result.TotalItems);
144+
Assert.AreEqual(2, result.TotalPages);
145+
Assert.AreEqual(25, result.Items.Count);
146+
}
147+
148+
[TestMethod]
149+
public async Task FilePathFilterReturnsMatchingConversions()
150+
{
151+
using (var context = new AppDbContext(_dbOptions))
152+
{
153+
var profile = CreateProfile(context);
154+
var file1 = new MediaFile { Path = "/media/movies/movie1.mkv", Profile = profile, CreatedDate = DateTime.UtcNow, UpdatedDate = DateTime.UtcNow };
155+
var file2 = new MediaFile { Path = "/media/movies/movie2.mkv", Profile = profile, CreatedDate = DateTime.UtcNow, UpdatedDate = DateTime.UtcNow };
156+
context.MediaFiles.AddRange(file1, file2);
157+
158+
context.MediaConversions.Add(new MediaConversion { Name = "Movie 1", State = ConversionState.Completed, MediaFile = file1, CreatedDate = DateTime.UtcNow, UpdatedDate = DateTime.UtcNow });
159+
context.MediaConversions.Add(new MediaConversion { Name = "Movie 2", State = ConversionState.Completed, MediaFile = file2, CreatedDate = DateTime.UtcNow, UpdatedDate = DateTime.UtcNow });
160+
161+
await context.SaveChangesAsync();
162+
}
163+
164+
var result = await GetConversions(filePath: "/media/movies/movie1.mkv");
165+
166+
Assert.AreEqual(1, result.TotalItems);
167+
Assert.AreEqual(1, result.Items.Count);
168+
Assert.AreEqual("Movie 1", result.Items[0].Name);
169+
Assert.AreEqual("/media/movies/movie1.mkv", result.Items[0].FilePath);
170+
}
171+
172+
[TestMethod]
173+
public async Task FilePathFilterReturnsEmptyForNonExistentPath()
174+
{
175+
using (var context = new AppDbContext(_dbOptions))
176+
{
177+
var profile = CreateProfile(context);
178+
var file = new MediaFile { Path = "/media/movies/movie.mkv", Profile = profile, CreatedDate = DateTime.UtcNow, UpdatedDate = DateTime.UtcNow };
179+
context.MediaFiles.Add(file);
180+
context.MediaConversions.Add(new MediaConversion { Name = "Movie", State = ConversionState.Completed, MediaFile = file, CreatedDate = DateTime.UtcNow, UpdatedDate = DateTime.UtcNow });
181+
182+
await context.SaveChangesAsync();
183+
}
184+
185+
var result = await GetConversions(filePath: "/media/movies/nonexistent.mkv");
186+
187+
Assert.AreEqual(0, result.TotalItems);
188+
Assert.AreEqual(0, result.Items.Count);
189+
Assert.AreEqual(0, result.TotalPages);
190+
}
191+
192+
[TestMethod]
193+
public async Task OrderByCreatedDateDescending()
194+
{
195+
using (var context = new AppDbContext(_dbOptions))
196+
{
197+
context.MediaConversions.Add(new MediaConversion { Name = "Oldest", State = ConversionState.Completed });
198+
context.MediaConversions.Add(new MediaConversion { Name = "Newest", State = ConversionState.Completed });
199+
context.MediaConversions.Add(new MediaConversion { Name = "Middle", State = ConversionState.Completed });
200+
201+
await context.SaveChangesAsync();
202+
203+
// Set distinct CreatedDate values via raw SQL (EF overrides them on save)
204+
await context.Database.ExecuteSqlRawAsync(
205+
"UPDATE MediaConversion SET CreatedDate = '2026-01-01' WHERE Name = 'Oldest'");
206+
await context.Database.ExecuteSqlRawAsync(
207+
"UPDATE MediaConversion SET CreatedDate = '2026-08-20' WHERE Name = 'Newest'");
208+
await context.Database.ExecuteSqlRawAsync(
209+
"UPDATE MediaConversion SET CreatedDate = '2026-05-10' WHERE Name = 'Middle'");
210+
}
211+
212+
var result = await GetConversions();
213+
214+
Assert.AreEqual("Newest", result.Items[0].Name);
215+
Assert.AreEqual("Middle", result.Items[1].Name);
216+
Assert.AreEqual("Oldest", result.Items[2].Name);
217+
}
218+
219+
[TestMethod]
220+
public async Task OrphanedConversionAppearsInUnfilteredResults()
221+
{
222+
using (var context = new AppDbContext(_dbOptions))
223+
{
224+
context.MediaConversions.Add(new MediaConversion
225+
{
226+
Name = "Orphaned",
227+
State = ConversionState.Completed,
228+
MediaFileId = null,
229+
CreatedDate = DateTime.UtcNow,
230+
UpdatedDate = DateTime.UtcNow
231+
});
232+
233+
await context.SaveChangesAsync();
234+
}
235+
236+
var result = await GetConversions();
237+
238+
Assert.AreEqual(1, result.Items.Count);
239+
Assert.AreEqual("Orphaned", result.Items[0].Name);
240+
Assert.IsNull(result.Items[0].FilePath);
241+
}
242+
243+
[TestMethod]
244+
public async Task OrphanedConversionExcludedByFilePathFilter()
245+
{
246+
using (var context = new AppDbContext(_dbOptions))
247+
{
248+
context.MediaConversions.Add(new MediaConversion
249+
{
250+
Name = "Orphaned",
251+
State = ConversionState.Completed,
252+
MediaFileId = null,
253+
CreatedDate = DateTime.UtcNow,
254+
UpdatedDate = DateTime.UtcNow
255+
});
256+
257+
await context.SaveChangesAsync();
258+
}
259+
260+
var result = await GetConversions(filePath: "/media/movies/any.mkv");
261+
262+
Assert.AreEqual(0, result.Items.Count);
263+
Assert.AreEqual(0, result.TotalItems);
264+
}
265+
266+
[TestMethod]
267+
public async Task PageBeyondTotalPagesReturnsEmpty()
268+
{
269+
using (var context = new AppDbContext(_dbOptions))
270+
{
271+
context.MediaConversions.Add(new MediaConversion { Name = "Only", State = ConversionState.Completed, CreatedDate = DateTime.UtcNow, UpdatedDate = DateTime.UtcNow });
272+
273+
await context.SaveChangesAsync();
274+
}
275+
276+
var result = await GetConversions(page: 5);
277+
278+
Assert.AreEqual(5, result.Page);
279+
Assert.AreEqual(1, result.TotalItems);
280+
Assert.AreEqual(1, result.TotalPages);
281+
Assert.AreEqual(0, result.Items.Count);
282+
}
283+
284+
private async Task<PaginatedResponse<ConversionResponse>> GetConversions(
285+
int page = 1, int pageSize = 25, string? filePath = null)
286+
{
287+
var factory = new TestDbContextFactory(_dbOptions);
288+
var controller = new ConversionsController(factory);
289+
290+
var actionResult = await controller.Get(page, pageSize, filePath);
291+
var okResult = actionResult as OkObjectResult;
292+
Assert.IsNotNull(okResult, "Expected OkObjectResult");
293+
294+
var response = okResult.Value as PaginatedResponse<ConversionResponse>;
295+
Assert.IsNotNull(response, "Expected PaginatedResponse<ConversionResponse>");
296+
297+
return response;
298+
}
299+
300+
private static Profile CreateProfile(AppDbContext context)
301+
{
302+
var profile = new Profile { Name = "Test", CreatedDate = DateTime.UtcNow, UpdatedDate = DateTime.UtcNow };
303+
context.Profiles.Add(profile);
304+
context.SaveChanges();
305+
return profile;
306+
}
307+
308+
private class TestDbContextFactory(DbContextOptions<AppDbContext> options) : IDbContextFactory<AppDbContext>
309+
{
310+
public AppDbContext CreateDbContext()
311+
{
312+
return new AppDbContext(options);
313+
}
314+
}
315+
}

0 commit comments

Comments
 (0)