Skip to content

Commit 72da67b

Browse files
jim60105Copilot
andcommitted
test: add xUnit suite, coverage gate, and CI for the audio pipeline
Add a SoundButtons.Tests xUnit project with unit and integration tests covering all openspec/specs requirements, enforcing a >=85% line+branch coverage gate via coverlet.msbuild. Introduce behavior-preserving testability seams: IProcessAudioService/IOpenAiService interfaces, internal static OptionSet builders, IHttpClientFactory-based YouTube scraping, and ServiceCollectionExtensions.AddSoundButtonsServices. Add Dockerfile `test` and `report` stages (off the production path) and a test.yml workflow that runs the suite + gate inside the test stage, builds the final image, uploads coverage to Codecov (fork-safe), and runs infra conformance checks (TFM, Dockerfile pins, dumb-init/STOPSIGNAL, hadolint, helm automount). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9b164e7 commit 72da67b

45 files changed

Lines changed: 2976 additions & 67 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
name: test
2+
3+
# Runs the test suite (with the >=85% line+branch coverage gate enforced inside
4+
# the Docker `test` stage) and the infrastructure conformance checks. The Docker
5+
# build fails on any test or coverage-gate failure, so coverage is enforced
6+
# independently of Codecov.
7+
on:
8+
pull_request:
9+
push:
10+
branches:
11+
- "master"
12+
workflow_dispatch:
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
conformance:
19+
name: Infra conformance checks
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v6
24+
25+
- name: Assert .NET target framework and Functions version
26+
run: |
27+
grep -q '<TargetFramework>net10.0</TargetFramework>' SoundButtons/SoundButtons.csproj
28+
grep -q '<AzureFunctionsVersion>v4</AzureFunctionsVersion>' SoundButtons/SoundButtons.csproj
29+
30+
- name: Assert Dockerfile image and tool pins
31+
run: |
32+
# .NET 10 base/build/sdk images
33+
grep -Eq 'mcr\.microsoft\.com/dotnet/sdk:10\.0' Dockerfile
34+
# ffmpeg pinned to 8.1
35+
grep -q 'static-ffmpeg-upx:8.1' Dockerfile
36+
# dumb-init pinned to v1.2.5 with checksum verification
37+
grep -q 'dumb-init/releases/download/v1.2.5/' Dockerfile
38+
grep -q 'sha256sum -c -' Dockerfile
39+
40+
- name: Assert container init process (dumb-init PID 1)
41+
run: |
42+
grep -q 'ENTRYPOINT \[ "dumb-init"' Dockerfile
43+
grep -q 'STOPSIGNAL SIGINT' Dockerfile
44+
# dumb-init must come from the dedicated checksum-verified `download` stage,
45+
# not be bundled with the ffmpeg image, and must not run with --single-child.
46+
grep -q 'COPY .*--from=download /dumb-init' Dockerfile
47+
! grep -q 'single-child' Dockerfile
48+
49+
- name: Lint Dockerfile (hadolint)
50+
uses: hadolint/hadolint-action@v3.1.0
51+
with:
52+
dockerfile: Dockerfile
53+
54+
- name: Set up Helm
55+
uses: azure/setup-helm@v4
56+
57+
- name: Assert Kubernetes automountServiceAccountToken disabled by default
58+
run: |
59+
helm template helm | grep -q 'automountServiceAccountToken: false'
60+
61+
test:
62+
name: Test suite + coverage
63+
runs-on: ubuntu-latest
64+
permissions:
65+
contents: read
66+
env:
67+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
68+
steps:
69+
- name: Checkout
70+
uses: actions/checkout@v6
71+
with:
72+
submodules: true
73+
74+
- name: Set up Docker Buildx
75+
uses: docker/setup-buildx-action@v4
76+
77+
# The `test` stage runs the suite and enforces the >=85% line+branch gate;
78+
# the build fails here on any test or coverage failure. The `report` stage
79+
# exports the Cobertura XML + TRX for Codecov.
80+
- name: Run tests and export coverage report
81+
run: |
82+
docker build --target report --output type=local,dest=./out -f Dockerfile .
83+
84+
# The design requires CI to confirm the production image still builds; the
85+
# test/report stages are off the production path, so build `final` explicitly.
86+
- name: Build production image (final stage)
87+
run: |
88+
docker build --target final -t soundbuttons-final-ci -f Dockerfile .
89+
90+
- name: Upload coverage to Codecov
91+
if: ${{ env.CODECOV_TOKEN != '' }}
92+
uses: codecov/codecov-action@v5
93+
with:
94+
token: ${{ secrets.CODECOV_TOKEN }}
95+
files: ./out/testresults/coverage.cobertura.xml
96+
fail_ci_if_error: false

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,10 @@ __pycache__/
264264
*.pyc
265265
.env
266266
tmp/**
267+
268+
# Coverage reports
269+
coverage*.xml
270+
coverage*.json
271+
*.cobertura.xml
272+
SoundButtons.Tests/cov.xml
273+
/out/

Dockerfile

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,53 @@ COPY SoundButtons/ ./SoundButtons/
6161
ARG TARGETARCH
6262
RUN dotnet publish "SoundButtons/SoundButtons.csproj" -a $TARGETARCH -c $BUILD_CONFIGURATION -o /app --no-restore
6363

64+
########################################
65+
# Test stage
66+
########################################
67+
# Runs unit + integration tests on the build platform (tests execute natively, so no
68+
# cross-arch emulation). The static ffmpeg/ffprobe binaries enable the encoder
69+
# integration tests; coverage is enforced via the coverlet.msbuild threshold configured
70+
# in the test project. Results are written to /testresults for the report stage to export.
71+
FROM build AS test
72+
73+
# ffmpeg/ffprobe for the encoder integration tests (no network required: media is
74+
# synthesized with lavfi virtual inputs).
75+
COPY --chmod=775 --from=ghcr.io/jim60105/static-ffmpeg-upx:8.1 /ffmpeg /usr/local/bin/
76+
COPY --chmod=775 --from=ghcr.io/jim60105/static-ffmpeg-upx:8.1 /ffprobe /usr/local/bin/
77+
78+
# yt-dlp for the generic download-path integration test (driven against a local file://
79+
# URL, so still no network is required at test time).
80+
ADD --chmod=775 https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux /usr/local/bin/yt-dlp
81+
82+
WORKDIR /source
83+
84+
# Restore the test project (and its reference to the production project) for the build
85+
# platform so the test host runs natively.
86+
COPY SoundButtons.Tests/SoundButtons.Tests.csproj ./SoundButtons.Tests/
87+
RUN dotnet restore "SoundButtons.Tests/SoundButtons.Tests.csproj"
88+
89+
# Copy the rest of the source files (production project already copied is not, so copy both)
90+
COPY SoundButtons/ ./SoundButtons/
91+
COPY SoundButtons.Tests/ ./SoundButtons.Tests/
92+
93+
# Run tests with coverage. The coverlet.msbuild Threshold (85, line+branch, total) in the
94+
# test csproj fails the build if coverage regresses. Cobertura + TRX are emitted for CI.
95+
RUN dotnet test "SoundButtons.Tests/SoundButtons.Tests.csproj" \
96+
-c Debug \
97+
--results-directory /testresults \
98+
--logger "trx;LogFileName=test-results.trx" \
99+
-p:CollectCoverage=true \
100+
"-p:CoverletOutputFormat=cobertura%2cjson" \
101+
-p:CoverletOutput=/testresults/
102+
103+
########################################
104+
# Report stage
105+
########################################
106+
# Minimal scratch image whose sole purpose is to export the test results/coverage to the
107+
# host via `docker build --target report --output type=local,dest=...`.
108+
FROM scratch AS report
109+
COPY --from=test /testresults /testresults
110+
64111
########################################
65112
# Download stage
66113
########################################

SoundButtons.Tests/AssemblyInfo.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
using Xunit;
2+
3+
// Several tests mutate process-global state (environment variables, PATH, and the
4+
// current working directory) to exercise binary discovery and configuration code
5+
// paths. Disable cross-class parallelization so those mutations cannot race.
6+
[assembly: CollectionBehavior(DisableTestParallelization = true)]
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Threading;
5+
using Azure;
6+
using Azure.Storage.Blobs;
7+
using Azure.Storage.Blobs.Models;
8+
using Microsoft.Extensions.Azure;
9+
using Moq;
10+
11+
namespace SoundButtons.Tests.Fakes;
12+
13+
/// <summary>
14+
/// Builds mocked Azure Blob Storage clients so functions depending on
15+
/// <see cref="IAzureClientFactory{BlobServiceClient}" /> can be unit tested without a
16+
/// real storage account. All blob operations are intercepted in-memory.
17+
/// </summary>
18+
public static class BlobMocks
19+
{
20+
public static IAzureClientFactory<BlobServiceClient> CreateFactory(Mock<BlobContainerClient> container)
21+
{
22+
var service = new Mock<BlobServiceClient>();
23+
service.Setup(s => s.GetBlobContainerClient(It.IsAny<string>())).Returns(container.Object);
24+
25+
var factory = new Mock<IAzureClientFactory<BlobServiceClient>>();
26+
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(service.Object);
27+
return factory.Object;
28+
}
29+
30+
public static Mock<BlobContainerClient> CreateContainer()
31+
{
32+
var container = new Mock<BlobContainerClient>();
33+
container.Setup(c => c.Name).Returns("sound-buttons");
34+
return container;
35+
}
36+
37+
public static Mock<BlobClient> CreateBlob(bool exists, string? readContent = null)
38+
{
39+
var blob = new Mock<BlobClient>();
40+
blob.Setup(b => b.Name).Returns("blob");
41+
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
42+
.ReturnsAsync(Response.FromValue(exists, Mock.Of<Response>()));
43+
blob.Setup(b => b.Exists(It.IsAny<CancellationToken>()))
44+
.Returns(Response.FromValue(exists, Mock.Of<Response>()));
45+
46+
if (readContent is not null)
47+
{
48+
blob.Setup(b => b.OpenReadAsync(It.IsAny<long>(), It.IsAny<int?>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()))
49+
.ReturnsAsync(() => new MemoryStream(System.Text.Encoding.UTF8.GetBytes(readContent)));
50+
}
51+
52+
blob.Setup(b => b.UploadAsync(It.IsAny<BinaryData>(), It.IsAny<BlobUploadOptions>(), It.IsAny<CancellationToken>()))
53+
.ReturnsAsync(Response.FromValue(Mock.Of<BlobContentInfo>(), Mock.Of<Response>()));
54+
blob.Setup(b => b.UploadAsync(It.IsAny<string>(), It.IsAny<BlobUploadOptions>(), It.IsAny<CancellationToken>()))
55+
.ReturnsAsync(Response.FromValue(Mock.Of<BlobContentInfo>(), Mock.Of<Response>()));
56+
blob.Setup(b => b.SetMetadataAsync(It.IsAny<IDictionary<string, string>>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()))
57+
.ReturnsAsync(Response.FromValue(Mock.Of<BlobInfo>(), Mock.Of<Response>()));
58+
59+
return blob;
60+
}
61+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System.Net.Http;
2+
using Microsoft.Extensions.DependencyInjection;
3+
4+
namespace SoundButtons.Tests.Fakes;
5+
6+
/// <summary>Simple <see cref="IHttpClientFactory" /> returning a client over the supplied handler.</summary>
7+
public sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
8+
{
9+
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
10+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System.Collections.Generic;
2+
using Microsoft.Azure.Functions.Worker.Http;
3+
4+
namespace SoundButtons.Tests.Fakes;
5+
6+
/// <summary>No-op <see cref="HttpCookies" /> double; the production code under test does not use cookies.</summary>
7+
public sealed class FakeHttpCookies : HttpCookies
8+
{
9+
public List<IHttpCookie> Appended { get; } = [];
10+
11+
public override void Append(string name, string value) => Appended.Add(new HttpCookie(name, value));
12+
13+
public override void Append(IHttpCookie cookie) => Appended.Add(cookie);
14+
15+
public override IHttpCookie CreateNew() => new HttpCookie(string.Empty, string.Empty);
16+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Net;
4+
using System.Net.Http;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
8+
namespace SoundButtons.Tests.Fakes;
9+
10+
/// <summary>
11+
/// Test <see cref="HttpMessageHandler" /> returning queued/predicated responses, used
12+
/// for the OpenAI client and the YouTube-clip scrape. No real network is performed.
13+
/// </summary>
14+
public sealed class FakeHttpMessageHandler : HttpMessageHandler
15+
{
16+
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
17+
18+
public List<HttpRequestMessage> Requests { get; } = [];
19+
20+
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
21+
22+
public FakeHttpMessageHandler(HttpStatusCode statusCode, string content)
23+
: this(_ => new HttpResponseMessage(statusCode) { Content = new StringContent(content) })
24+
{
25+
}
26+
27+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
28+
{
29+
Requests.Add(request);
30+
return Task.FromResult(_responder(request));
31+
}
32+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Net;
5+
using System.Security.Claims;
6+
using System.Text;
7+
using Microsoft.Azure.Functions.Worker;
8+
using Microsoft.Azure.Functions.Worker.Http;
9+
10+
namespace SoundButtons.Tests.Fakes;
11+
12+
/// <summary>
13+
/// Minimal in-memory <see cref="HttpRequestData" /> double for exercising the HTTP
14+
/// trigger without the Functions host. The body is supplied as a stream and headers
15+
/// are mutable.
16+
/// </summary>
17+
public sealed class FakeHttpRequestData(FunctionContext functionContext, Stream body, string contentType)
18+
: HttpRequestData(functionContext)
19+
{
20+
public override Stream Body { get; } = body;
21+
22+
public override HttpHeadersCollection Headers { get; } = BuildHeaders(contentType);
23+
24+
public override IReadOnlyCollection<IHttpCookie> Cookies { get; } = Array.Empty<IHttpCookie>();
25+
26+
public override Uri Url { get; } = new("https://sound-buttons.click/api/sound-buttons");
27+
28+
public override IEnumerable<ClaimsIdentity> Identities { get; } = Array.Empty<ClaimsIdentity>();
29+
30+
public override string Method => "POST";
31+
32+
public override HttpResponseData CreateResponse() => new FakeHttpResponseData(FunctionContext);
33+
34+
private static HttpHeadersCollection BuildHeaders(string contentType)
35+
{
36+
var headers = new HttpHeadersCollection();
37+
if (!string.IsNullOrEmpty(contentType))
38+
{
39+
headers.Add("Content-Type", contentType);
40+
}
41+
42+
return headers;
43+
}
44+
45+
public static FakeHttpRequestData FromText(FunctionContext context, string body, string contentType)
46+
=> new(context, new MemoryStream(Encoding.UTF8.GetBytes(body)), contentType);
47+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System.IO;
2+
using System.Net;
3+
using Microsoft.Azure.Functions.Worker;
4+
using Microsoft.Azure.Functions.Worker.Http;
5+
6+
namespace SoundButtons.Tests.Fakes;
7+
8+
/// <summary>
9+
/// Minimal in-memory <see cref="HttpResponseData" /> double. The body is a seekable
10+
/// <see cref="MemoryStream" /> so tests can read whatever the production code wrote.
11+
/// </summary>
12+
public sealed class FakeHttpResponseData(FunctionContext functionContext) : HttpResponseData(functionContext)
13+
{
14+
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
15+
16+
public override HttpHeadersCollection Headers { get; set; } = new();
17+
18+
public override Stream Body { get; set; } = new MemoryStream();
19+
20+
public override HttpCookies Cookies { get; } = new FakeHttpCookies();
21+
22+
/// <summary>Reads the response body as a UTF-8 string.</summary>
23+
public string ReadBodyAsString()
24+
{
25+
Body.Position = 0;
26+
using var reader = new StreamReader(Body, leaveOpen: true);
27+
return reader.ReadToEnd();
28+
}
29+
}

0 commit comments

Comments
 (0)