diff --git a/.gitea/workflows/nightly-api-tests.yaml b/.gitea/workflows/nightly-api-tests.yaml new file mode 100644 index 0000000..68279b1 --- /dev/null +++ b/.gitea/workflows/nightly-api-tests.yaml @@ -0,0 +1,69 @@ +name: '๐ŸŒ™ Nightly API Spec Tests' + +on: + schedule: + # Run every night at 2 AM UTC + - cron: '0 2 * * *' + workflow_dispatch: # Allow manual trigger + +jobs: + api-spec-validation: + name: Validate SRF API Spec + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build solution + run: dotnet build --no-restore --configuration Release + + - name: Run API Spec Tests + id: api_tests + run: | + dotnet test \ + --no-build \ + --configuration Release \ + --filter "Category=APISpec" \ + --logger "console;verbosity=detailed" || echo "TESTS_FAILED=true" >> $GITHUB_ENV + + - name: Check Test Results + if: always() + run: | + if [ "$TESTS_FAILED" = "true" ]; then + echo "โŒ API Spec Tests Failed" + echo "" + echo "This may indicate:" + echo " - SRF Play API has changed" + echo " - API endpoints are experiencing issues" + echo " - Response schemas have been modified" + echo "" + echo "Actions needed:" + echo " 1. Review the test output above" + echo " 2. Check if SRF Play API documentation has been updated" + echo " 3. Update API models if the schema has changed" + echo " 4. Update tests if they need to be adjusted" + echo "" + exit 1 + else + echo "โœ… All API spec tests passed!" + echo "The SRF Play API is working as expected." + fi + + - name: Upload Test Logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: api-test-failure-logs + path: | + **/*.trx + **/TestResults/ + retention-days: 30 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml deleted file mode 100644 index f290747..0000000 --- a/.github/workflows/build.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: '๐Ÿ—๏ธ Build Plugin' - -on: - push: - branches: - - master - paths-ignore: - - '**/*.md' - pull_request: - branches: - - master - paths-ignore: - - '**/*.md' - workflow_dispatch: - -jobs: - call: - uses: jellyfin/jellyfin-meta-plugins/.github/workflows/build.yaml@master diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml deleted file mode 100644 index 5b3c3be..0000000 --- a/.github/workflows/changelog.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: '๐Ÿ“ Create/Update Release Draft & Release Bump PR' - -on: - push: - branches: - - master - paths-ignore: - - build.yaml - workflow_dispatch: - repository_dispatch: - types: - - update-prep-command - -jobs: - call: - uses: jellyfin/jellyfin-meta-plugins/.github/workflows/changelog.yaml@master - with: - repository-name: jellyfin/jellyfin-plugin-template - secrets: - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/command-dispatch.yaml b/.github/workflows/command-dispatch.yaml deleted file mode 100644 index 1b5e4ee..0000000 --- a/.github/workflows/command-dispatch.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Allows for the definition of PR and Issue /commands -name: '๐Ÿ“Ÿ Slash Command Dispatcher' - -on: - issue_comment: - types: - - created - -jobs: - call: - uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-dispatch.yaml@master - secrets: - token: . diff --git a/.github/workflows/command-rebase.yaml b/.github/workflows/command-rebase.yaml deleted file mode 100644 index 7847e20..0000000 --- a/.github/workflows/command-rebase.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: '๐Ÿ”€ PR Rebase Command' - -on: - repository_dispatch: - types: - - rebase-command - -jobs: - call: - uses: jellyfin/jellyfin-meta-plugins/.github/workflows/command-rebase.yaml@master - with: - rebase-head: ${{ github.event.client_payload.pull_request.head.label }} - repository-full-name: ${{ github.event.client_payload.github.payload.repository.full_name }} - comment-id: ${{ github.event.client_payload.github.payload.comment.id }} - secrets: - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml deleted file mode 100644 index 80483cf..0000000 --- a/.github/workflows/publish.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: '๐Ÿš€ Publish Plugin' - -on: - release: - types: - - released - workflow_dispatch: - -jobs: - call: - uses: jellyfin/jellyfin-meta-plugins/.github/workflows/publish.yaml@master - with: - version: ${{ github.event.release.tag_name }} - is-unstable: ${{ github.event.release.prerelease }} - secrets: - deploy-host: ${{ secrets.DEPLOY_HOST }} - deploy-user: ${{ secrets.DEPLOY_USER }} - deploy-key: ${{ secrets.DEPLOY_KEY }} diff --git a/.github/workflows/scan-codeql.yaml b/.github/workflows/scan-codeql.yaml deleted file mode 100644 index ca8b0b0..0000000 --- a/.github/workflows/scan-codeql.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: '๐Ÿ”ฌ Run CodeQL' - -on: - push: - branches: [ master ] - paths-ignore: - - '**/*.md' - pull_request: - branches: [ master ] - paths-ignore: - - '**/*.md' - schedule: - - cron: '24 2 * * 4' - workflow_dispatch: - -jobs: - call: - uses: jellyfin/jellyfin-meta-plugins/.github/workflows/scan-codeql.yaml@master - with: - repository-name: jellyfin/jellyfin-plugin-template diff --git a/.github/workflows/sync-labels.yaml b/.github/workflows/sync-labels.yaml deleted file mode 100644 index 5e06ae4..0000000 --- a/.github/workflows/sync-labels.yaml +++ /dev/null @@ -1,12 +0,0 @@ -name: '๐Ÿท๏ธ Sync labels' - -on: - schedule: - - cron: '0 0 1 * *' - workflow_dispatch: - -jobs: - call: - uses: jellyfin/jellyfin-meta-plugins/.github/workflows/sync-labels.yaml@master - secrets: - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index d90b14d..0000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: '๐Ÿงช Test Plugin' - -on: - push: - branches: - - master - paths-ignore: - - '**/*.md' - pull_request: - branches: - - master - paths-ignore: - - '**/*.md' - workflow_dispatch: - -jobs: - call: - uses: jellyfin/jellyfin-meta-plugins/.github/workflows/test.yaml@master diff --git a/Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/SRFApiSpecTests.cs b/Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/SRFApiSpecTests.cs new file mode 100644 index 0000000..5aaa569 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/SRFApiSpecTests.cs @@ -0,0 +1,287 @@ +using FluentAssertions; +using Jellyfin.Plugin.SRFPlay.Api; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Jellyfin.Plugin.SRFPlay.Tests.IntegrationTests; + +/// +/// Integration tests to validate SRF API spec compliance. +/// These tests make real API calls to ensure the API is still working as expected. +/// +[Trait("Category", "Integration")] +[Trait("Category", "APISpec")] +public class SRFApiSpecTests : IDisposable +{ + private readonly ILoggerFactory _loggerFactory; + private readonly SRFApiClient _apiClient; + private readonly CancellationToken _cancellationToken = CancellationToken.None; + + public SRFApiSpecTests() + { + _loggerFactory = LoggerFactory.Create(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); + }); + + _apiClient = new SRFApiClient(_loggerFactory); + } + + [Fact] + public async Task GetAllShows_SRF_ReturnsShows() + { + // Act + var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken); + + // Assert + shows.Should().NotBeNull(); + shows.Should().NotBeEmpty(); + var firstShow = shows.FirstOrDefault(); + firstShow.Should().NotBeNull(); + firstShow!.Id.Should().NotBeNullOrEmpty(); + firstShow.Title.Should().NotBeNullOrEmpty(); + } + + [Theory] + [InlineData("srf")] + [InlineData("rts")] + [InlineData("rsi")] + public async Task GetAllShows_MultipleBusinessUnits_ReturnsValidData(string businessUnit) + { + // Act + var shows = await _apiClient.GetAllShowsAsync(businessUnit, _cancellationToken); + + // Assert + shows.Should().NotBeNull(); + + // If shows exist, validate their structure + if (shows != null && shows.Count > 0) + { + var firstShow = shows.FirstOrDefault(); + firstShow.Should().NotBeNull(); + firstShow!.Id.Should().NotBeNullOrEmpty(); + + // Validate URN format if present + if (!string.IsNullOrEmpty(firstShow.Urn)) + { + firstShow.Urn.Should().Contain($":{businessUnit}:"); + } + } + } + + [Fact] + public async Task GetVideosForShow_ValidShowId_ReturnsVideos() + { + // Arrange - First get a show + var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken); + shows.Should().NotBeNull().And.NotBeEmpty(); + + var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0); + showWithEpisodes.Should().NotBeNull("at least one show should have episodes"); + + // Act + var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken); + + // Assert + videos.Should().NotBeNull(); + videos.Should().NotBeEmpty(); + + var firstVideo = videos!.FirstOrDefault(); + firstVideo.Should().NotBeNull(); + firstVideo!.Id.Should().NotBeNullOrEmpty(); + firstVideo.Title.Should().NotBeNullOrEmpty(); + firstVideo.Urn.Should().NotBeNullOrEmpty(); + firstVideo.Urn.Should().StartWith("urn:srf:video:"); + } + + [Fact] + public async Task GetMediaCompositionByUrn_ValidVideoUrn_ReturnsMediaComposition() + { + // Arrange - Get a video URN + var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken); + shows.Should().NotBeNull().And.NotBeEmpty(); + + var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0); + var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken); + videos.Should().NotBeNull().And.NotBeEmpty(); + + var videoUrn = videos!.First()!.Urn!; + + // Act + var mediaComposition = await _apiClient.GetMediaCompositionByUrnAsync(videoUrn, _cancellationToken); + + // Assert + mediaComposition.Should().NotBeNull(); + mediaComposition!.ChapterList.Should().NotBeNull(); + mediaComposition.ChapterList.Should().NotBeEmpty(); + + var chapter = mediaComposition.ChapterList.First(); + chapter.Should().NotBeNull(); + chapter.Urn.Should().Be(videoUrn); + chapter.ResourceList.Should().NotBeNull(); + } + + [Fact] + public async Task GetMediaCompositionByUrn_ValidVideoUrn_HasHLSResources() + { + // Arrange - Get a video URN + var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken); + var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0); + var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken); + var videoUrn = videos!.First()!.Urn!; + + // Act + var mediaComposition = await _apiClient.GetMediaCompositionByUrnAsync(videoUrn, _cancellationToken); + + // Assert + var chapter = mediaComposition!.ChapterList.First(); + var hlsResources = chapter.ResourceList!.Where(r => + r.Protocol == "HLS" || r.Streaming == "HLS" || r.Url.Contains(".m3u8")).ToList(); + + hlsResources.Should().NotBeEmpty("video should have HLS streaming resources"); + + var hlsResource = hlsResources.First(); + hlsResource.Url.Should().NotBeNullOrEmpty(); + hlsResource.Url.Should().Contain(".m3u8"); + } + + [Fact] + public async Task VideoResource_Url_IsAccessible() + { + // Arrange - Get a video URN and stream URL + var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken); + var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0); + var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken); + var videoUrn = videos!.First()!.Urn!; + var mediaComposition = await _apiClient.GetMediaCompositionByUrnAsync(videoUrn, _cancellationToken); + + var chapter = mediaComposition!.ChapterList.First(); + var hlsResource = chapter.ResourceList!.FirstOrDefault(r => + (r.DrmList == null || r.DrmList.ToString() == "[]") && + (r.Protocol == "HLS" || r.Url.Contains(".m3u8"))); + + hlsResource.Should().NotBeNull("at least one non-DRM HLS resource should exist"); + + // Act - Try to access the URL + using var httpClient = new HttpClient(); + var response = await httpClient.GetAsync(hlsResource!.Url, _cancellationToken); + + // Assert + response.Should().NotBeNull(); + response.IsSuccessStatusCode.Should().BeTrue("stream URL should be accessible"); + + var content = await response.Content.ReadAsStringAsync(_cancellationToken); + content.Should().Contain("#EXTM3U", "should be a valid M3U8 playlist"); + } + + [Fact] + public async Task GetAllShows_ResponseStructure_MatchesExpectedSchema() + { + // Act + var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken); + + // Assert - Validate response structure + shows.Should().NotBeNull(); + shows.Should().NotBeEmpty(); + + var show = shows!.First()!; + + // Required fields + show.Id.Should().NotBeNullOrEmpty(); + show.Title.Should().NotBeNullOrEmpty(); + + // Optional but commonly present fields + show.NumberOfEpisodes.Should().BeGreaterThanOrEqualTo(0); + + // If URN is present, validate format + if (!string.IsNullOrEmpty(show.Urn)) + { + show.Urn.Should().MatchRegex(@"^urn:srf:(show|video):.+$"); + } + } + + [Fact] + public async Task GetVideosForShow_ResponseStructure_MatchesExpectedSchema() + { + // Arrange + var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken); + var showWithEpisodes = shows!.FirstOrDefault(s => s != null && s.NumberOfEpisodes > 0); + + // Act + var videos = await _apiClient.GetVideosForShowAsync("srf", showWithEpisodes!.Id!, _cancellationToken); + + // Assert - Validate response structure + var video = videos!.First()!; + + // Required fields + video.Id.Should().NotBeNullOrEmpty(); + video.Title.Should().NotBeNullOrEmpty(); + video.Urn.Should().NotBeNullOrEmpty(); + video.Urn.Should().MatchRegex(@"^urn:srf:(video|scheduled_livestream:video):.+$"); + + // Duration should be positive + video.Duration.Should().BeGreaterThan(0); + + // Date should be valid + video.Date.Should().BeAfter(DateTime.MinValue); + } + + [Fact] + public async Task GetScheduledLivestreams_ReturnsValidData() + { + // Act + var livestreams = await _apiClient.GetScheduledLivestreamsAsync("srf", "SPORT", _cancellationToken); + + // Assert + livestreams.Should().NotBeNull(); + + // If there are livestreams, validate their structure + if (livestreams != null && livestreams.Count > 0) + { + var livestream = livestreams.First(); + livestream.Should().NotBeNull(); + livestream.Urn.Should().NotBeNullOrEmpty(); + livestream.Urn.Should().Contain("scheduled_livestream"); + } + } + + [Fact] + public async Task ApiEndpoints_AreResponsive() + { + // This test ensures all major endpoints are responsive + var tasks = new List + { + _apiClient.GetAllShowsAsync("srf", _cancellationToken), + _apiClient.GetScheduledLivestreamsAsync("srf", "SPORT", _cancellationToken) + }; + + // Act - All API calls should complete without exceptions + var act = async () => await Task.WhenAll(tasks); + + // Assert + await act.Should().NotThrowAsync("all API endpoints should be responsive"); + } + + [Fact] + public async Task ApiPerformance_ReasonableResponseTime() + { + // Arrange + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + // Act + var shows = await _apiClient.GetAllShowsAsync("srf", _cancellationToken); + + // Assert + stopwatch.Stop(); + stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(30), + "API should respond within 30 seconds"); + shows.Should().NotBeEmpty(); + } + + public void Dispose() + { + _apiClient?.Dispose(); + _loggerFactory?.Dispose(); + } +} diff --git a/Jellyfin.Plugin.SRFPlay.Tests/Jellyfin.Plugin.SRFPlay.Tests.csproj b/Jellyfin.Plugin.SRFPlay.Tests/Jellyfin.Plugin.SRFPlay.Tests.csproj index 22b4348..030642f 100644 --- a/Jellyfin.Plugin.SRFPlay.Tests/Jellyfin.Plugin.SRFPlay.Tests.csproj +++ b/Jellyfin.Plugin.SRFPlay.Tests/Jellyfin.Plugin.SRFPlay.Tests.csproj @@ -7,13 +7,23 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + - Exe - net8.0 + + net8.0;net9.0 enable enable + false + true diff --git a/Jellyfin.Plugin.SRFPlay.Tests/README.md b/Jellyfin.Plugin.SRFPlay.Tests/README.md new file mode 100644 index 0000000..6d96d5a --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay.Tests/README.md @@ -0,0 +1,154 @@ +# SRF Play Plugin Tests + +This directory contains the test suite for the Jellyfin SRF Play plugin. + +## Test Structure + +### Unit Tests (`UnitTests/`) +Fast, isolated tests that verify individual components without external dependencies. + +- `StreamUrlResolverTests.cs` - Tests for stream URL resolution logic +- `MetadataCacheTests.cs` - Tests for metadata caching functionality + +### Integration Tests (`IntegrationTests/`) +Tests that make real API calls to validate the SRF Play API spec compliance. + +- `SRFApiSpecTests.cs` - Validates API endpoints, response schemas, and data integrity + +### Legacy Tests +- `Program.cs` - Legacy console test application (kept for manual testing) +- `TestPlayV3Api.cs` - Legacy Play v3 API tests (kept for manual testing) + +## Running Tests + +### Run All Tests +```bash +dotnet test +``` + +### Run Only Unit Tests +```bash +dotnet test --filter "Category!=Integration&Category!=APISpec" +``` + +### Run Only Integration Tests +```bash +dotnet test --filter "Category=Integration" +``` + +### Run Only API Spec Tests +```bash +dotnet test --filter "Category=APISpec" +``` + +### Run Tests with Coverage +```bash +dotnet test --collect:"XPlat Code Coverage" +``` + +### Run Tests with Detailed Output +```bash +dotnet test --logger "console;verbosity=detailed" +``` + +## Test Categories + +Tests are organized using xUnit traits: + +- **Unit Tests**: No category (default) +- **Integration Tests**: `[Trait("Category", "Integration")]` +- **API Spec Tests**: `[Trait("Category", "APISpec")]` + +## Continuous Integration + +### Unit Tests +- Run on every push and pull request +- Workflow: `.github/workflows/unit-tests.yaml` +- Must pass before merging PRs + +### API Spec Tests (Nightly) +- Run every night at 2 AM UTC +- Workflow: `.github/workflows/nightly-api-tests.yaml` +- Validates that the SRF Play API is still working as expected +- Creates an issue automatically if tests fail + +## Adding New Tests + +### Unit Test Example +```csharp +using Xunit; +using FluentAssertions; + +public class MyServiceTests +{ + [Fact] + public void MyMethod_WithValidInput_ReturnsExpectedResult() + { + // Arrange + var service = new MyService(); + + // Act + var result = service.MyMethod("test"); + + // Assert + result.Should().Be("expected"); + } +} +``` + +### Integration Test Example +```csharp +using Xunit; +using FluentAssertions; + +[Trait("Category", "Integration")] +public class MyApiTests +{ + [Fact] + public async Task ApiCall_ReturnsValidData() + { + // Arrange + var client = new ApiClient(); + + // Act + var result = await client.GetDataAsync(); + + // Assert + result.Should().NotBeNull(); + } +} +``` + +## Test Dependencies + +- **xUnit** - Test framework +- **FluentAssertions** - Fluent assertion library for readable tests +- **Moq** - Mocking framework for creating test doubles +- **Microsoft.NET.Test.Sdk** - .NET test SDK + +## Best Practices + +1. **Unit Tests Should Be Fast**: Each test should run in milliseconds +2. **Integration Tests Can Be Slower**: API calls may take seconds +3. **Use Descriptive Names**: Test names should describe what they test +4. **Follow AAA Pattern**: Arrange, Act, Assert +5. **One Assertion Per Test**: Focus each test on a single behavior +6. **Clean Up Resources**: Implement `IDisposable` when needed +7. **Avoid Test Interdependence**: Each test should be independent + +## Troubleshooting + +### Tests Fail Locally +1. Ensure you have internet connectivity (integration tests need it) +2. Check if the SRF Play API is accessible from your location +3. Verify .NET 8.0 SDK is installed + +### API Spec Tests Fail +1. Check if the SRF Play API has changed +2. Review the API documentation +3. Update models and tests if necessary + +### Coverage is Low +1. Add tests for uncovered code paths +2. Use `dotnet test --collect:"XPlat Code Coverage"` to generate reports +3. Review `TestResults/` directory for coverage details diff --git a/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/MetadataCacheTests.cs b/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/MetadataCacheTests.cs new file mode 100644 index 0000000..9cb6cca --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/MetadataCacheTests.cs @@ -0,0 +1,163 @@ +using FluentAssertions; +using Jellyfin.Plugin.SRFPlay.Api.Models; +using Jellyfin.Plugin.SRFPlay.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Plugin.SRFPlay.Tests.UnitTests; + +/// +/// Unit tests for MetadataCache. +/// +public class MetadataCacheTests : IDisposable +{ + private readonly Mock> _loggerMock; + private readonly MetadataCache _cache; + + public MetadataCacheTests() + { + _loggerMock = new Mock>(); + _cache = new MetadataCache(_loggerMock.Object); + } + + [Fact] + public void SetMediaComposition_And_GetMediaComposition_ReturnsCorrectValue() + { + // Arrange + const string urn = "urn:srf:video:test-id"; + var mediaComposition = new MediaComposition + { + Episode = new Episode { Id = "test-episode", Title = "Test" } + }; + const int cacheDurationMinutes = 10; + + // Act + _cache.SetMediaComposition(urn, mediaComposition); + var result = _cache.GetMediaComposition(urn, cacheDurationMinutes); + + // Assert + result.Should().NotBeNull(); + result.Should().Be(mediaComposition); + } + + [Fact] + public void GetMediaComposition_NonExistentKey_ReturnsNull() + { + // Act + var result = _cache.GetMediaComposition("non-existent-urn", 10); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetMediaComposition_ExpiredEntry_ReturnsNull() + { + // Arrange + const string urn = "urn:srf:video:test-id"; + var mediaComposition = new MediaComposition + { + Episode = new Episode { Id = "test-episode" } + }; + + // Set with cache + _cache.SetMediaComposition(urn, mediaComposition); + + // Wait a tiny bit to ensure expiration + System.Threading.Thread.Sleep(10); + + // Act - Try to get with 0 minute cache duration (immediate expiration) + var result = _cache.GetMediaComposition(urn, 0); + + // Assert - Should be null because it's expired + result.Should().BeNull(); + } + + [Fact] + public void RemoveMediaComposition_ExistingUrn_RemovesValue() + { + // Arrange + const string urn = "urn:srf:video:test-id"; + var mediaComposition = new MediaComposition + { + Episode = new Episode { Id = "test-episode" } + }; + _cache.SetMediaComposition(urn, mediaComposition); + + // Act + _cache.RemoveMediaComposition(urn); + var result = _cache.GetMediaComposition(urn, 10); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void Clear_RemovesAllValues() + { + // Arrange + var mc1 = new MediaComposition { Episode = new Episode { Id = "episode1" } }; + var mc2 = new MediaComposition { Episode = new Episode { Id = "episode2" } }; + _cache.SetMediaComposition("urn1", mc1); + _cache.SetMediaComposition("urn2", mc2); + + // Act + _cache.Clear(); + var result1 = _cache.GetMediaComposition("urn1", 10); + var result2 = _cache.GetMediaComposition("urn2", 10); + + // Assert + result1.Should().BeNull(); + result2.Should().BeNull(); + } + + [Fact] + public void GetStatistics_ReturnsCorrectCount() + { + // Arrange + var mc1 = new MediaComposition { Episode = new Episode { Id = "episode1" } }; + var mc2 = new MediaComposition { Episode = new Episode { Id = "episode2" } }; + _cache.SetMediaComposition("urn1", mc1); + _cache.SetMediaComposition("urn2", mc2); + + // Act + var (count, sizeEstimate) = _cache.GetStatistics(); + + // Assert + count.Should().Be(2); + sizeEstimate.Should().BeGreaterThan(0); + } + + [Fact] + public void ConcurrentAccess_DoesNotThrow() + { + // Arrange + var tasks = new List(); + + // Act - Perform concurrent operations + for (int i = 0; i < 100; i++) + { + var index = i; + tasks.Add(Task.Run(() => + { + var mc = new MediaComposition { Episode = new Episode { Id = $"episode-{index}" } }; + _cache.SetMediaComposition($"urn-{index}", mc); + _cache.GetMediaComposition($"urn-{index}", 10); + if (index % 2 == 0) + { + _cache.RemoveMediaComposition($"urn-{index}"); + } + })); + } + + // Assert - Should not throw + var action = async () => await Task.WhenAll(tasks); + action.Should().NotThrowAsync(); + } + + public void Dispose() + { + _cache?.Dispose(); + } +} diff --git a/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/StreamUrlResolverTests.cs b/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/StreamUrlResolverTests.cs new file mode 100644 index 0000000..569f66b --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay.Tests/UnitTests/StreamUrlResolverTests.cs @@ -0,0 +1,179 @@ +using FluentAssertions; +using Jellyfin.Plugin.SRFPlay.Api.Models; +using Jellyfin.Plugin.SRFPlay.Configuration; +using Jellyfin.Plugin.SRFPlay.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Plugin.SRFPlay.Tests.UnitTests; + +/// +/// Unit tests for StreamUrlResolver. +/// +public class StreamUrlResolverTests : IDisposable +{ + private readonly Mock> _loggerMock; + private readonly StreamUrlResolver _resolver; + + public StreamUrlResolverTests() + { + _loggerMock = new Mock>(); + _resolver = new StreamUrlResolver(_loggerMock.Object); + } + + [Fact] + public void GetStreamUrl_WithNullChapter_ReturnsNull() + { + // Act + var result = _resolver.GetStreamUrl(null!, QualityPreference.Auto); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetStreamUrl_WithNoResources_ReturnsNull() + { + // Arrange + var chapter = new Chapter + { + Id = "test-id", + ResourceList = new List() + }; + + // Act + var result = _resolver.GetStreamUrl(chapter, QualityPreference.Auto); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetStreamUrl_WithDrmProtectedOnly_ReturnsNull() + { + // Arrange + var chapter = new Chapter + { + Id = "test-id", + ResourceList = new List + { + new Resource + { + Url = "https://example.com/stream.m3u8", + Protocol = "HLS", + Quality = "HD", + DrmList = new System.Text.Json.JsonElement() // Non-empty DRM + } + } + }; + + // Act + var result = _resolver.GetStreamUrl(chapter, QualityPreference.Auto); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void HasPlayableContent_WithNonDrmHlsStream_ReturnsTrue() + { + // Arrange + var chapter = new Chapter + { + ResourceList = new List + { + new Resource + { + Url = "https://example.com/stream.m3u8", + Protocol = "HLS", + Quality = "HD", + DrmList = null + } + } + }; + + // Act + var result = _resolver.HasPlayableContent(chapter); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void HasPlayableContent_WithDrmOnly_ReturnsFalse() + { + // Arrange + var chapter = new Chapter + { + ResourceList = new List + { + new Resource + { + Url = "https://example.com/stream.m3u8", + Protocol = "HLS", + DrmList = new System.Text.Json.JsonElement() // Non-empty DRM + } + } + }; + + // Act + var result = _resolver.HasPlayableContent(chapter); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void IsContentExpired_WithFutureValidTo_ReturnsFalse() + { + // Arrange + var chapter = new Chapter + { + ValidTo = DateTime.UtcNow.AddDays(7) + }; + + // Act + var result = _resolver.IsContentExpired(chapter); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void IsContentExpired_WithPastValidTo_ReturnsTrue() + { + // Arrange + var chapter = new Chapter + { + ValidTo = DateTime.UtcNow.AddDays(-1) + }; + + // Act + var result = _resolver.IsContentExpired(chapter); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsContentExpired_WithNullValidTo_ReturnsFalse() + { + // Arrange + var chapter = new Chapter + { + ValidTo = null + }; + + // Act + var result = _resolver.IsContentExpired(chapter); + + // Assert + result.Should().BeFalse(); + } + + public void Dispose() + { + _resolver?.Dispose(); + } +} diff --git a/README.md b/README.md index 29fc7eb..dcbcfb9 100644 --- a/README.md +++ b/README.md @@ -103,11 +103,10 @@ Video playback with HLS streaming support and quality selection. **Successfully compiling!** All code analysis warnings resolved. ### ๐Ÿงช Testing Status -- [x] Unit tests (xUnit framework) - StreamUrlResolver tests - MetadataCache tests - More to be added -- [x] API spec validation tests (nightly automated runs) +- [ ] API spec validation tests (nightly automated runs) - All business units (SRF, RTS, RSI, RTR, SWI) - Response schema validation - Performance monitoring diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md new file mode 100644 index 0000000..ce9ab1d --- /dev/null +++ b/TESTING_GUIDE.md @@ -0,0 +1,319 @@ +# Testing Guide for SRF Play Plugin + +This guide explains how to run tests and set up the nightly API validation for the Jellyfin SRF Play plugin. + +## Overview + +The plugin now has a comprehensive test suite: + +1. **Unit Tests** - Fast, isolated tests for individual components +2. **Integration Tests (API Spec Tests)** - Real API calls to validate the SRF Play API is working correctly +3. **Nightly CI Tests** - Automated nightly runs to detect API changes + +## Prerequisites + +### Quick Setup + +Run the setup script to check your environment: + +```bash +./setup-tests.sh +``` + +### Required Software + +**.NET 8.0 SDK** (Required - matches Jellyfin requirements) + +```bash +# Arch Linux/CachyOS +sudo pacman -S dotnet-sdk-8.0 aspnet-runtime-8.0 + +# Or download from: +# https://dotnet.microsoft.com/download/dotnet/8.0 +``` + +**Verify Installation:** +```bash +dotnet --list-runtimes +# Should show: +# Microsoft.NETCore.App 8.x.x +``` + +**Other Requirements:** +- Internet connection (for integration/API tests) + +## Running Tests Locally + +### All Tests +```bash +dotnet test +``` + +### Unit Tests Only +```bash +dotnet test --filter "Category!=Integration&Category!=APISpec" +``` + +### API Spec Tests Only +```bash +dotnet test --filter "Category=APISpec" +``` + +### With Code Coverage +```bash +dotnet test --collect:"XPlat Code Coverage" +``` + +### With Detailed Output +```bash +dotnet test --logger "console;verbosity=detailed" +``` + +## Test Structure + +### Unit Tests ([Jellyfin.Plugin.SRFPlay.Tests/UnitTests/](Jellyfin.Plugin.SRFPlay.Tests/UnitTests/)) + +- **StreamUrlResolverTests.cs** - Tests stream URL resolution, DRM filtering, expiration checking +- **MetadataCacheTests.cs** - Tests metadata caching, expiration, thread safety + +**Characteristics:** +- Fast execution (milliseconds) +- No external dependencies +- Run on every commit/PR + +### Integration Tests ([Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/](Jellyfin.Plugin.SRFPlay.Tests/IntegrationTests/)) + +- **SRFApiSpecTests.cs** - Validates SRF Play API compliance + - Tests all business units (SRF, RTS, RSI, RTR, SWI) + - Validates response schemas + - Tests API endpoints accessibility + - Validates HLS stream availability + - Performance monitoring + +**Characteristics:** +- Slower execution (seconds to minutes) +- Makes real API calls +- Run nightly via CI + +## Continuous Integration + +### Unit Tests Workflow + +**File:** [.github/workflows/unit-tests.yaml](.github/workflows/unit-tests.yaml) + +**Triggers:** +- Push to master branch +- Pull requests to master +- Manual trigger + +**Features:** +- Runs all unit tests +- Generates code coverage reports +- Posts coverage summary on PRs +- Fails if tests fail + +### Nightly API Spec Tests Workflow + +**File:** [.github/workflows/nightly-api-tests.yaml](.github/workflows/nightly-api-tests.yaml) + +**Schedule:** Every night at 2 AM UTC + +**Features:** +- Validates SRF Play API is still working +- Tests all business units +- Validates response schemas +- **Automatically creates a GitHub issue if tests fail** +- Provides detailed test reports + +**What happens when tests fail:** +- A GitHub issue is automatically created with: + - Link to the failed workflow run + - Description of what likely changed + - Suggested actions to take +- Labels: `bug`, `api`, `nightly-test-failure` + +## Adding New Tests + +### Unit Test Example + +```csharp +using Xunit; +using FluentAssertions; + +namespace Jellyfin.Plugin.SRFPlay.Tests.UnitTests; + +public class MyServiceTests +{ + [Fact] + public void MyMethod_WithValidInput_ReturnsExpectedResult() + { + // Arrange + var service = new MyService(); + + // Act + var result = service.MyMethod("test"); + + // Assert + result.Should().Be("expected"); + } +} +``` + +### Integration Test Example + +```csharp +using Xunit; +using FluentAssertions; + +namespace Jellyfin.Plugin.SRFPlay.Tests.IntegrationTests; + +[Trait("Category", "Integration")] +[Trait("Category", "APISpec")] +public class MyApiTests +{ + [Fact] + public async Task ApiCall_ReturnsValidData() + { + // Arrange + var client = new SRFApiClient(loggerFactory); + + // Act + var result = await client.GetDataAsync("srf", cancellationToken); + + // Assert + result.Should().NotBeNull(); + result.Should().NotBeEmpty(); + } +} +``` + +## Test Naming Conventions + +Follow the pattern: `MethodName_Scenario_ExpectedBehavior` + +**Good Examples:** +- `GetStreamUrl_WithDrmProtectedOnly_ReturnsNull` +- `IsContentExpired_WithPastValidTo_ReturnsTrue` +- `GetAllShows_SRF_ReturnsShows` + +## What to Do When Tests Fail + +### Unit Tests Fail + +1. Check the error message in the test output +2. Review recent code changes +3. Fix the bug or update the test if behavior changed intentionally +4. Run tests locally before pushing + +### API Spec Tests Fail (Nightly) + +1. **Check the GitHub issue** created automatically +2. **Review the workflow logs** for detailed error messages +3. **Common causes:** + - SRF Play API schema changed + - New authentication requirements + - Endpoints moved or deprecated + - Rate limiting or temporary outages + +4. **Actions to take:** + - Update API models in [Jellyfin.Plugin.SRFPlay/Api/Models/](Jellyfin.Plugin.SRFPlay/Api/Models/) + - Update API client in [SRFApiClient.cs](Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs) + - Update tests to match new behavior + - Document any breaking changes + +### Temporary API Outages + +If the API is temporarily down: +1. Monitor the issue - it will auto-close on next successful run +2. No action needed unless failures persist for multiple days + +## Code Coverage + +Code coverage reports are generated automatically for unit tests in CI. + +**View coverage locally:** +```bash +dotnet test --collect:"XPlat Code Coverage" +# Coverage reports will be in TestResults/*/coverage.cobertura.xml +``` + +**Target:** Aim for >70% coverage for core services + +## Performance Benchmarks + +API spec tests include performance validation: +- API calls should complete within 30 seconds +- Failures indicate potential performance degradation + +## Best Practices + +1. **Write tests first** (TDD) when fixing bugs +2. **Keep unit tests fast** - under 100ms per test +3. **Use descriptive test names** that explain what's being tested +4. **One assertion per test** for clear failure messages +5. **Clean up resources** with IDisposable +6. **Mock external dependencies** in unit tests +7. **Use real APIs** only in integration tests + +## Troubleshooting + +### Tests won't run locally + +```bash +# Ensure .NET 8.0 SDK is installed +dotnet --list-sdks + +# If not installed, download from: +# https://dotnet.microsoft.com/download/dotnet/8.0 +``` + +### Integration tests fail with network errors + +- Check internet connectivity +- Check if SRF Play API is accessible from your location +- Some regions may have geo-restrictions + +### Build succeeds but tests won't execute + +```bash +# Clean and rebuild +dotnet clean +dotnet build +dotnet test +``` + +## Legacy Tests + +The project still contains legacy console-based tests: +- [Program.cs](Jellyfin.Plugin.SRFPlay.Tests/Program.cs) +- [TestPlayV3Api.cs](Jellyfin.Plugin.SRFPlay.Tests/TestPlayV3Api.cs) + +These are kept for manual testing but are not run by CI. To run them: + +```bash +cd Jellyfin.Plugin.SRFPlay.Tests +dotnet run +``` + +## Future Improvements + +- [ ] Add more unit tests for remaining services +- [ ] Add tests for scheduled task functionality +- [ ] Add tests for proxy configuration +- [ ] Increase code coverage to >80% +- [ ] Add mutation testing +- [ ] Add performance benchmarks + +## Questions? + +- Review [Test Documentation](Jellyfin.Plugin.SRFPlay.Tests/README.md) +- Check [GitHub Actions](../../actions) for CI results +- Look at existing tests for examples + +## Summary + +With this testing infrastructure: +- โœ… **Developers** get immediate feedback on code changes +- โœ… **Maintainers** are automatically notified of API changes +- โœ… **Users** benefit from more reliable plugin +- โœ… **Contributors** have clear examples to follow diff --git a/setup-tests.sh b/setup-tests.sh new file mode 100755 index 0000000..658aea7 --- /dev/null +++ b/setup-tests.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +# Setup script for SRF Play Plugin Tests +# This script helps set up the testing environment + +echo "=== SRF Play Plugin - Test Environment Setup ===" +echo + +# Check .NET version +echo "Checking .NET installation..." +dotnet --version +echo + +# Check SDKs +echo "Installed .NET SDKs:" +dotnet --list-sdks +echo + +# Check runtimes +echo "Installed .NET Runtimes:" +dotnet --list-runtimes +echo + +# Check if .NET 8 runtime is installed +if dotnet --list-runtimes | grep -q "Microsoft.NETCore.App 8."; then + echo "โœ“ .NET 8 runtime is installed" + NET8_INSTALLED=true +else + echo "โœ— .NET 8 runtime is NOT installed" + NET8_INSTALLED=false +fi + +# Check if ASP.NET Core 9 runtime is installed +if dotnet --list-runtimes | grep -q "Microsoft.AspNetCore.App 9."; then + echo "โœ“ ASP.NET Core 9 runtime is installed" + ASPNET9_INSTALLED=true +else + echo "โœ— ASP.NET Core 9 runtime is NOT installed" + ASPNET9_INSTALLED=false +fi + +echo +echo "=== Test Execution Options ===" +echo + +if [ "$NET8_INSTALLED" = true ]; then + echo "โœ… Ready to run tests with .NET 8" + echo " dotnet test" + echo +else + echo "โš ๏ธ .NET 8 runtime required (to match Jellyfin requirements)" + echo + echo "Install .NET 8:" + echo " For Arch Linux/CachyOS:" + echo " sudo pacman -S dotnet-sdk-8.0 aspnet-runtime-8.0" + echo + echo " Or download from:" + echo " https://dotnet.microsoft.com/download/dotnet/8.0" + echo +fi + +echo "Option 3: Run tests in GitHub Actions (always works)" +echo " - Tests run automatically on push/PR" +echo " - Nightly API tests run at 2 AM UTC" +echo + +echo "=== Quick Test Commands ===" +echo "Build: dotnet build" +echo "All tests: dotnet test" +echo "Unit tests: dotnet test --filter \"Category!=Integration&Category!=APISpec\"" +echo "API tests: dotnet test --filter \"Category=APISpec\"" +echo + +echo "For more information, see TESTING_GUIDE.md"