55import static org .testng .Assert .*;
66
77import com .linkedin .common .urn .Urn ;
8+ import com .linkedin .common .urn .UrnUtils ;
89import com .linkedin .datahub .graphql .QueryContext ;
910import com .linkedin .entity .client .EntityClient ;
11+ import com .linkedin .metadata .search .SearchEntity ;
12+ import com .linkedin .metadata .search .SearchEntityArray ;
1013import com .linkedin .metadata .search .SearchResult ;
1114import graphql .schema .DataFetchingEnvironment ;
15+ import java .util .Collections ;
16+ import java .util .Set ;
1217import java .util .concurrent .CompletionException ;
1318import org .mockito .Mockito ;
1419import org .testng .annotations .Test ;
1520
1621public class DeleteDomainResolverTest {
1722
1823 private static final String TEST_URN = "urn:li:domain:test-id" ;
24+ private static final String CHILD_URN = "urn:li:domain:child-id" ;
1925
2026 @ Test
2127 public void testGetSuccess () throws Exception {
2228 EntityClient mockClient = Mockito .mock (EntityClient .class );
2329 DeleteDomainResolver resolver = new DeleteDomainResolver (mockClient );
2430
25- // Execute resolver
2631 QueryContext mockContext = getMockAllowContext ();
2732 DataFetchingEnvironment mockEnv = Mockito .mock (DataFetchingEnvironment .class );
2833 Mockito .when (mockEnv .getArgument (Mockito .eq ("urn" ))).thenReturn (TEST_URN );
2934 Mockito .when (mockEnv .getContext ()).thenReturn (mockContext );
3035
31- // Domain has 0 child domains
36+ // Domain has 0 child domains -- early exit before filterExistingUrns.
3237 Mockito .when (
3338 mockClient .filter (
3439 any (),
3540 Mockito .eq ("domain" ),
3641 Mockito .any (),
3742 Mockito .any (),
3843 Mockito .eq (0 ),
39- Mockito .eq (1 )))
40- .thenReturn (new SearchResult ().setNumEntities (0 ));
44+ Mockito .eq (200 )))
45+ .thenReturn (new SearchResult ().setNumEntities (0 ). setEntities ( new SearchEntityArray ()) );
4146
4247 assertTrue (resolver .get (mockEnv ).get ());
4348
@@ -50,35 +55,117 @@ public void testDeleteWithChildDomains() throws Exception {
5055 EntityClient mockClient = Mockito .mock (EntityClient .class );
5156 DeleteDomainResolver resolver = new DeleteDomainResolver (mockClient );
5257
53- // Execute resolver
5458 QueryContext mockContext = getMockAllowContext ();
5559 DataFetchingEnvironment mockEnv = Mockito .mock (DataFetchingEnvironment .class );
5660 Mockito .when (mockEnv .getArgument (Mockito .eq ("urn" ))).thenReturn (TEST_URN );
5761 Mockito .when (mockEnv .getContext ()).thenReturn (mockContext );
5862
59- // Domain has child domains
63+ // OpenSearch returns one child candidate.
64+ Urn childUrn = UrnUtils .getUrn (CHILD_URN );
65+ SearchEntity childEntity = new SearchEntity ().setEntity (childUrn );
6066 Mockito .when (
6167 mockClient .filter (
6268 any (),
6369 Mockito .eq ("domain" ),
6470 Mockito .any (),
6571 Mockito .any (),
6672 Mockito .eq (0 ),
67- Mockito .eq (1 )))
68- .thenReturn (new SearchResult ().setNumEntities (1 ));
73+ Mockito .eq (200 )))
74+ .thenReturn (
75+ new SearchResult ().setNumEntities (1 ).setEntities (new SearchEntityArray (childEntity )));
76+
77+ // Primary store (MySQL) confirms the child still exists.
78+ Mockito .when (mockClient .filterExistingUrns (any (), Mockito .eq (Set .of (childUrn ))))
79+ .thenReturn (Set .of (childUrn ));
6980
7081 assertThrows (CompletionException .class , () -> resolver .get (mockEnv ).join ());
7182
7283 Mockito .verify (mockClient , Mockito .times (0 )).deleteEntity (any (), Mockito .any ());
7384 }
7485
86+ @ Test
87+ public void testDeleteBlockedWhenPagedCandidatesAllStaleButMoreExist () throws Exception {
88+ // When OpenSearch reports more total candidates than fit in one page and all
89+ // fetched candidates are stale in MySQL, deletion must still be blocked.
90+ // Without the fallback check (numEntities > entities.size()), the code would
91+ // incorrectly return false -- potentially allowing deletion of a domain that
92+ // still has real children in the un-fetched remainder of the OpenSearch result.
93+ EntityClient mockClient = Mockito .mock (EntityClient .class );
94+ DeleteDomainResolver resolver = new DeleteDomainResolver (mockClient );
95+
96+ QueryContext mockContext = getMockAllowContext ();
97+ DataFetchingEnvironment mockEnv = Mockito .mock (DataFetchingEnvironment .class );
98+ Mockito .when (mockEnv .getArgument (Mockito .eq ("urn" ))).thenReturn (TEST_URN );
99+ Mockito .when (mockEnv .getContext ()).thenReturn (mockContext );
100+
101+ // OpenSearch reports 300 total but only returns one entry in this page,
102+ // simulating the case where numEntities > the fetched page size.
103+ Urn childUrn = UrnUtils .getUrn (CHILD_URN );
104+ Mockito .when (
105+ mockClient .filter (
106+ any (),
107+ Mockito .eq ("domain" ),
108+ Mockito .any (),
109+ Mockito .any (),
110+ Mockito .eq (0 ),
111+ Mockito .eq (200 )))
112+ .thenReturn (
113+ new SearchResult ()
114+ .setNumEntities (300 )
115+ .setEntities (new SearchEntityArray (new SearchEntity ().setEntity (childUrn ))));
116+
117+ // The fetched candidate is stale in MySQL.
118+ Mockito .when (mockClient .filterExistingUrns (any (), any ())).thenReturn (Collections .emptySet ());
119+
120+ // Must still block deletion: we cannot confirm childlessness from one page.
121+ assertThrows (CompletionException .class , () -> resolver .get (mockEnv ).join ());
122+ Mockito .verify (mockClient , Mockito .times (0 )).deleteEntity (any (), Mockito .any ());
123+ }
124+
125+ @ Test
126+ public void testDeleteWithStaleChildDomains () throws Exception {
127+ // Regression test for the OpenSearch eventual-consistency race condition:
128+ // OpenSearch still shows a child that was just deleted from MySQL.
129+ // hasChildDomains() must allow the parent delete to proceed once the
130+ // primary store (MySQL) confirms no child actually exists.
131+ EntityClient mockClient = Mockito .mock (EntityClient .class );
132+ DeleteDomainResolver resolver = new DeleteDomainResolver (mockClient );
133+
134+ QueryContext mockContext = getMockAllowContext ();
135+ DataFetchingEnvironment mockEnv = Mockito .mock (DataFetchingEnvironment .class );
136+ Mockito .when (mockEnv .getArgument (Mockito .eq ("urn" ))).thenReturn (TEST_URN );
137+ Mockito .when (mockEnv .getContext ()).thenReturn (mockContext );
138+
139+ // OpenSearch returns a stale child candidate.
140+ Urn childUrn = UrnUtils .getUrn (CHILD_URN );
141+ SearchEntity childEntity = new SearchEntity ().setEntity (childUrn );
142+ Mockito .when (
143+ mockClient .filter (
144+ any (),
145+ Mockito .eq ("domain" ),
146+ Mockito .any (),
147+ Mockito .any (),
148+ Mockito .eq (0 ),
149+ Mockito .eq (200 )))
150+ .thenReturn (
151+ new SearchResult ().setNumEntities (1 ).setEntities (new SearchEntityArray (childEntity )));
152+
153+ // Primary store (MySQL) confirms the child no longer exists -- stale OpenSearch hit.
154+ Mockito .when (mockClient .filterExistingUrns (any (), Mockito .eq (Set .of (childUrn ))))
155+ .thenReturn (Collections .emptySet ());
156+
157+ // Deletion should succeed because the only OpenSearch candidate is stale.
158+ assertTrue (resolver .get (mockEnv ).get ());
159+
160+ Mockito .verify (mockClient , Mockito .times (1 ))
161+ .deleteEntity (any (), Mockito .eq (Urn .createFromString (TEST_URN )));
162+ }
163+
75164 @ Test
76165 public void testGetUnauthorized () throws Exception {
77- // Create resolver
78166 EntityClient mockClient = Mockito .mock (EntityClient .class );
79167 DeleteDomainResolver resolver = new DeleteDomainResolver (mockClient );
80168
81- // Execute resolver
82169 DataFetchingEnvironment mockEnv = Mockito .mock (DataFetchingEnvironment .class );
83170 Mockito .when (mockEnv .getArgument (Mockito .eq ("urn" ))).thenReturn (TEST_URN );
84171 QueryContext mockContext = getMockDenyContext ();
0 commit comments