2020import java .util .List ;
2121import javax .xml .namespace .QName ;
2222
23+ import com .evolveum .midpoint .prism .path .ItemPath ;
24+
2325import org .springframework .beans .factory .annotation .Autowired ;
2426import org .springframework .test .annotation .DirtiesContext ;
2527import org .springframework .test .context .ContextConfiguration ;
@@ -61,6 +63,7 @@ public class TestSmartIntegrationService extends AbstractEmptyModelIntegrationTe
6163 @ Autowired private SmartIntegrationService smartIntegrationService ;
6264
6365 private static DummyBasicScenario dummyForSuggestCorrelationAndMappings ;
66+ private static DummyBasicScenario dummyForSuggestCategoricalMappings ;
6467
6568 private static final DummyTestResource RESOURCE_DUMMY_FOR_SUGGEST_OBJECT_TYPES = new DummyTestResource (
6669 TEST_DIR , "resource-dummy-for-suggest-object-types.xml" , "0c59d761-bea9-4342-bbc7-ee0e199d275b" ,
@@ -78,6 +81,12 @@ public class TestSmartIntegrationService extends AbstractEmptyModelIntegrationTe
7881 resourceController -> dummyForSuggestCorrelationAndMappings = DummyBasicScenario .on (resourceController )
7982 .initialize ());
8083
84+ private static final DummyTestResource RESOURCE_DUMMY_FOR_SUGGEST_CATEGORICAL_MAPPINGS = new DummyTestResource (
85+ TEST_DIR , "resource-dummy-for-suggest-categorical-mappings.xml" , "a1b2c3d4-e5f6-7890-abcd-ef1234560001" ,
86+ "for-suggest-categorical-mappings" ,
87+ resourceController -> dummyForSuggestCategoricalMappings = DummyBasicScenario .on (resourceController )
88+ .initialize ());
89+
8190 private static final TestObject <?> USER_JACK = TestObject .file (TEST_DIR , "user-jack.xml" , "84d2ff68-9b32-4ef4-b87b-02536fd5e83c" );
8291 private static final TestObject <?> USER_JIM = TestObject .file (TEST_DIR , "user-jim.xml" , "8f433649-6cc4-401b-910f-10fa5449f14c" );
8392 private static final TestObject <?> USER_ALICE = TestObject .file (TEST_DIR , "user-alice.xml" , "79df4c1f-6480-4eb8-9db7-863e25d5b5fa" );
@@ -93,10 +102,24 @@ public void initSystem(Task initTask, OperationResult initResult) throws Excepti
93102 initAndTestDummyResource (RESOURCE_DUMMY_FOR_SUGGEST_OBJECT_TYPES , initTask , initResult );
94103 initAndTestDummyResource (RESOURCE_DUMMY_FOR_SUGGEST_FOCUS_TYPE , initTask , initResult );
95104 initAndTestDummyResource (RESOURCE_DUMMY_FOR_SUGGEST_CORRELATION_AND_MAPPINGS , initTask , initResult );
105+ initAndTestDummyResource (RESOURCE_DUMMY_FOR_SUGGEST_CATEGORICAL_MAPPINGS , initTask , initResult );
96106
97107 initTestObjects (initTask , initResult ,
98108 USER_JACK , USER_JIM , USER_ALICE , USER_BOB );
99109 createAndLinkAccounts (initTask , initResult );
110+ createUnlinkedCategoricalAccounts ();
111+ }
112+
113+ private void createUnlinkedCategoricalAccounts () throws Exception {
114+ var a = dummyForSuggestCategoricalMappings .account ;
115+ a .add ("worker1" )
116+ .addAttributeValues (DummyBasicScenario .Account .AttributeNames .STATUS .local (), "active" );
117+ a .add ("worker2" )
118+ .addAttributeValues (DummyBasicScenario .Account .AttributeNames .STATUS .local (), "active" );
119+ a .add ("worker3" )
120+ .addAttributeValues (DummyBasicScenario .Account .AttributeNames .STATUS .local (), "inactive" );
121+ a .add ("worker4" )
122+ .addAttributeValues (DummyBasicScenario .Account .AttributeNames .STATUS .local (), "inactive" );
100123 }
101124
102125 private void createAndLinkAccounts (Task initTask , OperationResult initResult ) throws Exception {
@@ -422,6 +445,150 @@ public void test310SuggestMappingsWithFailure() throws CommonException {
422445 .display ();
423446 }
424447
448+ /**
449+ * Tests categorical mapping suggestion for the lockout status attribute.
450+ * Shadow 'status' has 2 unique values ("active", "inactive"); LockoutStatusType has 2 enum values → cardinality check passes.
451+ */
452+ @ Test
453+ public void test400SuggestMappingsCategorical () throws CommonException {
454+ if (DefaultServiceClientImpl .hasServiceUrlOverride ()) {
455+ // We'll go with the real service client. Hence, this test will not check the actual response in detail.
456+ } else {
457+ var mockClient = new MockServiceClientImpl (request -> {
458+ if (request instanceof SiMatchSchemaRequestType ) {
459+ return new SiMatchSchemaResponseType ()
460+ .attributeMatch (new SiAttributeMatchSuggestionType ()
461+ .applicationAttribute (asStringSimple (
462+ DummyBasicScenario .Account .AttributeNames .STATUS .path ()))
463+ .midPointAttribute (asStringSimple (
464+ ItemPath .create (UserType .F_ACTIVATION , ActivationType .F_LOCKOUT_STATUS ))));
465+ } else if (request instanceof SiSuggestCategoricalMappingRequestType ) {
466+ return new SiSuggestMappingResponseType ()
467+ .transformationScript (
468+ "// Map status to lockoutStatus\n "
469+ + "input == null ? null"
470+ + " : input.equalsIgnoreCase(\" inactive\" ) ? \" locked\" : \" normal\" " );
471+ }
472+ return null ;
473+ });
474+ TestServiceClientFactory .mockServiceClient (this .clientFactoryMock , mockClient );
475+ }
476+
477+ var task = getTestTask ();
478+ var result = task .getResult ();
479+
480+ when ("submitting 'suggest mappings' operation for categorical lockout status attribute" );
481+ var token = smartIntegrationService .submitSuggestMappingsOperation (
482+ RESOURCE_DUMMY_FOR_SUGGEST_CATEGORICAL_MAPPINGS .oid , ACCOUNT_DEFAULT , true , null ,
483+ List .of (DataAccessPermissionType .SCHEMA_ACCESS ,
484+ DataAccessPermissionType .STATISTICS_ACCESS ,
485+ DataAccessPermissionType .RAW_DATA_ACCESS ),
486+ task , result );
487+
488+ then ("returned token is not null" );
489+ assertThat (token ).isNotNull ();
490+
491+ when ("waiting for the operation to finish successfully" );
492+ var response = waitForFinish (
493+ () -> smartIntegrationService .getSuggestMappingsOperationStatus (token , task , result ),
494+ TIMEOUT );
495+
496+ then ("there are suggested mappings" );
497+ displayDumpable ("response" , response );
498+ assertThat (response ).isNotNull ();
499+ var attrMappings = response .getAttributeMappings ();
500+ assertThat (attrMappings ).as ("attribute mappings" ).isNotEmpty ();
501+
502+ if (!DefaultServiceClientImpl .hasServiceUrlOverride ()) {
503+ and ("the status attribute mapping has a categorical transformation script" );
504+ var statusMapping = findStatusMapping (attrMappings );
505+ assertThat (statusMapping ).as ("status attribute mapping" ).isNotNull ();
506+ var inbound = statusMapping .getDefinition ().getInbound ();
507+ assertThat (inbound ).as ("inbound mappings for status" ).hasSize (1 );
508+ assertThat (inbound .get (0 ).getExpression ())
509+ .as ("transformation expression (categorical mapping script)" )
510+ .isNotNull ();
511+
512+ and ("the categorical mapping has null expected quality" );
513+ assertThat (statusMapping .getExpectedQuality ())
514+ .as ("expected quality of categorical mapping should be null (LLM confidence not assessed)" )
515+ .isNull ();
516+ }
517+ }
518+
519+ /**
520+ * Tests that categorical mapping is NOT triggered when RAW_DATA_ACCESS is absent.
521+ * Without it, useAiService=false, so the categorical path is skipped and the mapping falls back to asIs (null expression).
522+ */
523+ @ Test
524+ public void test410SuggestMappingsCategoricalWithoutRawDataAccess () throws CommonException {
525+ if (DefaultServiceClientImpl .hasServiceUrlOverride ()) {
526+ // We'll go with the real service client. Hence, this test will not check the actual response in detail.
527+ } else {
528+ var mockClient = new MockServiceClientImpl (request -> {
529+ if (request instanceof SiMatchSchemaRequestType ) {
530+ return new SiMatchSchemaResponseType ()
531+ .attributeMatch (new SiAttributeMatchSuggestionType ()
532+ .applicationAttribute (asStringSimple (
533+ DummyBasicScenario .Account .AttributeNames .STATUS .path ()))
534+ .midPointAttribute (asStringSimple (
535+ ItemPath .create (UserType .F_ACTIVATION , ActivationType .F_LOCKOUT_STATUS ))));
536+ } else if (request instanceof SiSuggestCategoricalMappingRequestType ) {
537+ throw new AssertionError (
538+ "SUGGEST_CATEGORICAL_MAPPING must not be called without RAW_DATA_ACCESS (useAiService=false)" );
539+ }
540+ return null ;
541+ });
542+ TestServiceClientFactory .mockServiceClient (this .clientFactoryMock , mockClient );
543+ }
544+
545+ var task = getTestTask ();
546+ var result = task .getResult ();
547+
548+ when ("submitting 'suggest mappings' operation without RAW_DATA_ACCESS" );
549+ var token = smartIntegrationService .submitSuggestMappingsOperation (
550+ RESOURCE_DUMMY_FOR_SUGGEST_CATEGORICAL_MAPPINGS .oid , ACCOUNT_DEFAULT , true , null ,
551+ List .of (DataAccessPermissionType .SCHEMA_ACCESS ,
552+ DataAccessPermissionType .STATISTICS_ACCESS ),
553+ task , result );
554+
555+ then ("returned token is not null" );
556+ assertThat (token ).isNotNull ();
557+
558+ when ("waiting for the operation to finish" );
559+ var response = waitForFinish (
560+ () -> smartIntegrationService .getSuggestMappingsOperationStatus (token , task , result ),
561+ TIMEOUT );
562+
563+ then ("there are suggested mappings" );
564+ displayDumpable ("response" , response );
565+ assertThat (response ).isNotNull ();
566+ var attrMappings = response .getAttributeMappings ();
567+ assertThat (attrMappings ).as ("attribute mappings" ).isNotEmpty ();
568+
569+ if (!DefaultServiceClientImpl .hasServiceUrlOverride ()) {
570+ and ("the status mapping has null expression — asIs, because useAiService was false" );
571+ var statusMapping = findStatusMapping (attrMappings );
572+ assertThat (statusMapping ).as ("status attribute mapping" ).isNotNull ();
573+ var inbound = statusMapping .getDefinition ().getInbound ();
574+ assertThat (inbound ).as ("inbound mappings for status" ).hasSize (1 );
575+ assertThat (inbound .get (0 ).getExpression ())
576+ .as ("expression should be null (asIs) — categorical mapping disabled without RAW_DATA_ACCESS" )
577+ .isNull ();
578+ }
579+ }
580+
581+ private AttributeMappingsSuggestionType findStatusMapping (List <AttributeMappingsSuggestionType > attrMappings ) {
582+ return attrMappings .stream ()
583+ .filter (m -> {
584+ var ref = m .getDefinition ().getRef ();
585+ return ref != null && ref .getItemPath ().lastName ().getLocalPart ().equals (
586+ DummyBasicScenario .Account .AttributeNames .STATUS .local ());
587+ })
588+ .findFirst ()
589+ .orElse (null );
590+ }
591+
425592 private void skipIfRealService () {
426593 skipTestIf (DefaultServiceClientImpl .hasServiceUrlOverride (), "Not applicable with a real service" );
427594 }
0 commit comments