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.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.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>(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;