This commit is contained in:
Gabrieal Jimmy
2026-07-16 13:14:58 -05:00
commit f321976d70
18 changed files with 2435 additions and 0 deletions

124
SmokeTests/Program.cs Normal file
View File

@@ -0,0 +1,124 @@
using System.Text.Json;
using Jellyfin.Plugin.RedditComments.Services;
using Microsoft.Extensions.Logging.Abstractions;
var failures = 0;
void Check(bool condition, string name)
{
Console.WriteLine((condition ? "PASS" : "FAIL") + " " + name);
if (!condition)
{
failures++;
}
}
// ---------------------------------------------------------------- CommentCache
var dbPath = Path.Combine(Path.GetTempPath(), "rc-test-" + Guid.NewGuid().ToString("N"), "cache.db");
var cache = new CommentCache(dbPath, NullLogger<CommentCache>.Instance);
Check(cache.Get("item1", TimeSpan.FromDays(30), TimeSpan.FromHours(24)) is null, "cache: miss on empty db");
cache.Set(new CachedThread
{
ItemId = "item1",
ThreadId = "abc123",
ThreadTitle = "Some Show - Episode 5 discussion",
Subreddit = "anime",
Permalink = "/r/anime/comments/abc123/x/",
ThreadScore = 500,
NumComments = 321,
CommentsJson = "[{\"Author\":\"u\",\"Body\":\"b\",\"Score\":1,\"CreatedUtc\":1,\"Replies\":[]}]"
});
var hit = cache.Get("item1", TimeSpan.FromDays(30), TimeSpan.FromHours(24));
Check(hit is not null && hit.ThreadId == "abc123" && hit.NumComments == 321, "cache: hit after set");
Check(hit!.FetchedAt > DateTime.UtcNow.AddMinutes(-1), "cache: fetched_at stamped");
var stale = cache.Get("item1", TimeSpan.Zero, TimeSpan.FromHours(24));
Check(stale is null, "cache: stale entry treated as miss");
// negative caching
cache.Set(new CachedThread { ItemId = "item2" });
var notFound = cache.Get("item2", TimeSpan.FromDays(30), TimeSpan.FromHours(24));
Check(notFound is not null && notFound.ThreadId == string.Empty, "cache: negative result cached");
Check(cache.Get("item2", TimeSpan.FromDays(30), TimeSpan.Zero) is null, "cache: negative result expires on its own ttl");
cache.Invalidate("item1");
Check(cache.Get("item1", TimeSpan.FromDays(30), TimeSpan.FromHours(24)) is null, "cache: invalidate removes entry");
// reopen against the same file to prove persistence
var cache2 = new CommentCache(dbPath, NullLogger<CommentCache>.Instance);
Check(cache2.Get("item2", TimeSpan.FromDays(30), TimeSpan.FromHours(24)) is not null, "cache: persists across instances");
// ---------------------------------------------------------------- Parsing: search
const string searchJson = """
{"kind":"Listing","data":{"children":[
{"kind":"t3","data":{"id":"abc123","title":"Some Show - Episode 5 discussion","subreddit":"anime","permalink":"/r/anime/comments/abc123/x/","score":500,"num_comments":321,"link_flair_text":"Episode Discussion"}},
{"kind":"t3","data":{"id":"def456","title":"Unrelated post","subreddit":"anime","permalink":"/r/anime/comments/def456/y/","score":10,"num_comments":2,"link_flair_text":null}}
]}}
""";
using (var doc = JsonDocument.Parse(searchJson))
{
var candidates = RedditCommentsService.ParseSearchCandidates(doc);
Check(candidates.Count == 2, "search: parses all candidates");
Check(candidates[0].Id == "abc123" && candidates[0].NumComments == 321, "search: fields mapped");
Check(candidates[1].LinkFlairText == string.Empty, "search: null flair becomes empty");
}
// ---------------------------------------------------------------- Parsing: comments
const string commentsJson = """
[
{"kind":"Listing","data":{"children":[{"kind":"t3","data":{"id":"abc123","title":"Thread"}}]}},
{"kind":"Listing","data":{"children":[
{"kind":"t1","data":{"author":"user1","body":"Great episode","score":42,"created_utc":1752000000,"replies":{"kind":"Listing","data":{"children":[
{"kind":"t1","data":{"author":"user2","body":"Agreed","score":10,"created_utc":1752000100,"replies":""}},
{"kind":"more","data":{"children":["x","y"]}}
]}}}},
{"kind":"t1","data":{"author":"user3","body":"[deleted]","score":50,"created_utc":1752000200,"replies":""}},
{"kind":"t1","data":{"author":"user4","body":"downvoted take","score":-3,"created_utc":1752000300,"replies":""}},
{"kind":"more","data":{"children":["z"]}}
]}}
]
""";
using (var doc = JsonDocument.Parse(commentsJson))
{
var comments = RedditCommentsService.ParseCommentTree(doc, minScore: 1);
Check(comments.Count == 1, "comments: deleted/downvoted/more filtered out");
Check(comments[0].Author == "user1" && comments[0].Score == 42, "comments: fields mapped");
Check(comments[0].Replies.Count == 1 && comments[0].Replies[0].Author == "user2", "comments: nested replies parsed, 'more' skipped");
var everything = RedditCommentsService.ParseCommentTree(doc, minScore: -100);
Check(everything.Count == 2, "comments: low minScore keeps downvoted but not deleted");
}
// round-trip through the cache serialization format
using (var doc = JsonDocument.Parse(commentsJson))
{
var comments = RedditCommentsService.ParseCommentTree(doc, minScore: 1);
var serialized = JsonSerializer.Serialize(comments);
var deserialized = JsonSerializer.Deserialize<List<Jellyfin.Plugin.RedditComments.Models.CommentDto>>(serialized);
Check(deserialized is not null && deserialized.Count == 1 && deserialized[0].Replies.Count == 1, "comments: JSON round-trip");
}
// ---------------------------------------------------------------- RateLimiter
var limiter = new RateLimiter(maxPerMinuteOverride: 2);
await limiter.WaitAsync();
await limiter.WaitAsync();
var third = limiter.WaitAsync();
var completed = await Task.WhenAny(third, Task.Delay(TimeSpan.FromMilliseconds(500))) == third;
Check(!completed, "ratelimit: 3rd request within the window is blocked");
var limiter2 = new RateLimiter(maxPerMinuteOverride: 60);
var sw = System.Diagnostics.Stopwatch.StartNew();
for (var i = 0; i < 60; i++)
{
await limiter2.WaitAsync();
}
sw.Stop();
Check(sw.ElapsedMilliseconds < 1000, "ratelimit: 60 requests pass instantly under the limit");
Console.WriteLine();
Console.WriteLine(failures == 0 ? "ALL TESTS PASSED" : failures + " TEST(S) FAILED");
return failures == 0 ? 0 : 1;