diff --git a/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs
index f1c7ad7..78c81b2 100644
--- a/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs
+++ b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs
@@ -36,6 +36,47 @@ public class TestCaseIssuesController(
private const int StatusFailed = 2;
private const int StatusPassed = 3;
+
+ /// GET api/admin/v1/testcases — every test case with its
+ /// issue link, newest pass first. Optional ?passId= narrows to one
+ /// test pass and ?status= to one status.
+ ///
+ /// Exists because the admin SPA speaks /api/admin/v1 exclusively and
+ /// the client-facing test-case routes are shaped for the in-game QA tab
+ /// (per-pass, no issue fields).
+ [HttpGet("api/admin/v1/testcases")]
+ public async Task ListCases(
+ [FromQuery] uint? passId, [FromQuery] int? status, CancellationToken ct)
+ {
+ var query = db.TestCases.AsQueryable();
+ if (passId is uint pass) query = query.Where(c => c.TestPassId == pass);
+ if (status is int s) query = query.Where(c => c.Status == s);
+
+ var cases = await query
+ .OrderByDescending(c => c.TestPassId)
+ .ThenBy(c => c.Key)
+ .Take(500)
+ .ToListAsync(ct);
+
+ return Ok(new
+ {
+ githubConfigured = github.IsConfigured,
+ repository = github.Repository,
+ cases = cases.Select(c => new
+ {
+ c.Id,
+ c.Key,
+ c.Title,
+ c.RoomName,
+ c.Status,
+ c.TestPassId,
+ issueUrl = c.JiraBugUrl,
+ issueNumber = GitHubIssueService.IssueNumberFromUrl(c.JiraBugUrl),
+ c.UpdatedAt,
+ }).ToList(),
+ });
+ }
+
/// POST api/testcasemanagement/v1/testcase/{id}/issue —
/// file a GitHub issue for a test case and link it.
///
@@ -44,6 +85,7 @@ public class TestCaseIssuesController(
/// pass is therefore safe, which is the whole point — the alternative is a
/// tester filing the same bug on every run.
[HttpPost("api/testcasemanagement/v1/testcase/{id}/issue")]
+ [HttpPost("api/admin/v1/testcases/{id}/issue")]
public async Task CreateIssue(string id, CancellationToken ct)
{
if (!github.IsConfigured)
@@ -81,6 +123,7 @@ public class TestCaseIssuesController(
/// unlink, leaving the issue itself alone. Closing someone's issue as a
/// side effect of tidying a QA link would be the wrong call.
[HttpDelete("api/testcasemanagement/v1/testcase/{id}/issue")]
+ [HttpDelete("api/admin/v1/testcases/{id}/issue")]
public async Task UnlinkIssue(string id, CancellationToken ct)
{
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id, ct);
@@ -112,6 +155,7 @@ public class TestCaseIssuesController(
/// change, so a case a tester has Claimed, or one nobody has run yet, is
/// never rewritten underneath them.
[HttpPost("api/testcasemanagement/v1/issues/sync")]
+ [HttpPost("api/admin/v1/testcases/issues/sync")]
public async Task Sync(CancellationToken ct)
{
if (!github.IsConfigured)
diff --git a/DorkNet.Server/admin-ui/src/pages/Activity.tsx b/DorkNet.Server/admin-ui/src/pages/Activity.tsx
index 9063dc9..d67aff8 100644
--- a/DorkNet.Server/admin-ui/src/pages/Activity.tsx
+++ b/DorkNet.Server/admin-ui/src/pages/Activity.tsx
@@ -1,25 +1,30 @@
import { Audit } from './Audit';
import { PlayerLogs } from './PlayerLogs';
+import { TestCases } from './TestCases';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
-// Both log views in one place: the admin audit trail and the per-player
-// HTTP request ring buffer. Old /audit and /logs routes redirect here.
+// Internal operations views in one place: the admin audit trail, the
+// per-player HTTP request ring buffer, and QA test cases with the GitHub
+// issues filed against them. Old /audit and /logs routes redirect here.
export function Activity() {
- const [tab, setTab] = useTabParam<'audit' | 'logs'>('audit', ['audit', 'logs']);
+ const [tab, setTab] = useTabParam<'audit' | 'logs' | 'testcases'>(
+ 'audit', ['audit', 'logs', 'testcases']);
return (