using System.Text.Json; using PLib.Infrastructure.Metadata; using Shouldly; namespace PLib.Tests.Metadata; /// /// Decoding a stash-box reply, against payloads shaped like the real ones. /// /// /// This is the part of the feature that cannot be checked without a server and a key, and the /// part where a wrong guess looks exactly like "the source knows nothing about your video". /// public sealed class StashBoxPayloadTests { /// The newer query, whose result is one group of scenes per group of fingerprints. private static StashBoxMetadataProvider.GraphQlQuery Nested => StashBoxMetadataProvider.Queries[0]; /// The older query, kept for instances that have not been updated. private static StashBoxMetadataProvider.GraphQlQuery Flat => StashBoxMetadataProvider.Queries[1]; [Fact] public void The_newer_query_returns_scenes_one_level_deeper_and_they_are_flattened() { const string payload = """ { "data": { "findScenesBySceneFingerprints": [ [ { "id": "abc", "title": "Первая", "details": "Описание", "studio": { "name": "Студия" }, "tags": [ { "name": "драма" }, { "name": "нуар" } ], "performers": [ { "performer": { "name": "Актёр" }, "as": "Псевдоним" } ] }, { "id": "def", "title": "Вторая", "studio": null, "tags": [], "performers": [] } ] ] } } """; var matches = Read(payload, Nested); matches.Count.ShouldBe(2); var first = matches[0]; first.SourceName.ShouldBe("StashDB"); first.RemoteId.ShouldBe("abc"); first.Title.ShouldBe("Первая"); first.Description.ShouldBe("Описание"); first.Studios.Select(x => x.Name).ShouldBe(["Студия"]); first.Tags.Select(x => x.Name).ShouldBe(["драма", "нуар"]); // The credited alias is not the person; the label has to be the performer's own name. first.Performers.Select(x => x.Name).ShouldBe(["Актёр"]); matches[1].Studios.ShouldBeEmpty(); } [Fact] public void The_older_query_returns_a_flat_list() { const string payload = """ { "data": { "findSceneByFingerprint": [ { "id": "abc", "title": "Сцена", "studio": { "name": "Студия" }, "tags": [], "performers": [] } ] } } """; Read(payload, Flat).ShouldHaveSingleItem().Title.ShouldBe("Сцена"); } [Fact] public void A_reply_with_no_matches_is_an_empty_list_rather_than_a_failure() { Read("""{ "data": { "findScenesBySceneFingerprints": [ [] ] } }""", Nested).ShouldBeEmpty(); Read("""{ "data": { "findScenesBySceneFingerprints": null } }""", Nested).ShouldBeEmpty(); } [Fact] public void A_scene_without_a_title_still_produces_a_match_that_can_be_shown() { var match = Read( """{ "data": { "findSceneByFingerprint": [ { "id": "abc", "title": null } ] } }""", Flat).ShouldHaveSingleItem(); match.Title.ShouldNotBeNullOrWhiteSpace(); match.Tags.ShouldBeEmpty(); } [Fact] public void The_smallest_picture_still_wide_enough_for_a_card_is_the_one_kept() { // stash-box returns every size it holds, and the first is not the best: originals run // to several thousand pixels, and one of those per performer to draw it 150 wide would // cost megabytes a head. const string payload = """ { "data": { "findSceneByFingerprint": [ { "id": "abc", "title": "Сцена", "studio": { "name": "Студия", "images": [ { "url": "s/4000.jpg", "width": 4000 }, { "url": "s/500.jpg", "width": 500 } ] }, "tags": [ { "name": "драма" } ], "performers": [ { "performer": { "name": "Актёр", "images": [ { "url": "p/2000.jpg", "width": 2000 }, { "url": "p/400.jpg", "width": 400 }, { "url": "p/100.jpg", "width": 100 } ] } } ] } ] } } """; var match = Read(payload, Flat).ShouldHaveSingleItem(); match.Performers.Single().ImageUrl.ShouldBe("p/400.jpg"); match.Studios.Single().ImageUrl.ShouldBe("s/500.jpg"); // stash-box holds no picture for a tag at all, so there is nothing to find. match.Tags.Single().ImageUrl.ShouldBeNull(); } [Fact] public void The_scene_carries_its_own_cover_apart_from_the_pictures_of_its_people() { const string payload = """ { "data": { "findSceneByFingerprint": [ { "id": "abc", "title": "Сцена", "images": [ { "url": "scene/1920.jpg", "width": 1920 }, { "url": "scene/640.jpg", "width": 640 } ], "performers": [ { "performer": { "name": "Актёр", "images": [ { "url": "p/400.jpg", "width": 400 } ] } } ] } ] } } """; var match = Read(payload, Flat).ShouldHaveSingleItem(); match.ImageUrl.ShouldBe("scene/640.jpg"); match.Performers.Single().ImageUrl.ShouldBe("p/400.jpg"); // Still a URL, and it stays one: whether to spend the bandwidth is the caller's // decision, and a candidate must never wait on a picture host to be shown. match.ImageUrl.ShouldNotBeNull(); } [Fact] public void When_every_picture_is_too_small_the_widest_is_taken_rather_than_none() { const string payload = """ { "data": { "findSceneByFingerprint": [ { "id": "abc", "title": "Сцена", "performers": [ { "performer": { "name": "Актёр", "images": [ { "url": "p/80.jpg", "width": 80 }, { "url": "p/200.jpg", "width": 200 } ] } } ] } ] } } """; // A small picture beats an empty card. Read(payload, Flat).Single().Performers.Single().ImageUrl.ShouldBe("p/200.jpg"); } [Fact] public void An_entity_with_no_pictures_at_all_is_still_a_match() { const string payload = """ { "data": { "findSceneByFingerprint": [ { "id": "abc", "title": "Сцена", "studio": { "name": "Студия", "images": [] }, "performers": [ { "performer": { "name": "Актёр" } } ] } ] } } """; var match = Read(payload, Flat).ShouldHaveSingleItem(); match.Studios.Single().ImageUrl.ShouldBeNull(); match.Performers.Single().Name.ShouldBe("Актёр"); } [Fact] public void Errors_are_raised_even_though_the_server_answered_two_hundred() { // A rejected key comes back as 200 with an errors array; treating that as "nothing // found" would tell the user their video is unknown rather than their key is wrong. var payload = """{ "errors": [ { "message": "unauthorized" } ], "data": null }"""; Should.Throw(() => Read(payload, Nested)) .Message.ShouldBe("unauthorized"); } [Fact] public void A_missing_field_is_reported_apart_from_other_errors_so_the_older_query_gets_a_turn() { // Verbatim from a live instance that only has the newer query. var payload = """ { "errors": [ { "message": "Cannot query field \"findSceneByFingerprint\" on type \"Query\". Did you mean \"findScenesBySceneFingerprints\"?" } ] } """; var rejected = Should.Throw(() => Read(payload, Flat)); // Not the type the caller reports as a failure: this one means "try the next query". rejected.ShouldNotBeOfType(); } private static IReadOnlyList Read( string payload, StashBoxMetadataProvider.GraphQlQuery query) { using var document = JsonDocument.Parse(payload); return StashBoxMetadataProvider.ReadPayload(document.RootElement, query, "StashDB"); } }