Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ npm-debug.log*
# Supabase
supabase/.temp/

# One-off local/ops SQL and helper scripts (not part of the app)
scripts/

# Local-only notes (integration failure reports, scratch docs)
docs/local_docs/

Expand Down
34 changes: 34 additions & 0 deletions backend/BanterApp.Api.Tests/Admin/AdminEndpointIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,38 @@ public async Task GetLaunchChecklist_WithAdmin_ReturnsChecklistItems()
Assert.Contains(body!.Items, i => i.Label.Contains("OPENAI_API_KEY", StringComparison.OrdinalIgnoreCase));
}

[Fact]
public async Task GetPrompts_WithAdmin_ReturnsShippedKeys()
{
using var client = _factory.CreateAdminClient();

var response = await client.GetAsync("/api/admin/prompts");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<PromptListItem>>();
Assert.NotNull(body);
Assert.Contains(body!, p => p.Key == "FeedBanterSystemPrompt");
Assert.Contains(body!, p => p.Key == "PunditExtractionSystemPrompt");
Assert.Equal(5, body!.Count);
}

[Fact]
public async Task PutPrompt_WithAdminAndCsrf_StoresOverride()
{
using var factory = new BanterAppWebApplicationFactory();
using var client = factory.CreateAdminClient();
await CsrfTestHelper.ApplyCsrfAsync(client);

var response = await client.PutAsJsonAsync(
"/api/admin/prompts/NewsReactionSystemPrompt",
new { body = "Admin override for news reactions." });

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var saved = await response.Content.ReadFromJsonAsync<PromptListItem>();
Assert.True(saved!.IsOverride);
Assert.Contains("Admin override", saved.Body, StringComparison.Ordinal);
}

[Fact]
public async Task GetSession_WithAdmin_ReturnsIsPlatformAdminTrue()
{
Expand Down Expand Up @@ -148,4 +180,6 @@ private sealed record LaunchChecklistResponse(List<ChecklistItem> Items);
private sealed record ChecklistItem(string Label, bool Passed);

private sealed record SessionAdminResponse(bool IsPlatformAdmin);

private sealed record PromptListItem(string Key, string Body, bool IsOverride);
}
49 changes: 49 additions & 0 deletions backend/BanterApp.Api.Tests/CompetitionFocusTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using BanterApp.Api.Data;
using Xunit;

namespace BanterApp.Api.Tests;

public class CompetitionFocusTests
{
[Fact]
public void DisplayName_IsPremierLeagueSeason()
{
Assert.Equal("Premier League", CompetitionFocus.Name);
Assert.Contains("2026/27", CompetitionFocus.DisplayName, StringComparison.Ordinal);
Assert.Contains("Premier League", CompetitionFocus.PromptDirective, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Ignore World Cup", CompetitionFocus.PromptDirective, StringComparison.OrdinalIgnoreCase);
}

[Theory]
[InlineData("World Cup hydration breaks", true)]
[InlineData("https://www.fifa.com/tournaments/mens/worldcup/qatar2022", true)]
[InlineData("https://www.theguardian.com/football/world-cup/2026", true)]
[InlineData("Arsenal vs Chelsea Premier League preview", false)]
[InlineData("FIFA 26 career mode", false)]
[InlineData("", false)]
public void LooksLikeOffFocus_DetectsWorldCupNotGenericFifa(string value, bool expected)
{
Assert.Equal(expected, CompetitionFocus.LooksLikeOffFocus(value));
}

[Fact]
public void ApplyToSystemPrompt_PrependsDirectiveOnce()
{
var first = CompetitionFocus.ApplyToSystemPrompt("Write banter.");
var second = CompetitionFocus.ApplyToSystemPrompt(first);

Assert.StartsWith(CompetitionFocus.PromptDirective, first, StringComparison.Ordinal);
Assert.Equal(first, second);
}

[Fact]
public void YouTubeSearchQueries_ArePremierLeagueSpecific()
{
Assert.NotEmpty(CompetitionFocus.YouTubeSearchQueries);
Assert.All(CompetitionFocus.YouTubeSearchQueries, q =>
{
Assert.Contains("Premier League", q, StringComparison.OrdinalIgnoreCase);
Assert.False(CompetitionFocus.LooksLikeOffFocus(q));
});
}
}
18 changes: 18 additions & 0 deletions backend/BanterApp.Api.Tests/FootballBanterConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ public async Task GenerateAsync_PreservesSourceAttribution()
var engine = new FootballBanterEngine(
config,
new StubFootballBanterContentGenerator(),
new UnusedPromptCatalog(),
Microsoft.Extensions.Logging.Abstractions.NullLogger<FootballBanterEngine>.Instance);

var output = await engine.GenerateAsync(new FootballBanterSourceInput
Expand Down Expand Up @@ -234,4 +235,21 @@ public Task<string> GenerateFootballBanterJsonAsync(
public Task<string> GenerateUsernameSuggestionAsync(CancellationToken cancellationToken = default) =>
Task.FromResult("TestKnight42");
}

private sealed class UnusedPromptCatalog : Integrations.Ai.IPromptCatalog
{
public Task<string> ResolveAsync(string key, CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("Stub OpenAI path should not resolve prompts.");

public Task<IReadOnlyList<Integrations.Ai.PromptCatalogEntry>> ListAsync(
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("Stub OpenAI path should not list prompts.");

public Task<Integrations.Ai.PromptCatalogEntry> SaveAsync(
string key,
string? body,
Guid? updatedByUserId,
CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("Stub OpenAI path should not save prompts.");
}
}
1 change: 1 addition & 0 deletions backend/BanterApp.Api.Tests/FootballGifQueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public class FootballGifQueryTests
[InlineData("goal of the season", true)]
[InlineData("happy birthday", false)]
[InlineData("cat dancing", false)]
[InlineData("world cup celebration", false)]
[InlineData("", false)]
public void LooksFootball_DetectsFootballPhrases(string phrase, bool expected)
{
Expand Down
3 changes: 3 additions & 0 deletions backend/BanterApp.Api.Tests/GifLibraryServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public async Task EnsureSeededAsync_InsertsBundledAssetsWithDescriptions()
Assert.Equal(GifAssetSources.Bundled, a.Source);
});
Assert.Contains(await db.GifSearchQueries.Select(q => q.Phrase).ToListAsync(), p => p == "premier league");
Assert.Contains(assets, a => a.Url == "/reactions/delulu-vision.svg");
Assert.True(assets.Count >= 20);
Assert.Contains(await db.GifSearchQueries.Select(q => q.Phrase).ToListAsync(), p => p == "haaland celebration");
}

[Fact]
Expand Down
76 changes: 76 additions & 0 deletions backend/BanterApp.Api.Tests/PromptCatalogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using BanterApp.Api.Integrations.Ai;
using BanterApp.Api.Tests.Infrastructure;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Xunit;

namespace BanterApp.Api.Tests;

public class PromptCatalogTests
{
[Fact]
public async Task ResolveAsync_UsesCodeDefaultWhenTableIsEmpty()
{
await using var db = TestDbContextFactory.Create();
var catalog = CreateCatalog(db, "shipped default");

var resolved = await catalog.ResolveAsync(PromptKeys.NewsReaction);

Assert.Contains("shipped default", resolved, StringComparison.Ordinal);
Assert.Contains("Premier League", resolved, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ResolveAsync_UsesDatabaseOverrideWithoutRedeploy()
{
await using var db = TestDbContextFactory.Create();
var catalog = CreateCatalog(db, "shipped default");

await catalog.SaveAsync(PromptKeys.NewsReaction, "Live override: talk like a Super Sunday desk.", null);

var resolved = await catalog.ResolveAsync(PromptKeys.NewsReaction);

Assert.Contains("Live override", resolved, StringComparison.Ordinal);
Assert.DoesNotContain("shipped default", resolved, StringComparison.Ordinal);
}

[Fact]
public async Task SaveAsync_EmptyBodyClearsOverride()
{
await using var db = TestDbContextFactory.Create();
var catalog = CreateCatalog(db, "shipped default");
await catalog.SaveAsync(PromptKeys.FeedBanter, "temporary override", null);

var cleared = await catalog.SaveAsync(PromptKeys.FeedBanter, " ", null);

Assert.False(cleared.IsOverride);
Assert.Equal("shipped default", cleared.Body);
Assert.Contains("shipped default", await catalog.ResolveAsync(PromptKeys.FeedBanter), StringComparison.Ordinal);
}

[Fact]
public async Task ListAsync_IncludesShippedDefaultsForKnownKeys()
{
await using var db = TestDbContextFactory.Create();
var catalog = CreateCatalog(db, "shipped default");

var list = await catalog.ListAsync();

Assert.Equal(PromptKeys.All.Count, list.Count);
Assert.All(list, e => Assert.False(e.IsOverride));
Assert.Contains(list, e => e.Key == PromptKeys.PunditExtraction);
}

private static PromptCatalog CreateCatalog(Data.AppDbContext db, string defaultBody)
{
var options = Options.Create(new AiOptions
{
NewsReactionSystemPrompt = defaultBody,
FeedBanterSystemPrompt = defaultBody,
MemeImagePrompt = defaultBody,
FeedVisualSystemPrompt = defaultBody,
PunditExtractionSystemPrompt = defaultBody
});
return new PromptCatalog(db, options, new MemoryCache(new MemoryCacheOptions()));
}
}
30 changes: 30 additions & 0 deletions backend/BanterApp.Api.Tests/PunditMediaItemServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,36 @@ public async Task UpsertItemAsync_skips_blank_external_id()
Assert.Empty(db.MediaItems.Local);
}

[Fact]
public async Task UpsertItemAsync_skips_world_cup_titles_and_urls()
{
await using var db = TestDbContextFactory.Create();
var service = new PunditMediaItemService(db);
var source = await service.EnsureSourceAsync("Feed", "rss", "https://example.com/feed.xml");

var byTitle = await service.UpsertItemAsync(
source,
Article("guid-wc", "World Cup hydration breaks"),
CancellationToken.None);
var byUrl = await service.UpsertItemAsync(
source,
new(
"guid-fifa",
"Match preview",
"summary",
"https://www.fifa.com/tournaments/mens/worldcup/preview",
null,
DateTimeOffset.UtcNow,
"https://example.com/feed.xml",
Publication: "FIFA",
FullText: "body"),
CancellationToken.None);

Assert.Equal((0, 0, 1, false), byTitle);
Assert.Equal((0, 0, 1, false), byUrl);
Assert.Empty(db.MediaItems.Local);
}

private static MediaItemDto Article(string externalId, string title) =>
new(
externalId,
Expand Down
42 changes: 42 additions & 0 deletions backend/BanterApp.Api.Tests/Pundits/PunditExtractionJobTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,48 @@ r.Metadata is not null &&
Assert.True(usage.CircuitOpened);
}

[Fact]
public async Task ExtractAsync_SkipsWorldCupItemsWithoutCallingOpenAi()
{
await using var db = TestDbContextFactory.Create();
var source = new MediaSource
{
Id = Guid.NewGuid(),
Name = "Sky",
SourceType = "rss",
ExtractPredictions = true,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow
};
db.MediaSources.Add(source);
var item = EnrichedItem(source.Id, "wc");
item.Title = "World Cup hydration breaks";
item.SourceUrl = "https://www.theguardian.com/football/world-cup/2026";
db.MediaItems.Add(item);
await db.SaveChangesAsync();

var extractor = new RateLimitExtractor();
var tracking = new RecordingErrorTracking();
var usage = new RecordingUsageGuard();
var job = new PunditExtractionJob(
db,
extractor,
persistence: null!,
Options.Create(new PunditIngestOptions { Enabled = true, ExtractionBatchSize = 5, MinSourceTextLength = 20 }),
new SyncRunTracker(db, tracking, NullLogger<SyncRunTracker>.Instance),
new StubRecurringJobs(),
usage,
tracking,
NullLogger<PunditExtractionJob>.Instance);

await job.ExtractAsync(CancellationToken.None);

Assert.Equal(0, extractor.Calls);
var stored = Assert.Single(db.MediaItems);
Assert.Equal(MediaItemProcessingStatus.Skipped, stored.ProcessingStatus);
Assert.Contains("Off-focus", stored.ProcessingError, StringComparison.OrdinalIgnoreCase);
}

private static MediaItem EnrichedItem(Guid sourceId, string suffix) => new()
{
Id = Guid.NewGuid(),
Expand Down
72 changes: 72 additions & 0 deletions backend/BanterApp.Api.Tests/WorldCupLegacyPurgeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,76 @@ public async Task RemovesMisStampedApiFootballWorldCupRows()
Assert.True(await db.Matches.AnyAsync(m => m.Id == "pl26-mw1-1"));
Assert.False(await db.Matches.AnyAsync(m => m.Id.StartsWith("apifb-")));
}

[Fact]
public async Task RemovesWorldCupMediaItemsAndDeactivatesGifQueries()
{
await using var db = TestDbContextFactory.Create();
var source = new MediaSource
{
Id = Guid.NewGuid(),
Name = "Sky Sports",
SourceType = "rss",
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow
};
var fifaSource = new MediaSource
{
Id = Guid.NewGuid(),
Name = "FIFA World Cup",
SourceType = "rss",
SiteUrl = "https://www.fifa.com/tournaments/mens/worldcup",
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow
};
db.MediaSources.AddRange(source, fifaSource);
db.MediaItems.AddRange(
new MediaItem
{
Id = Guid.NewGuid(),
MediaSourceId = source.Id,
ExternalId = "pl-1",
Title = "Arsenal vs Chelsea preview",
SourceUrl = "https://www.skysports.com/football/arsenal-chelsea",
Description = "Premier League preview",
ProcessingStatus = MediaItemProcessingStatus.Pending,
LastSyncedAt = DateTimeOffset.UtcNow
},
new MediaItem
{
Id = Guid.NewGuid(),
MediaSourceId = source.Id,
ExternalId = "wc-1",
Title = "World Cup hydration breaks",
SourceUrl = "https://www.theguardian.com/football/world-cup/2026",
Description = "World Cup notes",
ProcessingStatus = MediaItemProcessingStatus.Pending,
LastSyncedAt = DateTimeOffset.UtcNow
});
db.GifSearchQueries.Add(new GifSearchQuery
{
Id = Guid.NewGuid(),
Phrase = "world cup celebration",
Source = GifSearchQuerySources.GiphyTrending,
IsFootballRelated = true,
IsActive = true
});
db.GeneratedContents.Add(new GeneratedContent
{
Id = Guid.NewGuid(),
Type = GeneratedContentType.Banter,
Prompt = "Write World Cup banter",
Output = "England to the semi-finals"
});
await db.SaveChangesAsync();

var removed = await WorldCupLegacyPurge.ExecuteAsync(db);

Assert.Equal(0, removed);
Assert.Equal("Arsenal vs Chelsea preview", (await db.MediaItems.SingleAsync()).Title);
Assert.False(await db.GeneratedContents.AnyAsync());
Assert.False((await db.GifSearchQueries.SingleAsync()).IsActive);
Assert.True((await db.MediaSources.SingleAsync(s => s.Name == "Sky Sports")).IsActive);
Assert.False((await db.MediaSources.SingleAsync(s => s.Name == "FIFA World Cup")).IsActive);
}
}
Loading
Loading