Files
user-engine/tests/test_onboarding_journeys.py

362 lines
13 KiB
Python

import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
FactorVerification,
IdentityFactorType,
OnboardingJourneyStatus,
OnboardingStepStatus,
OnboardingTriggerType,
PreparedEntitlement,
PreparedEntitlementKind,
PreparedFactorRequirement,
WelcomeProtocol,
WelcomeProtocolStep,
)
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
class OnboardingJourneyTests(unittest.TestCase):
def test_registration_completion_starts_matching_welcome_protocol(self):
service, store = _service()
actor = _actor()
protocol = service.register_welcome_protocol(
actor,
_registration_protocol(),
correlation_id="corr-register-protocol",
)
completion = _complete_registration(service, actor)
journeys = store.onboarding_journeys_for_user(completion.user.user_id)
context = service.identity_context(
actor,
user_id=completion.user.user_id,
tenant="tenant:coulomb",
correlation_id="corr-context",
)
self.assertEqual(len(journeys), 1)
self.assertEqual(journeys[0].protocol_id, protocol.protocol_id)
self.assertEqual(journeys[0].trigger_type, OnboardingTriggerType.REGISTRATION)
self.assertEqual(journeys[0].status, OnboardingJourneyStatus.IN_PROGRESS)
self.assertEqual(journeys[0].steps[0].status, OnboardingStepStatus.IN_PROGRESS)
self.assertEqual(context.onboarding_journeys[0].journey_id, journeys[0].journey_id)
self.assertIn(
"onboarding_journey.started",
[event.event_type for event in service.outbox_events()],
)
self.assertNotIn(
"sample.user@example.test",
repr([event.payload for event in service.outbox_events()]),
)
def test_prepared_account_claim_starts_prepared_welcome_protocol(self):
service, store = _service()
actor = _actor()
service.register_welcome_protocol(
actor,
_prepared_protocol(),
correlation_id="corr-prepared-protocol",
)
prepared = service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(
PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
),
),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.ONBOARDING_JOURNEY,
tenant="tenant:coulomb",
onboarding_journey="welcome-demo",
),
),
correlation_id="corr-prepare",
)
completion = _complete_registration(service, actor)
service.claim_prepared_account(
actor,
completion.session.registration_id,
prepared_account_id=prepared.prepared_account_id,
correlation_id="corr-claim",
)
journeys = store.onboarding_journeys_for_user(completion.user.user_id)
self.assertEqual(len(journeys), 1)
self.assertEqual(journeys[0].trigger_type, OnboardingTriggerType.PREPARED_ACCOUNT)
self.assertEqual(journeys[0].source_id, prepared.prepared_account_id)
self.assertEqual(journeys[0].journey_key, "welcome-demo")
self.assertIn(
"prepared_account.onboarding_requested",
[event.event_type for event in service.outbox_events()],
)
self.assertIn(
"onboarding_journey.started",
[event.event_type for event in service.outbox_events()],
)
def test_missing_subsystem_callback_blocks_journey_with_gap(self):
service, store = _service()
session = service.me(human_actor_claims(), correlation_id="corr-me")
protocol = service.register_welcome_protocol(
session.actor,
WelcomeProtocol(
tenant="tenant:coulomb",
name="Blocked Welcome",
trigger_type=OnboardingTriggerType.MANUAL,
steps=(
WelcomeProtocolStep(
step_key="external-setup",
title="External Setup",
subsystem="ops-console",
requires_subsystem_callback=True,
),
),
),
correlation_id="corr-blocked-protocol",
)
start = service.start_onboarding_journey(
session.actor,
session.user.user_id,
protocol.protocol_id,
correlation_id="corr-start-blocked",
)
diagnostics = service.onboarding_diagnostics(
session.actor,
tenant="tenant:coulomb",
correlation_id="corr-diagnostics",
)
self.assertEqual(start.journey.status, OnboardingJourneyStatus.BLOCKED)
self.assertEqual(start.journey.steps[0].status, OnboardingStepStatus.BLOCKED)
self.assertIn("subsystem-callback-missing", start.journey.steps[0].lifecycle_gap)
self.assertEqual(store.record_counts()["onboarding_journeys"], 1)
self.assertEqual(diagnostics.statuses[OnboardingJourneyStatus.BLOCKED.value], 1)
self.assertTrue(diagnostics.lifecycle_gaps)
def test_blocked_journey_can_resume_and_complete(self):
service, _ = _service()
session = service.me(human_actor_claims(), correlation_id="corr-me")
protocol = service.register_welcome_protocol(
session.actor,
WelcomeProtocol(
tenant="tenant:coulomb",
name="Resume Welcome",
trigger_type=OnboardingTriggerType.MANUAL,
steps=(
WelcomeProtocolStep(
step_key="callback",
title="Callback",
subsystem="crm",
requires_subsystem_callback=True,
),
),
),
correlation_id="corr-resume-protocol",
)
blocked = service.start_onboarding_journey(
session.actor,
session.user.user_id,
protocol.protocol_id,
correlation_id="corr-start",
).journey
resumed = service.resume_onboarding_journey(
session.actor,
blocked.journey_id,
callback_refs={"callback": "crm://welcome/callback"},
correlation_id="corr-resume",
)
completed = service.complete_onboarding_step(
session.actor,
resumed.journey_id,
"callback",
correlation_id="corr-complete-step",
)
self.assertEqual(resumed.status, OnboardingJourneyStatus.IN_PROGRESS)
self.assertEqual(
resumed.steps[0].handoff.callback_ref,
"crm://welcome/callback",
)
self.assertIsNone(resumed.steps[0].lifecycle_gap)
self.assertEqual(completed.status, OnboardingJourneyStatus.COMPLETED)
self.assertEqual(completed.completed_at is not None, True)
def test_progress_skip_and_fail_operations_are_auditable(self):
service, _ = _service()
session = service.me(human_actor_claims(), correlation_id="corr-me")
protocol = service.register_welcome_protocol(
session.actor,
WelcomeProtocol(
tenant="tenant:coulomb",
name="Two Step Welcome",
trigger_type=OnboardingTriggerType.MANUAL,
steps=(
WelcomeProtocolStep(
step_key="intro",
title="Intro",
subsystem="portal",
),
WelcomeProtocolStep(
step_key="tour",
title="Tour",
subsystem="portal",
),
),
),
correlation_id="corr-two-step-protocol",
)
journey = service.start_onboarding_journey(
session.actor,
session.user.user_id,
protocol.protocol_id,
correlation_id="corr-start-two-step",
).journey
progressed = service.progress_onboarding_step(
session.actor,
journey.journey_id,
"intro",
correlation_id="corr-progress",
)
second_active = service.complete_onboarding_step(
session.actor,
progressed.journey_id,
"intro",
correlation_id="corr-complete-intro",
)
skipped = service.skip_onboarding_step(
session.actor,
second_active.journey_id,
"tour",
correlation_id="corr-skip-tour",
)
self.assertEqual(skipped.status, OnboardingJourneyStatus.COMPLETED)
self.assertIn(
"onboarding_step.skipped",
[event.event_type for event in service.outbox_events()],
)
failed_protocol = service.register_welcome_protocol(
session.actor,
WelcomeProtocol(
tenant="tenant:coulomb",
name="Failing Welcome",
trigger_type=OnboardingTriggerType.MANUAL,
steps=(
WelcomeProtocolStep(
step_key="danger",
title="Danger",
subsystem="portal",
),
),
),
correlation_id="corr-fail-protocol",
)
failed_start = service.start_onboarding_journey(
session.actor,
session.user.user_id,
failed_protocol.protocol_id,
correlation_id="corr-start-fail",
).journey
failed = service.fail_onboarding_step(
session.actor,
failed_start.journey_id,
"danger",
lifecycle_gap="portal-unavailable",
correlation_id="corr-fail-step",
)
self.assertEqual(failed.status, OnboardingJourneyStatus.FAILED)
self.assertEqual(failed.steps[0].lifecycle_gap, "portal-unavailable")
self.assertIn(
"onboarding_step.failed",
[event.event_type for event in service.outbox_events()],
)
def _service():
store = InMemoryUserEngineStore()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=LocalAuthorizationCheckPort(),
)
return service, store
def _actor():
return FixtureIdentityClaimsAdapter().normalize(
human_actor_claims(subject="sample-user", tenant="tenant:coulomb")
)
def _complete_registration(service: UserEngineService, actor):
session = service.start_registration(actor, correlation_id="corr-start")
service.attach_registration_factor(
actor,
session.registration_id,
FactorVerification(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
display_value="sample.user@example.test",
source_system="fixture-email",
),
correlation_id="corr-factor",
)
return service.complete_registration(
actor,
session.registration_id,
correlation_id="corr-complete",
)
def _registration_protocol() -> WelcomeProtocol:
return WelcomeProtocol(
tenant="tenant:coulomb",
name="Registration Welcome",
trigger_type=OnboardingTriggerType.REGISTRATION,
required_factor_types=(IdentityFactorType.EMAIL,),
steps=(
WelcomeProtocolStep(
step_key="intro",
title="Intro",
subsystem="portal",
callback_ref="portal://welcome/intro",
requires_subsystem_callback=True,
),
),
)
def _prepared_protocol() -> WelcomeProtocol:
return WelcomeProtocol(
tenant="tenant:coulomb",
name="Prepared Welcome",
trigger_type=OnboardingTriggerType.PREPARED_ACCOUNT,
journey_key="welcome-demo",
prepared_journey="welcome-demo",
steps=(
WelcomeProtocolStep(
step_key="prepared-intro",
title="Prepared Intro",
subsystem="portal",
callback_ref="portal://welcome/prepared",
requires_subsystem_callback=True,
),
),
)
if __name__ == "__main__":
unittest.main()