From 7abfcc4ec4e61b360b3fa8debd1f7100a90ac735 Mon Sep 17 00:00:00 2001 From: Duncan Garmonsway Date: Thu, 26 Feb 2026 23:55:52 +0000 Subject: [PATCH] Test link expansion inclusion rules with truth tables (reverse links) Note: this only covers reverse links. Direct links will be done separately (see #3886). This exhaustively tests the simplest case of link expansion, when there is a single candidate link to be included in a Content Store or GraphQL response. The simplicity makes it possible to test all possible combinations of properties of link sources and targets. This differs from other tests because it specifically checks whether a link should be included, whereas other tests only check whether GraphQL does the same thing as classic link expanion, whether that's correct or not. Drafts are currently only tested for classic link expansion. Once GraphQL supports drafts, a minor change to these tests will test GraphQL's link expansion too. The more complex scenario, not addressed here, is when there are several candidate links. Precedence determines which of the candidates are expanded. But for a link to be considered for precedence, it must already pass the inclusion rules tested in this PR. --- .../reverse_link_inclusion_spec.rb | 101 ++++++++++++++++ .../reverse_links/test_case.rb | 114 ++++++++++++++++++ .../reverse_links/test_case_factory.rb | 66 ++++++++++ 3 files changed, 281 insertions(+) create mode 100644 spec/integration/graphql/link_expansion/reverse_link_inclusion_spec.rb create mode 100644 spec/support/graphql_link_expansion_inclusion_helpers/reverse_links/test_case.rb create mode 100644 spec/support/graphql_link_expansion_inclusion_helpers/reverse_links/test_case_factory.rb diff --git a/spec/integration/graphql/link_expansion/reverse_link_inclusion_spec.rb b/spec/integration/graphql/link_expansion/reverse_link_inclusion_spec.rb new file mode 100644 index 0000000000..4ddef10da9 --- /dev/null +++ b/spec/integration/graphql/link_expansion/reverse_link_inclusion_spec.rb @@ -0,0 +1,101 @@ +RSpec.describe "reverse link expansion inclusion" do + def for_content_store(target_edition, link_type:, with_drafts:) + reverse_link_type = ExpansionRules::REVERSE_LINKS[link_type.to_sym] || link_type + Presenters::Queries::ExpandedLinkSet + .by_edition(target_edition, with_drafts:) + .links + .fetch(reverse_link_type, []) + end + + def for_graphql(target_edition, link_type:, with_drafts:) + GraphQL::Dataloader.with_dataloading do |dataloader| + request = dataloader.with( + Sources::ReverseLinkedToEditionsSource, + content_store: with_drafts ? "draft" : "live", + locale: target_edition.locale, + ).request([target_edition, link_type]) + + request.load + end + end + + test_cases = GraphqlLinkExpansionInclusionHelpers::ReverseLinks::TestCaseFactory.all + + test_cases.each do |test_case| + context "when the link kind is #{test_case.link_kind}" do + context test_case.with_drafts_description do + context test_case.target_edition_locale_description do + it test_case.description do + linked_edition = create( + :live_edition, + document: create( + :document, + locale: test_case.root_locale, + ), + ) + + source_edition = create( + :edition, + state: test_case.state, + content_store: test_case.state == "draft" ? "draft" : "live", + document_type: test_case.source_edition_document_type, + document: create( + :document, + locale: test_case.locale, + ), + test_case.link_kind => [ + { + link_type: test_case.link_type, + target_content_id: linked_edition.content_id, + }, + ], + ) + + if test_case.state == "unpublished" + create( + :unpublishing, + edition: source_edition, + type: test_case.source_edition_unpublishing_type, + ) + end + + %w[content_store graphql].each do |destination| + # GraphQL doesn't yet support drafts + next if destination == "graphql" && test_case.with_drafts? + + # GraphQL will only attempt to find a reverse link for a field that is declared + # as a reverse_links_field in app/graphql/types/edition_type.rb. This test + # calls the dataloader directly, but the dataloader won't check that the + # link type is reversible, so we should skip irreversible link types. + next if destination == "graphql" && !test_case.allowed_reverse_link_type + + result = send( + :"for_#{destination}", + linked_edition, + **{ + link_type: test_case.link_type, + with_drafts: test_case.with_drafts?, + }, + ) + + if test_case.included + # if result.size != 1 + # byebug + # end + expect(result.size).to( + eq(1), + "unexpected exclusion for #{destination}", + ) + else + expect(result).to( + be_empty, + "unexpected inclusion for #{destination}", + ) + end + end + end + end + end + end + end +end diff --git a/spec/support/graphql_link_expansion_inclusion_helpers/reverse_links/test_case.rb b/spec/support/graphql_link_expansion_inclusion_helpers/reverse_links/test_case.rb new file mode 100644 index 0000000000..9498e9cac6 --- /dev/null +++ b/spec/support/graphql_link_expansion_inclusion_helpers/reverse_links/test_case.rb @@ -0,0 +1,114 @@ +module GraphqlLinkExpansionInclusionHelpers + module ReverseLinks + class TestCase + def initialize( + with_drafts:, + root_locale:, + state:, + renderable_document_type:, + locale:, + withdrawal:, + permitted_unpublished_link_type:, + allowed_reverse_link_type:, + link_kind: + ) + @with_drafts = with_drafts + @root_locale = root_locale + @state = state + @renderable_document_type = renderable_document_type + @locale = locale + @withdrawal = withdrawal + @permitted_unpublished_link_type = permitted_unpublished_link_type + @allowed_reverse_link_type = allowed_reverse_link_type + @link_kind = link_kind + end + + attr_reader :root_locale, :state, :renderable_document_type, :locale, :allowed_reverse_link_type, :link_kind + + def with_drafts? + @with_drafts + end + + def description + inclusion_string = included ? "includes" : "excludes" + state_string = if state == "unpublished" + link_type_description = permitted_unpublished_link_type? ? "permitted" : "unpermitted" + unpublishing_type_description = withdrawal? ? "withdrawal" : "non-withdrawal" + "an unpublished #{unpublishing_type_description} with a #{link_type_description} link type" + else + state + end + locale_string = locale == "default" ? "in the default locale" : "in the locale \"#{locale}\"" + document_type_string = "a #{renderable_document_type ? 'renderable' : 'non-renderable'} document type" + link_reversibility_string = "via a #{allowed_reverse_link_type ? 'reversible' : 'non-reversible'} link type (i.e. #{link_type})" + + "#{inclusion_string} a source edition that is #{[state_string, document_type_string, locale_string, link_reversibility_string].to_sentence}" + end + + def with_drafts_description + "when #{with_drafts? ? 'accepting' : 'rejecting'} drafts" + end + + def target_edition_locale_description + "when the target edition's locale is \"#{root_locale}\"" + end + + def link_kind_description + "when the link kind is \"#{link_kind}\"" + end + + def source_edition_document_type + @source_edition_document_type ||= if renderable_document_type + renderable_types = GovukSchemas::DocumentTypes.valid_document_types - Edition::NON_RENDERABLE_FORMATS + renderable_types.sample + else + Edition::NON_RENDERABLE_FORMATS.sample + end + end + + def link_type + @link_type ||= begin + reverse_link_types = ExpansionRules::REVERSE_LINKS.keys.map(&:to_s) + permitted_unpublished_link_types = Link::PERMITTED_UNPUBLISHED_LINK_TYPES + + link_types ||= [] + + link_types.concat(reverse_link_types & permitted_unpublished_link_types) if allowed_reverse_link_type && permitted_unpublished_link_type? + link_types.concat(reverse_link_types - permitted_unpublished_link_types) if allowed_reverse_link_type && !permitted_unpublished_link_type? + link_types.concat(permitted_unpublished_link_types - reverse_link_types) if !allowed_reverse_link_type && permitted_unpublished_link_type? + link_types.concat(%w[ordered_related_items]) if !allowed_reverse_link_type && !permitted_unpublished_link_type? + + link_types.sample + end + end + + def source_edition_unpublishing_type + @source_edition_unpublishing_type ||= begin + return "withdrawal" if withdrawal? + + Unpublishing::VALID_TYPES.reject { it == "withdrawal" }.sample + end + end + + def included + renderable_document_type && + allowed_reverse_link_type && + (with_drafts? || state != "draft") && + (state != "unpublished" || + (withdrawal? && permitted_unpublished_link_type?) + ) && + (link_kind == "link_set_links" && [root_locale, Edition::DEFAULT_LOCALE].include?(locale) || link_kind == "edition_links" && [root_locale].include?(locale)) + end + + private + + def withdrawal? + @withdrawal + end + + def permitted_unpublished_link_type? + @permitted_unpublished_link_type + end + end + end +end diff --git a/spec/support/graphql_link_expansion_inclusion_helpers/reverse_links/test_case_factory.rb b/spec/support/graphql_link_expansion_inclusion_helpers/reverse_links/test_case_factory.rb new file mode 100644 index 0000000000..a9e7f3a2d1 --- /dev/null +++ b/spec/support/graphql_link_expansion_inclusion_helpers/reverse_links/test_case_factory.rb @@ -0,0 +1,66 @@ +module GraphqlLinkExpansionInclusionHelpers + module ReverseLinks + class TestCaseFactory + class << self + def all + with_drafts_values = [true, false] + root_locale_values = [Edition::DEFAULT_LOCALE, "fr"] + + state_values = %w[published unpublished draft] + withdrawal_values = [true, false, nil] + permitted_unpublished_link_type_values = [true, false, nil] + + renderable_document_type_values = [false, true] + locale_values = [Edition::DEFAULT_LOCALE, "fr", "hu"] + allowed_reverse_link_type_values = [true, false] + link_kind_values = %w[link_set_links edition_links] + + with_drafts_values.product( + root_locale_values, + state_values, + withdrawal_values, + permitted_unpublished_link_type_values, + renderable_document_type_values, + locale_values, + allowed_reverse_link_type_values, + link_kind_values, + ).map { + { + with_drafts: _1, + root_locale: _2, + state: _3, + withdrawal: _4, + permitted_unpublished_link_type: _5, + renderable_document_type: _6, + locale: _7, + allowed_reverse_link_type: _8, + link_kind: _9, + } + } + .reject { |test_case| + [ + redundant_locale(test_case), + invalid_state(test_case), + ].any? + } + .map { TestCase.new(**it) } + end + + private + + def redundant_locale(test_case) + test_case[:root_locale] == Edition::DEFAULT_LOCALE && + test_case[:locale] == "hu" + end + + def invalid_state(test_case) + if test_case[:state] == "unpublished" + test_case[:withdrawal].nil? || test_case[:permitted_unpublished_link_type].nil? + else + !test_case[:withdrawal].nil? || !test_case[:permitted_unpublished_link_type].nil? + end + end + end + end + end +end