refactor: extract subsonic controller logic into specialized services

- Extract SubsonicRequestParser for HTTP parameter extraction
- Extract SubsonicResponseBuilder for XML/JSON response formatting
- Extract SubsonicModelMapper for search result parsing and merging
- Extract SubsonicProxyService for upstream Subsonic server communication
- Add comprehensive test coverage (45 tests) for all new services
- Reduce SubsonicController from 1174 to 666 lines (-43%)

All tests passing. Build succeeds with 0 errors.
This commit is contained in:
V1ck3s
2026-01-08 21:47:05 +01:00
parent 09ee618ac8
commit 9245dac99e
10 changed files with 2031 additions and 574 deletions

View File

@@ -0,0 +1,347 @@
using Microsoft.Extensions.Logging;
using Moq;
using octo_fiesta.Models.Domain;
using octo_fiesta.Models.Search;
using octo_fiesta.Services.Subsonic;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
namespace octo_fiesta.Tests;
public class SubsonicModelMapperTests
{
private readonly SubsonicModelMapper _mapper;
private readonly Mock<ILogger<SubsonicModelMapper>> _mockLogger;
private readonly SubsonicResponseBuilder _responseBuilder;
public SubsonicModelMapperTests()
{
_responseBuilder = new SubsonicResponseBuilder();
_mockLogger = new Mock<ILogger<SubsonicModelMapper>>();
_mapper = new SubsonicModelMapper(_responseBuilder, _mockLogger.Object);
}
[Fact]
public void ParseSearchResponse_JsonWithSongs_ParsesCorrectly()
{
// Arrange
var jsonResponse = @"{
""subsonic-response"": {
""status"": ""ok"",
""version"": ""1.16.1"",
""searchResult3"": {
""song"": [
{
""id"": ""song1"",
""title"": ""Test Song"",
""artist"": ""Test Artist"",
""album"": ""Test Album""
}
]
}
}
}";
var responseBody = Encoding.UTF8.GetBytes(jsonResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Single(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void ParseSearchResponse_XmlWithSongs_ParsesCorrectly()
{
// Arrange
var xmlResponse = @"<?xml version=""1.0"" encoding=""UTF-8""?>
<subsonic-response xmlns=""http://subsonic.org/restapi"" status=""ok"" version=""1.16.1"">
<searchResult3>
<song id=""song1"" title=""Test Song"" artist=""Test Artist"" album=""Test Album"" />
</searchResult3>
</subsonic-response>";
var responseBody = Encoding.UTF8.GetBytes(xmlResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/xml");
// Assert
Assert.Single(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void ParseSearchResponse_JsonWithAllTypes_ParsesAllCorrectly()
{
// Arrange
var jsonResponse = @"{
""subsonic-response"": {
""status"": ""ok"",
""version"": ""1.16.1"",
""searchResult3"": {
""song"": [
{""id"": ""song1"", ""title"": ""Song 1""}
],
""album"": [
{""id"": ""album1"", ""name"": ""Album 1""}
],
""artist"": [
{""id"": ""artist1"", ""name"": ""Artist 1""}
]
}
}
}";
var responseBody = Encoding.UTF8.GetBytes(jsonResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Single(songs);
Assert.Single(albums);
Assert.Single(artists);
}
[Fact]
public void ParseSearchResponse_XmlWithAllTypes_ParsesAllCorrectly()
{
// Arrange
var xmlResponse = @"<?xml version=""1.0"" encoding=""UTF-8""?>
<subsonic-response xmlns=""http://subsonic.org/restapi"" status=""ok"" version=""1.16.1"">
<searchResult3>
<song id=""song1"" title=""Song 1"" />
<album id=""album1"" name=""Album 1"" />
<artist id=""artist1"" name=""Artist 1"" />
</searchResult3>
</subsonic-response>";
var responseBody = Encoding.UTF8.GetBytes(xmlResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/xml");
// Assert
Assert.Single(songs);
Assert.Single(albums);
Assert.Single(artists);
}
[Fact]
public void ParseSearchResponse_InvalidJson_ReturnsEmpty()
{
// Arrange
var invalidJson = "{invalid json}";
var responseBody = Encoding.UTF8.GetBytes(invalidJson);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Empty(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void ParseSearchResponse_EmptySearchResult_ReturnsEmpty()
{
// Arrange
var jsonResponse = @"{
""subsonic-response"": {
""status"": ""ok"",
""version"": ""1.16.1"",
""searchResult3"": {}
}
}";
var responseBody = Encoding.UTF8.GetBytes(jsonResponse);
// Act
var (songs, albums, artists) = _mapper.ParseSearchResponse(responseBody, "application/json");
// Assert
Assert.Empty(songs);
Assert.Empty(albums);
Assert.Empty(artists);
}
[Fact]
public void MergeSearchResults_Json_MergesSongsCorrectly()
{
// Arrange
var localSongs = new List<object>
{
new Dictionary<string, object> { ["id"] = "local1", ["title"] = "Local Song" }
};
var externalResult = new SearchResult
{
Songs = new List<Song>
{
new Song { Id = "ext1", Title = "External Song" }
},
Albums = new List<Album>(),
Artists = new List<Artist>()
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
localSongs, new List<object>(), new List<object>(), externalResult, true);
// Assert
Assert.Equal(2, mergedSongs.Count);
}
[Fact]
public void MergeSearchResults_Json_DeduplicatesArtists()
{
// Arrange
var localArtists = new List<object>
{
new Dictionary<string, object> { ["id"] = "local1", ["name"] = "Test Artist" }
};
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>
{
new Artist { Id = "ext1", Name = "Test Artist" }, // Same name - should be filtered
new Artist { Id = "ext2", Name = "Different Artist" } // Different name - should be included
}
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), localArtists, externalResult, true);
// Assert
Assert.Equal(2, mergedArtists.Count); // 1 local + 1 external (duplicate filtered)
}
[Fact]
public void MergeSearchResults_Json_CaseInsensitiveDeduplication()
{
// Arrange
var localArtists = new List<object>
{
new Dictionary<string, object> { ["id"] = "local1", ["name"] = "Test Artist" }
};
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>
{
new Artist { Id = "ext1", Name = "test artist" } // Different case - should still be filtered
}
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), localArtists, externalResult, true);
// Assert
Assert.Single(mergedArtists); // Only the local artist
}
[Fact]
public void MergeSearchResults_Xml_MergesSongsCorrectly()
{
// Arrange
var ns = XNamespace.Get("http://subsonic.org/restapi");
var localSongs = new List<object>
{
new XElement("song", new XAttribute("id", "local1"), new XAttribute("title", "Local Song"))
};
var externalResult = new SearchResult
{
Songs = new List<Song>
{
new Song { Id = "ext1", Title = "External Song" }
},
Albums = new List<Album>(),
Artists = new List<Artist>()
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
localSongs, new List<object>(), new List<object>(), externalResult, false);
// Assert
Assert.Equal(2, mergedSongs.Count);
}
[Fact]
public void MergeSearchResults_Xml_DeduplicatesArtists()
{
// Arrange
var localArtists = new List<object>
{
new XElement("artist", new XAttribute("id", "local1"), new XAttribute("name", "Test Artist"))
};
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>
{
new Artist { Id = "ext1", Name = "Test Artist" }, // Same name - should be filtered
new Artist { Id = "ext2", Name = "Different Artist" } // Different name - should be included
}
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), localArtists, externalResult, false);
// Assert
Assert.Equal(2, mergedArtists.Count); // 1 local + 1 external (duplicate filtered)
}
[Fact]
public void MergeSearchResults_EmptyLocalResults_ReturnsOnlyExternal()
{
// Arrange
var externalResult = new SearchResult
{
Songs = new List<Song> { new Song { Id = "ext1" } },
Albums = new List<Album> { new Album { Id = "ext2" } },
Artists = new List<Artist> { new Artist { Id = "ext3", Name = "Artist" } }
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
new List<object>(), new List<object>(), new List<object>(), externalResult, true);
// Assert
Assert.Single(mergedSongs);
Assert.Single(mergedAlbums);
Assert.Single(mergedArtists);
}
[Fact]
public void MergeSearchResults_EmptyExternalResults_ReturnsOnlyLocal()
{
// Arrange
var localSongs = new List<object> { new Dictionary<string, object> { ["id"] = "local1" } };
var localAlbums = new List<object> { new Dictionary<string, object> { ["id"] = "local2" } };
var localArtists = new List<object> { new Dictionary<string, object> { ["id"] = "local3", ["name"] = "Local" } };
var externalResult = new SearchResult
{
Songs = new List<Song>(),
Albums = new List<Album>(),
Artists = new List<Artist>()
};
// Act
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
localSongs, localAlbums, localArtists, externalResult, true);
// Assert
Assert.Single(mergedSongs);
Assert.Single(mergedAlbums);
Assert.Single(mergedArtists);
}
}

View File

@@ -0,0 +1,325 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using octo_fiesta.Models.Settings;
using octo_fiesta.Services.Subsonic;
using System.Net;
namespace octo_fiesta.Tests;
public class SubsonicProxyServiceTests
{
private readonly SubsonicProxyService _service;
private readonly Mock<HttpMessageHandler> _mockHttpMessageHandler;
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
public SubsonicProxyServiceTests()
{
_mockHttpMessageHandler = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_mockHttpMessageHandler.Object);
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
_mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
var settings = Options.Create(new SubsonicSettings
{
Url = "http://localhost:4533"
});
_service = new SubsonicProxyService(_mockHttpClientFactory.Object, settings);
}
[Fact]
public async Task RelayAsync_SuccessfulRequest_ReturnsBodyAndContentType()
{
// Arrange
var responseContent = new byte[] { 1, 2, 3, 4, 5 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(responseContent)
};
responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "u", "admin" },
{ "p", "password" },
{ "v", "1.16.0" }
};
// Act
var (body, contentType) = await _service.RelayAsync("rest/ping", parameters);
// Assert
Assert.Equal(responseContent, body);
Assert.Equal("application/json", contentType);
}
[Fact]
public async Task RelayAsync_BuildsCorrectUrl()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(Array.Empty<byte>())
};
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "u", "admin" },
{ "p", "secret" }
};
// Act
await _service.RelayAsync("rest/ping", parameters);
// Assert
Assert.NotNull(capturedRequest);
Assert.Contains("http://localhost:4533/rest/ping", capturedRequest!.RequestUri!.ToString());
Assert.Contains("u=admin", capturedRequest.RequestUri.ToString());
Assert.Contains("p=secret", capturedRequest.RequestUri.ToString());
}
[Fact]
public async Task RelayAsync_EncodesSpecialCharacters()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(Array.Empty<byte>())
};
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => capturedRequest = req)
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "query", "rock & roll" },
{ "artist", "AC/DC" }
};
// Act
await _service.RelayAsync("rest/search3", parameters);
// Assert
Assert.NotNull(capturedRequest);
var url = capturedRequest!.RequestUri!.ToString();
// HttpClient automatically applies URL encoding when building the URI
// Space can be encoded as + or %20, & as %26, / as %2F
Assert.Contains("query=", url);
Assert.Contains("artist=", url);
Assert.Contains("AC%2FDC", url); // / should be encoded as %2F
}
[Fact]
public async Task RelayAsync_HttpError_ThrowsException()
{
// Arrange
var responseMessage = new HttpResponseMessage(HttpStatusCode.NotFound);
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act & Assert
await Assert.ThrowsAsync<HttpRequestException>(() =>
_service.RelayAsync("rest/ping", parameters));
}
[Fact]
public async Task RelaySafeAsync_SuccessfulRequest_ReturnsSuccessTrue()
{
// Arrange
var responseContent = new byte[] { 1, 2, 3 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(responseContent)
};
responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/xml");
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act
var (body, contentType, success) = await _service.RelaySafeAsync("rest/ping", parameters);
// Assert
Assert.True(success);
Assert.Equal(responseContent, body);
Assert.Equal("application/xml", contentType);
}
[Fact]
public async Task RelaySafeAsync_HttpError_ReturnsSuccessFalse()
{
// Arrange
var responseMessage = new HttpResponseMessage(HttpStatusCode.InternalServerError);
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act
var (body, contentType, success) = await _service.RelaySafeAsync("rest/ping", parameters);
// Assert
Assert.False(success);
Assert.Null(body);
Assert.Null(contentType);
}
[Fact]
public async Task RelaySafeAsync_NetworkException_ReturnsSuccessFalse()
{
// Arrange
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
var parameters = new Dictionary<string, string> { { "u", "admin" } };
// Act
var (body, contentType, success) = await _service.RelaySafeAsync("rest/ping", parameters);
// Assert
Assert.False(success);
Assert.Null(body);
Assert.Null(contentType);
}
[Fact]
public async Task RelayStreamAsync_SuccessfulRequest_ReturnsFileStreamResult()
{
// Arrange
var streamContent = new byte[] { 1, 2, 3, 4, 5 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(streamContent)
};
responseMessage.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("audio/mpeg");
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string>
{
{ "id", "song123" },
{ "u", "admin" }
};
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var fileResult = Assert.IsType<FileStreamResult>(result);
Assert.Equal("audio/mpeg", fileResult.ContentType);
Assert.True(fileResult.EnableRangeProcessing);
}
[Fact]
public async Task RelayStreamAsync_HttpError_ReturnsStatusCodeResult()
{
// Arrange
var responseMessage = new HttpResponseMessage(HttpStatusCode.NotFound);
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var statusResult = Assert.IsType<StatusCodeResult>(result);
Assert.Equal(404, statusResult.StatusCode);
}
[Fact]
public async Task RelayStreamAsync_Exception_ReturnsObjectResultWith500()
{
// Arrange
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection failed"));
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var objectResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(500, objectResult.StatusCode);
}
[Fact]
public async Task RelayStreamAsync_DefaultContentType_UsesAudioMpeg()
{
// Arrange
var streamContent = new byte[] { 1, 2, 3 };
var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(streamContent)
// No ContentType set
};
_mockHttpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(responseMessage);
var parameters = new Dictionary<string, string> { { "id", "song123" } };
// Act
var result = await _service.RelayStreamAsync(parameters, CancellationToken.None);
// Assert
var fileResult = Assert.IsType<FileStreamResult>(result);
Assert.Equal("audio/mpeg", fileResult.ContentType);
}
}

View File

@@ -0,0 +1,202 @@
using Microsoft.AspNetCore.Http;
using octo_fiesta.Services.Subsonic;
using System.Text;
namespace octo_fiesta.Tests;
public class SubsonicRequestParserTests
{
private readonly SubsonicRequestParser _parser;
public SubsonicRequestParserTests()
{
_parser = new SubsonicRequestParser();
}
[Fact]
public async Task ExtractAllParametersAsync_QueryParameters_ExtractsCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?u=admin&p=password&v=1.16.0&c=testclient&f=json");
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(5, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("1.16.0", result["v"]);
Assert.Equal("testclient", result["c"]);
Assert.Equal("json", result["f"]);
}
[Fact]
public async Task ExtractAllParametersAsync_FormEncodedBody_ExtractsCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
var formData = "u=admin&p=password&query=test+artist&artistCount=10";
var bytes = Encoding.UTF8.GetBytes(formData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/x-www-form-urlencoded";
context.Request.ContentLength = bytes.Length;
context.Request.Method = "POST";
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(4, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("test artist", result["query"]);
Assert.Equal("10", result["artistCount"]);
}
[Fact]
public async Task ExtractAllParametersAsync_JsonBody_ExtractsCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
var jsonData = "{\"u\":\"admin\",\"p\":\"password\",\"query\":\"test artist\",\"artistCount\":10}";
var bytes = Encoding.UTF8.GetBytes(jsonData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(4, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("test artist", result["query"]);
Assert.Equal("10", result["artistCount"]);
}
[Fact]
public async Task ExtractAllParametersAsync_QueryAndFormBody_MergesCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?u=admin&p=password&f=json");
var formData = "query=test&artistCount=5";
var bytes = Encoding.UTF8.GetBytes(formData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/x-www-form-urlencoded";
context.Request.ContentLength = bytes.Length;
context.Request.Method = "POST";
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(5, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("password", result["p"]);
Assert.Equal("json", result["f"]);
Assert.Equal("test", result["query"]);
Assert.Equal("5", result["artistCount"]);
}
[Fact]
public async Task ExtractAllParametersAsync_EmptyRequest_ReturnsEmptyDictionary()
{
// Arrange
var context = new DefaultHttpContext();
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Empty(result);
}
[Fact]
public async Task ExtractAllParametersAsync_SpecialCharacters_EncodesCorrectly()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?query=rock+%26+roll&artist=AC%2FDC");
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("rock & roll", result["query"]);
Assert.Equal("AC/DC", result["artist"]);
}
[Fact]
public async Task ExtractAllParametersAsync_InvalidJson_IgnoresBody()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?u=admin");
var invalidJson = "{invalid json}";
var bytes = Encoding.UTF8.GetBytes(invalidJson);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Single(result);
Assert.Equal("admin", result["u"]);
}
[Fact]
public async Task ExtractAllParametersAsync_NullJsonValues_HandlesGracefully()
{
// Arrange
var context = new DefaultHttpContext();
var jsonData = "{\"u\":\"admin\",\"p\":null,\"query\":\"test\"}";
var bytes = Encoding.UTF8.GetBytes(jsonData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(3, result.Count);
Assert.Equal("admin", result["u"]);
Assert.Equal("", result["p"]);
Assert.Equal("test", result["query"]);
}
[Fact]
public async Task ExtractAllParametersAsync_DuplicateKeys_BodyOverridesQuery()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?format=xml&query=old");
var jsonData = "{\"query\":\"new\",\"artist\":\"Beatles\"}";
var bytes = Encoding.UTF8.GetBytes(jsonData);
context.Request.Body = new MemoryStream(bytes);
context.Request.ContentType = "application/json";
context.Request.ContentLength = bytes.Length;
// Act
var result = await _parser.ExtractAllParametersAsync(context.Request);
// Assert
Assert.Equal(3, result.Count);
Assert.Equal("xml", result["format"]);
Assert.Equal("new", result["query"]); // Body overrides query
Assert.Equal("Beatles", result["artist"]);
}
}

View File

@@ -0,0 +1,322 @@
using Microsoft.AspNetCore.Mvc;
using octo_fiesta.Models.Domain;
using octo_fiesta.Services.Subsonic;
using System.Text.Json;
using System.Xml.Linq;
namespace octo_fiesta.Tests;
public class SubsonicResponseBuilderTests
{
private readonly SubsonicResponseBuilder _builder;
public SubsonicResponseBuilderTests()
{
_builder = new SubsonicResponseBuilder();
}
[Fact]
public void CreateResponse_JsonFormat_ReturnsJsonWithOkStatus()
{
// Act
var result = _builder.CreateResponse("json", "testElement", new { });
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
Assert.NotNull(jsonResult.Value);
// Serialize and deserialize to check structure
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
Assert.Equal("ok", doc.RootElement.GetProperty("subsonic-response").GetProperty("status").GetString());
Assert.Equal("1.16.1", doc.RootElement.GetProperty("subsonic-response").GetProperty("version").GetString());
}
[Fact]
public void CreateResponse_XmlFormat_ReturnsXmlWithOkStatus()
{
// Act
var result = _builder.CreateResponse("xml", "testElement", new { });
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var root = doc.Root!;
Assert.Equal("subsonic-response", root.Name.LocalName);
Assert.Equal("ok", root.Attribute("status")?.Value);
Assert.Equal("1.16.1", root.Attribute("version")?.Value);
}
[Fact]
public void CreateError_JsonFormat_ReturnsJsonWithError()
{
// Act
var result = _builder.CreateError("json", 70, "Test error message");
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var response = doc.RootElement.GetProperty("subsonic-response");
Assert.Equal("failed", response.GetProperty("status").GetString());
Assert.Equal(70, response.GetProperty("error").GetProperty("code").GetInt32());
Assert.Equal("Test error message", response.GetProperty("error").GetProperty("message").GetString());
}
[Fact]
public void CreateError_XmlFormat_ReturnsXmlWithError()
{
// Act
var result = _builder.CreateError("xml", 70, "Test error message");
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var root = doc.Root!;
Assert.Equal("failed", root.Attribute("status")?.Value);
var ns = root.GetDefaultNamespace();
var errorElement = root.Element(ns + "error");
Assert.NotNull(errorElement);
Assert.Equal("70", errorElement.Attribute("code")?.Value);
Assert.Equal("Test error message", errorElement.Attribute("message")?.Value);
}
[Fact]
public void CreateSongResponse_JsonFormat_ReturnsSongData()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song",
Artist = "Test Artist",
Album = "Test Album",
Duration = 180,
Track = 5,
Year = 2023,
Genre = "Rock",
LocalPath = "/music/test.mp3"
};
// Act
var result = _builder.CreateSongResponse("json", song);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var songData = doc.RootElement.GetProperty("subsonic-response").GetProperty("song");
Assert.Equal("song123", songData.GetProperty("id").GetString());
Assert.Equal("Test Song", songData.GetProperty("title").GetString());
Assert.Equal("Test Artist", songData.GetProperty("artist").GetString());
Assert.Equal("Test Album", songData.GetProperty("album").GetString());
}
[Fact]
public void CreateSongResponse_XmlFormat_ReturnsSongData()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song",
Artist = "Test Artist",
Album = "Test Album",
Duration = 180
};
// Act
var result = _builder.CreateSongResponse("xml", song);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var songElement = doc.Root!.Element(ns + "song");
Assert.NotNull(songElement);
Assert.Equal("song123", songElement.Attribute("id")?.Value);
Assert.Equal("Test Song", songElement.Attribute("title")?.Value);
}
[Fact]
public void CreateAlbumResponse_JsonFormat_ReturnsAlbumWithSongs()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Test Album",
Artist = "Test Artist",
Year = 2023,
Songs = new List<Song>
{
new Song { Id = "song1", Title = "Song 1", Duration = 180 },
new Song { Id = "song2", Title = "Song 2", Duration = 200 }
}
};
// Act
var result = _builder.CreateAlbumResponse("json", album);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var albumData = doc.RootElement.GetProperty("subsonic-response").GetProperty("album");
Assert.Equal("album123", albumData.GetProperty("id").GetString());
Assert.Equal("Test Album", albumData.GetProperty("name").GetString());
Assert.Equal(2, albumData.GetProperty("songCount").GetInt32());
Assert.Equal(380, albumData.GetProperty("duration").GetInt32());
}
[Fact]
public void CreateAlbumResponse_XmlFormat_ReturnsAlbumWithSongs()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Test Album",
Artist = "Test Artist",
SongCount = 2,
Songs = new List<Song>
{
new Song { Id = "song1", Title = "Song 1" },
new Song { Id = "song2", Title = "Song 2" }
}
};
// Act
var result = _builder.CreateAlbumResponse("xml", album);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var albumElement = doc.Root!.Element(ns + "album");
Assert.NotNull(albumElement);
Assert.Equal("album123", albumElement.Attribute("id")?.Value);
Assert.Equal("2", albumElement.Attribute("songCount")?.Value);
}
[Fact]
public void CreateArtistResponse_JsonFormat_ReturnsArtistData()
{
// Arrange
var artist = new Artist
{
Id = "artist123",
Name = "Test Artist"
};
var albums = new List<Album>
{
new Album { Id = "album1", Title = "Album 1" },
new Album { Id = "album2", Title = "Album 2" }
};
// Act
var result = _builder.CreateArtistResponse("json", artist, albums);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var artistData = doc.RootElement.GetProperty("subsonic-response").GetProperty("artist");
Assert.Equal("artist123", artistData.GetProperty("id").GetString());
Assert.Equal("Test Artist", artistData.GetProperty("name").GetString());
Assert.Equal(2, artistData.GetProperty("albumCount").GetInt32());
}
[Fact]
public void CreateArtistResponse_XmlFormat_ReturnsArtistData()
{
// Arrange
var artist = new Artist
{
Id = "artist123",
Name = "Test Artist"
};
var albums = new List<Album>
{
new Album { Id = "album1", Title = "Album 1" },
new Album { Id = "album2", Title = "Album 2" }
};
// Act
var result = _builder.CreateArtistResponse("xml", artist, albums);
// Assert
var contentResult = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", contentResult.ContentType);
var doc = XDocument.Parse(contentResult.Content!);
var ns = doc.Root!.GetDefaultNamespace();
var artistElement = doc.Root!.Element(ns + "artist");
Assert.NotNull(artistElement);
Assert.Equal("artist123", artistElement.Attribute("id")?.Value);
Assert.Equal("Test Artist", artistElement.Attribute("name")?.Value);
Assert.Equal("2", artistElement.Attribute("albumCount")?.Value);
}
[Fact]
public void CreateSongResponse_SongWithNullValues_HandlesGracefully()
{
// Arrange
var song = new Song
{
Id = "song123",
Title = "Test Song"
// Other fields are null
};
// Act
var result = _builder.CreateSongResponse("json", song);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var songData = doc.RootElement.GetProperty("subsonic-response").GetProperty("song");
Assert.Equal("song123", songData.GetProperty("id").GetString());
Assert.Equal("Test Song", songData.GetProperty("title").GetString());
}
[Fact]
public void CreateAlbumResponse_EmptySongList_ReturnsZeroCounts()
{
// Arrange
var album = new Album
{
Id = "album123",
Title = "Empty Album",
Artist = "Test Artist",
Songs = new List<Song>()
};
// Act
var result = _builder.CreateAlbumResponse("json", album);
// Assert
var jsonResult = Assert.IsType<JsonResult>(result);
var json = JsonSerializer.Serialize(jsonResult.Value);
var doc = JsonDocument.Parse(json);
var albumData = doc.RootElement.GetProperty("subsonic-response").GetProperty("album");
Assert.Equal(0, albumData.GetProperty("songCount").GetInt32());
Assert.Equal(0, albumData.GetProperty("duration").GetInt32());
}
}