Skip to content

Commit ea7d878

Browse files
authored
Add API contract tests for mobile-consumed endpoints (#5426)
* Add API contract tests for mobile-consumed endpoints Introduce an in-memory integration test project (Ombi.Api.IntegrationTests) that boots the API via WebApplicationFactory/TestServer and asserts the HTTP status and JSON shape of the endpoints the mobile app consumes, so a breaking change to those response contracts fails CI. The harness hosts a trimmed-down test startup (Quartz, the SPA static files and the SignalR hub are not started), points the EF Core SQLite contexts at a fresh temp database, authenticates every request via a deterministic test auth handler and swaps the search/request engines for mocks so the serialized wire shape is pinned without reaching out to TheMovieDb. Phase A covers the highest-risk reads: V2 search (popular movies, movie details, tv details, multi-search), V2 request lists (movie + tv), the current-user identity endpoint and the customization/sonarr settings endpoints. * Add contract tests for remaining V2 search and request mutation endpoints Extends the integration suite to the rest of the V2 search surface (top rated/upcoming/now playing movies, popular/trending/anticipated tv, movie/tv streams, actor credits, rotten tomatoes ratings) and the request mutation flows (create movie/tv, approve, deny, mark available, advanced options, recently requested). Adds a Rotten Tomatoes API mock and a PUT JSON helper to the harness. * Add contract tests for identity, issues, settings, DVR, recently-added, mobile and token Extends the integration suite across the rest of the read surface the mobile app consumes: identity user list/dropdown/claims, issues categories/list/comments, the remaining settings reads (authentication, plex, client id, issues-enabled), Radarr/Sonarr enabled/profiles/root-folders, recently-added movies/tv (engine mocked) and mobile push-token register/remove. Also asserts the token endpoint's unauthorized contract. Pre-seeds Plex settings so the read-only client-id endpoint never triggers a (disabled) Quartz job. * Add contract tests for request lifecycle remainder and Plex libraries Covers the remaining consumed request endpoints (movie info, delete, subscribe/ unsubscribe, tv child approve/deny/mark-available, tv children) and the Plex libraries endpoint via a mocked Plex API. Adds a README documenting what the suite covers and the endpoints intentionally left out (Quartz-scheduled job triggers and settings writes, live tester connections, binary image proxying). * Address CodeRabbit review feedback on the API contract tests - Use the SDK-style project type GUID for Ombi.Api.IntegrationTests in the solution and nest it under the Tests solution folder. - Drop the unneeded Npgsql legacy-timestamp switch from the test pipeline (the harness only ever uses SQLite). - Seed a deterministic issue category + issue so the issues tests assert real item shapes instead of just an array. - Validate every element of returned collections (identity users/dropdown/ claims) rather than only the first. - Assert the Rotten Tomatoes movie-ratings payload shape. - Assert the array body on a 200 for the Radarr/Sonarr profile/root-folder endpoints. - README: "recently requested" wording fix. * Bump version to 4.53.11 * Revert README wording change
1 parent 9f6227f commit ea7d878

21 files changed

Lines changed: 1521 additions & 32 deletions
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.Linq;
2+
using System.Net;
3+
using System.Net.Http;
4+
using System.Threading.Tasks;
5+
using Newtonsoft.Json;
6+
using Newtonsoft.Json.Linq;
7+
using NUnit.Framework;
8+
9+
namespace Ombi.Api.IntegrationTests.Harness
10+
{
11+
/// <summary>
12+
/// Base class for the API contract tests. Exposes the shared in-memory host, an HttpClient and
13+
/// a few helpers, and resets the engine mocks between tests.
14+
/// </summary>
15+
public abstract class IntegrationTestBase
16+
{
17+
protected OmbiApiTestFactory Factory => SharedTestFixture.Factory;
18+
protected HttpClient Client;
19+
20+
[OneTimeSetUp]
21+
public void CreateClient()
22+
{
23+
Client = Factory.CreateClient();
24+
}
25+
26+
[SetUp]
27+
public void ResetMocks()
28+
{
29+
// Clear any setups/invocations from a previous test so each test is isolated.
30+
Factory.MovieEngineV2.Invocations.Clear();
31+
Factory.TvSearchEngineV2.Invocations.Clear();
32+
Factory.MultiSearchEngine.Invocations.Clear();
33+
Factory.MovieRequestEngine.Invocations.Clear();
34+
Factory.TvRequestEngine.Invocations.Clear();
35+
Factory.RottenTomatoesApi.Invocations.Clear();
36+
Factory.RecentlyAddedEngine.Invocations.Clear();
37+
Factory.PlexApi.Invocations.Clear();
38+
}
39+
40+
[OneTimeTearDown]
41+
public void DisposeClient()
42+
{
43+
Client?.Dispose();
44+
}
45+
46+
protected async Task<(HttpStatusCode status, string body)> GetAsync(string url)
47+
{
48+
using var resp = await Client.GetAsync(url);
49+
var body = await resp.Content.ReadAsStringAsync();
50+
return (resp.StatusCode, body);
51+
}
52+
53+
protected async Task<(HttpStatusCode status, string body)> PostJsonAsync(string url, object payload)
54+
{
55+
var content = new StringContent(JsonConvert.SerializeObject(payload), System.Text.Encoding.UTF8, "application/json");
56+
using var resp = await Client.PostAsync(url, content);
57+
var body = await resp.Content.ReadAsStringAsync();
58+
return (resp.StatusCode, body);
59+
}
60+
61+
protected async Task<(HttpStatusCode status, string body)> PutJsonAsync(string url, object payload)
62+
{
63+
var content = new StringContent(JsonConvert.SerializeObject(payload), System.Text.Encoding.UTF8, "application/json");
64+
using var resp = await Client.PutAsync(url, content);
65+
var body = await resp.Content.ReadAsStringAsync();
66+
return (resp.StatusCode, body);
67+
}
68+
69+
/// <summary>Asserts the JSON object has a property with exactly this (case-sensitive) name.</summary>
70+
protected static void AssertHasProperty(JObject obj, string propertyName)
71+
{
72+
Assert.That(obj.ContainsKey(propertyName), Is.True,
73+
$"Expected JSON to contain property '{propertyName}'. Actual properties: [{string.Join(", ", obj.Properties().Select(p => p.Name))}]");
74+
}
75+
76+
/// <summary>
77+
/// Asserts the JSON object contains every one of these (case-sensitive) property names. This is
78+
/// the contract the mobile app relies on - the keys and casing it deserializes into.
79+
/// </summary>
80+
protected static void AssertHasProperties(JObject obj, params string[] propertyNames)
81+
{
82+
foreach (var name in propertyNames)
83+
{
84+
AssertHasProperty(obj, name);
85+
}
86+
}
87+
88+
protected static JObject AsObject(string body) => JObject.Parse(body);
89+
90+
protected static JArray AsArray(string body) => JArray.Parse(body);
91+
}
92+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using System;
2+
using System.IO;
3+
using Microsoft.AspNetCore.Authentication;
4+
using Microsoft.AspNetCore.Hosting;
5+
using Microsoft.AspNetCore.Mvc.Testing;
6+
using Microsoft.AspNetCore.TestHost;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.DependencyInjection.Extensions;
9+
using Microsoft.Extensions.Hosting;
10+
using Moq;
11+
using Ombi.Api.External.ExternalApis.RottenTomatoes;
12+
using Ombi.Api.External.MediaServers.Plex;
13+
using Ombi.Core;
14+
using Ombi.Core.Engine;
15+
using Ombi.Core.Engine.Interfaces;
16+
using Ombi.Core.Engine.V2;
17+
using Ombi.Helpers;
18+
19+
namespace Ombi.Api.IntegrationTests.Harness
20+
{
21+
/// <summary>
22+
/// Boots the Ombi API in-memory for contract testing. Search/request engines that would
23+
/// otherwise reach out to TheMovieDb etc. are swapped for mocks so the tests assert the
24+
/// serialized HTTP shape (the contract the mobile app depends on) deterministically.
25+
/// </summary>
26+
public class OmbiApiTestFactory : WebApplicationFactory<Startup>
27+
{
28+
private readonly string _storagePath;
29+
30+
public Mock<IMovieEngineV2> MovieEngineV2 { get; } = new Mock<IMovieEngineV2>();
31+
public Mock<ITVSearchEngineV2> TvSearchEngineV2 { get; } = new Mock<ITVSearchEngineV2>();
32+
public Mock<IMultiSearchEngine> MultiSearchEngine { get; } = new Mock<IMultiSearchEngine>();
33+
public Mock<IMovieRequestEngine> MovieRequestEngine { get; } = new Mock<IMovieRequestEngine>();
34+
public Mock<ITvRequestEngine> TvRequestEngine { get; } = new Mock<ITvRequestEngine>();
35+
public Mock<IRottenTomatoesApi> RottenTomatoesApi { get; } = new Mock<IRottenTomatoesApi>();
36+
public Mock<IRecentlyAddedEngine> RecentlyAddedEngine { get; } = new Mock<IRecentlyAddedEngine>();
37+
public Mock<IPlexApi> PlexApi { get; } = new Mock<IPlexApi>();
38+
39+
public OmbiApiTestFactory()
40+
{
41+
_storagePath = Path.Combine(Path.GetTempPath(), "ombi-itests-" + Guid.NewGuid().ToString("N"));
42+
Directory.CreateDirectory(_storagePath);
43+
44+
// These must be set before the host - and therefore the database and JWT auth - is built.
45+
StartupSingleton.Instance.StoragePath = _storagePath;
46+
StartupSingleton.Instance.SecurityKey = "ombi-integration-tests-security-key-please-ignore-1234567890";
47+
}
48+
49+
// Bypass Ombi's Program.CreateHostBuilder (which wires up the real Startup with Quartz, the
50+
// SPA and SignalR) and host only the test startup.
51+
protected override IHostBuilder CreateHostBuilder()
52+
{
53+
return Host.CreateDefaultBuilder()
54+
.ConfigureWebHostDefaults(webBuilder =>
55+
{
56+
webBuilder.UseStartup<TestStartup>();
57+
// UseStartup sets ApplicationName to this test assembly; reset it to the Ombi
58+
// assembly (after UseStartup) so MVC discovers its controllers - otherwise it
59+
// scans this test assembly and every route 404s.
60+
webBuilder.UseSetting(WebHostDefaults.ApplicationKey, typeof(Startup).Assembly.GetName().Name);
61+
});
62+
}
63+
64+
protected override void ConfigureWebHost(IWebHostBuilder builder)
65+
{
66+
builder.UseContentRoot(AppContext.BaseDirectory);
67+
builder.UseEnvironment("Production");
68+
69+
builder.ConfigureTestServices(services =>
70+
{
71+
// Force our deterministic auth scheme as the default for authenticate/challenge.
72+
services.AddAuthentication(TestAuthHandler.Scheme)
73+
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.Scheme, _ => { });
74+
services.PostConfigure<AuthenticationOptions>(o =>
75+
{
76+
o.DefaultScheme = TestAuthHandler.Scheme;
77+
o.DefaultAuthenticateScheme = TestAuthHandler.Scheme;
78+
o.DefaultChallengeScheme = TestAuthHandler.Scheme;
79+
});
80+
81+
// Bypass caching so each request reaches the mocked engine.
82+
services.RemoveAll<IMediaCacheService>();
83+
services.AddSingleton<IMediaCacheService, PassThroughMediaCacheService>();
84+
85+
// Replace engines that hit external services with mocks the tests configure.
86+
services.AddTransient(_ => MovieEngineV2.Object);
87+
services.AddTransient(_ => TvSearchEngineV2.Object);
88+
services.AddTransient(_ => MultiSearchEngine.Object);
89+
services.AddTransient(_ => MovieRequestEngine.Object);
90+
services.AddTransient(_ => TvRequestEngine.Object);
91+
services.AddTransient(_ => RottenTomatoesApi.Object);
92+
services.AddTransient(_ => RecentlyAddedEngine.Object);
93+
services.AddTransient(_ => PlexApi.Object);
94+
});
95+
}
96+
97+
protected override void Dispose(bool disposing)
98+
{
99+
base.Dispose(disposing);
100+
try
101+
{
102+
if (Directory.Exists(_storagePath))
103+
{
104+
Directory.Delete(_storagePath, true);
105+
}
106+
}
107+
catch
108+
{
109+
// best-effort cleanup of the temp database directory
110+
}
111+
}
112+
}
113+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using Ombi.Helpers;
4+
5+
namespace Ombi.Api.IntegrationTests.Harness
6+
{
7+
/// <summary>
8+
/// Replaces the real <see cref="IMediaCacheService"/> in tests so every controller call goes
9+
/// straight to the (mocked) engine. This keeps each test deterministic and avoids cross-test
10+
/// cache contamination.
11+
/// </summary>
12+
public class PassThroughMediaCacheService : IMediaCacheService
13+
{
14+
public Task<T> GetOrAddAsync<T>(string cacheKey, Func<Task<T>> factory, DateTimeOffset absoluteExpiration = default)
15+
{
16+
return factory();
17+
}
18+
19+
public Task Purge() => Task.CompletedTask;
20+
}
21+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using NUnit.Framework;
2+
using Ombi.Api.IntegrationTests.Harness;
3+
4+
// Assembly-level fixture: the concrete SQLite contexts only run their EF migrations once per
5+
// process (a static guard), so all tests must share a single host/database instance.
6+
[SetUpFixture]
7+
public class SharedTestFixture
8+
{
9+
public static OmbiApiTestFactory Factory { get; private set; }
10+
11+
[OneTimeSetUp]
12+
public void GlobalSetUp()
13+
{
14+
Factory = new OmbiApiTestFactory();
15+
// Force the host to build (runs migrations + seeding) before any test executes.
16+
using (Factory.CreateClient())
17+
{
18+
}
19+
}
20+
21+
[OneTimeTearDown]
22+
public void GlobalTearDown()
23+
{
24+
Factory?.Dispose();
25+
}
26+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using System.Security.Claims;
2+
using System.Text.Encodings.Web;
3+
using System.Threading.Tasks;
4+
using Microsoft.AspNetCore.Authentication;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.Extensions.Options;
7+
using Ombi.Helpers;
8+
9+
namespace Ombi.Api.IntegrationTests.Harness
10+
{
11+
/// <summary>
12+
/// A deterministic authentication handler used by the integration tests. It authenticates
13+
/// every request as a fixed admin/power user, so we can exercise the [Authorize] endpoints
14+
/// the mobile app consumes without minting JWTs or going through the real login flow.
15+
/// </summary>
16+
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
17+
{
18+
public const string Scheme = "Test";
19+
public const string TestUserName = "testuser";
20+
public const string TestUserId = "test-user-id";
21+
22+
public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger,
23+
UrlEncoder encoder)
24+
: base(options, logger, encoder)
25+
{
26+
}
27+
28+
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
29+
{
30+
var claims = new[]
31+
{
32+
new Claim(ClaimTypes.Name, TestUserName),
33+
new Claim(ClaimTypes.NameIdentifier, TestUserId),
34+
new Claim("id", TestUserId),
35+
new Claim(ClaimTypes.Role, OmbiRoles.Admin),
36+
new Claim(ClaimTypes.Role, OmbiRoles.PowerUser),
37+
new Claim(ClaimTypes.Role, OmbiRoles.RequestMovie),
38+
new Claim(ClaimTypes.Role, OmbiRoles.RequestTv),
39+
};
40+
41+
var identity = new ClaimsIdentity(claims, Scheme);
42+
var principal = new ClaimsPrincipal(identity);
43+
var ticket = new AuthenticationTicket(principal, Scheme);
44+
45+
return Task.FromResult(AuthenticateResult.Success(ticket));
46+
}
47+
}
48+
}

0 commit comments

Comments
 (0)