package usecase import ( "context" "testing" "time" "cmroubao/backend-api/internal/domain" ) const ( lifecycleUserID = "00000000-0000-4000-8000-000000000091" lifecycleDeviceID = "00000000-0000-4000-8000-000000000092" lifecycleTaskID = "00000000-0000-4000-8000-000000000093" lifecycleExecID = "00000000-0000-4000-8000-000000000094" ) func TestLifecycleServiceHeartbeatAndClaimUseServerIdentityAndTime( t *testing.T, ) { repository := &fakeLifecycleRepository{ heartbeatRecord: DeviceHeartbeatRecord{ Device: domain.Device{ID: lifecycleDeviceID}, }, claimResult: ClaimNextRepositoryResult{ Task: &domain.PurchaseTask{ID: lifecycleTaskID}, }, } service := mustLifecycleService(t, repository) clientActive := lifecycleTaskID heartbeat, err := service.HeartbeatDevice( context.Background(), DeviceHeartbeatCommand{ UserID: lifecycleUserID, DeviceID: lifecycleDeviceID, AppVersion: "0.1.0", AndroidVersion: "16", PDDVersion: "8.17.0", AccessibilityEnabled: true, PDDInstalled: true, ClientActiveTaskID: &clientActive, }, ) if err != nil { t.Fatalf("HeartbeatDevice() error = %v", err) } if heartbeat.ClientStateMatches || !repository.heartbeatUpdate.ReportedAt.Equal(fakeClock{}.Now()) { t.Fatalf("heartbeat result/update = %+v / %+v", heartbeat, repository.heartbeatUpdate) } rawToken := validTestToken(30) claim, err := service.ClaimNext( context.Background(), ClaimNextCommand{ UserID: lifecycleUserID, DeviceID: lifecycleDeviceID, IdempotencyKey: "claim-1", ClaimToken: rawToken, }, ) if err != nil { t.Fatalf("ClaimNext() error = %v", err) } request := repository.claimRequest if claim.Task == nil || claim.Task.ID != lifecycleTaskID || request.ClaimTokenHash == rawToken || request.ClaimTokenHash != hashSecret(rawToken) || len(request.RequestHash) != 64 || request.ExpiresAt.Sub(request.Now) != 10*time.Minute || request.Now.Sub(request.ReadinessAfter) != 2*time.Minute { t.Fatalf("claim/request = %+v / %+v", claim, request) } if request.Event.ActorUserID == nil || *request.Event.ActorUserID != lifecycleUserID || request.Event.ActorDeviceID == nil || *request.Event.ActorDeviceID != lifecycleDeviceID { t.Fatalf("claim event = %+v", request.Event) } } func TestLifecycleServiceTransitionsHashClaimAndCreateExecution( t *testing.T, ) { repository := &fakeLifecycleRepository{ startResult: StartTaskRepositoryResult{ Task: domain.PurchaseTask{ ID: lifecycleTaskID, Status: domain.TaskStatusRunning, Version: 3, ClaimGeneration: 1, }, Execution: domain.TaskExecution{ID: lifecycleExecID}, }, heartbeatTaskResult: TaskHeartbeatRepositoryResult{ Task: domain.PurchaseTask{ ID: lifecycleTaskID, Status: domain.TaskStatusRunning, Version: 4, ClaimGeneration: 1, }, Execution: domain.TaskExecution{ID: lifecycleExecID}, CancelRequested: true, }, releaseTask: domain.PurchaseTask{ ID: lifecycleTaskID, Status: domain.TaskStatusPending, }, cancelTask: domain.PurchaseTask{ ID: lifecycleTaskID, Status: domain.TaskStatusCanceled, }, } service := mustLifecycleService(t, repository) token := validTestToken(40) start, err := service.StartTask( context.Background(), StartTaskCommand{ UserID: lifecycleUserID, DeviceID: lifecycleDeviceID, TaskID: lifecycleTaskID, ClaimGeneration: 1, ClaimToken: token, ExpectedVersion: 2, IdempotencyKey: "start-1", }, ) if err != nil { t.Fatalf("StartTask() error = %v", err) } startRequest := repository.startRequest if start.Execution.ID != lifecycleExecID || startRequest.ClaimTokenHash != hashSecret(token) || startRequest.Execution.ID == "" || startRequest.Execution.CurrentStep != "PREFLIGHT" || startRequest.ExpiresAt.Sub(startRequest.Now) != 90*time.Second || startRequest.Event.Type != "TASK_STARTED" { t.Fatalf("start/request = %+v / %+v", start, startRequest) } heartbeat, err := service.HeartbeatTask( context.Background(), TaskHeartbeatCommand{ UserID: lifecycleUserID, DeviceID: lifecycleDeviceID, TaskID: lifecycleTaskID, ExecutionID: lifecycleExecID, ClaimGeneration: 1, ClaimToken: token, Step: "SCAN_RESULTS", }, ) if err != nil { t.Fatalf("HeartbeatTask() error = %v", err) } if !heartbeat.CancelRequested || repository.taskHeartbeatRequest.MinimumExpiry.Sub( repository.taskHeartbeatRequest.Now, ) != 90*time.Second { t.Fatalf("heartbeat/request = %+v / %+v", heartbeat, repository.taskHeartbeatRequest) } release, err := service.ReleaseTask( context.Background(), ReleaseTaskCommand{ UserID: lifecycleUserID, DeviceID: lifecycleDeviceID, TaskID: lifecycleTaskID, ClaimGeneration: 1, ClaimToken: token, ExpectedVersion: 2, IdempotencyKey: "release-1", }, ) if err != nil || release.Task.Status != domain.TaskStatusPending { t.Fatalf("ReleaseTask() = %+v, error = %v", release, err) } if repository.releaseRequest.Event.Type != "TASK_RELEASED" { t.Fatalf("release event = %+v", repository.releaseRequest.Event) } acknowledged, err := service.AcknowledgeCancellation( context.Background(), AcknowledgeCancellationCommand{ UserID: lifecycleUserID, DeviceID: lifecycleDeviceID, TaskID: lifecycleTaskID, ExecutionID: lifecycleExecID, ClaimGeneration: 1, ClaimToken: token, ExpectedVersion: 4, IdempotencyKey: "cancel-ack-1", }, ) if err != nil || acknowledged.Task.Status != domain.TaskStatusCanceled { t.Fatalf( "AcknowledgeCancellation() = %+v, error = %v", acknowledged, err, ) } if repository.cancelRequest.Event.Type != "TASK_CANCELED" { t.Fatalf("cancel event = %+v", repository.cancelRequest.Event) } } func TestLifecycleServiceRejectsMalformedClaimsAndMapsConflicts( t *testing.T, ) { repository := &fakeLifecycleRepository{ claimErr: ErrDeviceNotReady, } service := mustLifecycleService(t, repository) _, err := service.ClaimNext( context.Background(), ClaimNextCommand{ UserID: lifecycleUserID, DeviceID: lifecycleDeviceID, IdempotencyKey: "claim-1", ClaimToken: validTestToken(50), }, ) assertUsecaseError(t, err, ErrorKindConflict, "DEVICE_NOT_READY") _, err = service.ClaimNext( context.Background(), ClaimNextCommand{ UserID: lifecycleUserID, DeviceID: lifecycleDeviceID, IdempotencyKey: "claim-2", ClaimToken: "not-a-token", }, ) assertUsecaseError(t, err, ErrorKindInvalid, "TASK_CLAIM_INVALID") _, err = service.HeartbeatTask( context.Background(), TaskHeartbeatCommand{ UserID: lifecycleUserID, DeviceID: lifecycleDeviceID, TaskID: lifecycleTaskID, ExecutionID: lifecycleExecID, ClaimGeneration: 1, ClaimToken: validTestToken(51), Step: "not valid", }, ) assertUsecaseError(t, err, ErrorKindInvalid, "TASK_HEARTBEAT_INVALID") } func TestLifecycleServiceAuthorizesReferenceImageWithHashedClaim( t *testing.T, ) { task := domain.PurchaseTask{ ID: lifecycleTaskID, ImageAssetID: "00000000-0000-4000-8000-000000000095", } repository := &fakeLifecycleRepository{claimedTask: task} service := mustLifecycleService(t, repository) rawToken := validTestToken(36) result, err := service.AuthorizeReferenceImage( context.Background(), ReferenceImageCommand{ UserID: lifecycleUserID, DeviceID: lifecycleDeviceID, TaskID: lifecycleTaskID, ClaimGeneration: 2, ClaimToken: rawToken, }, ) if err != nil || result.ImageAssetID != task.ImageAssetID { t.Fatalf( "AuthorizeReferenceImage() = %+v, error = %v", result, err, ) } if repository.claimedTaskRequest.ClaimTokenHash != hashSecret(rawToken) || repository.claimedTaskRequest.ClaimGeneration != 2 || !repository.claimedTaskRequest.Now.Equal(fakeClock{}.Now()) { t.Fatalf( "claimed task request = %+v", repository.claimedTaskRequest, ) } } type fakeLifecycleRepository struct { heartbeatUpdate DeviceHeartbeatUpdate heartbeatRecord DeviceHeartbeatRecord heartbeatErr error claimRequest ClaimNextRepositoryRequest claimResult ClaimNextRepositoryResult claimErr error startRequest StartTaskRepositoryRequest startResult StartTaskRepositoryResult startErr error taskHeartbeatRequest TaskHeartbeatRepositoryRequest heartbeatTaskResult TaskHeartbeatRepositoryResult taskHeartbeatErr error claimedTaskRequest TaskClaimRepositoryRequest claimedTask domain.PurchaseTask claimedTaskErr error releaseRequest ReleaseTaskRepositoryRequest releaseTask domain.PurchaseTask releaseReplayed bool releaseErr error cancelRequest CancelAcknowledgementRepositoryRequest cancelTask domain.PurchaseTask cancelReplayed bool cancelErr error } func (repository *fakeLifecycleRepository) RecordDeviceHeartbeat( _ context.Context, update DeviceHeartbeatUpdate, ) (DeviceHeartbeatRecord, error) { repository.heartbeatUpdate = update return repository.heartbeatRecord, repository.heartbeatErr } func (repository *fakeLifecycleRepository) ClaimNext( _ context.Context, request ClaimNextRepositoryRequest, ) (ClaimNextRepositoryResult, error) { repository.claimRequest = request return repository.claimResult, repository.claimErr } func (repository *fakeLifecycleRepository) StartTask( _ context.Context, request StartTaskRepositoryRequest, ) (StartTaskRepositoryResult, error) { repository.startRequest = request return repository.startResult, repository.startErr } func (repository *fakeLifecycleRepository) HeartbeatTask( _ context.Context, request TaskHeartbeatRepositoryRequest, ) (TaskHeartbeatRepositoryResult, error) { repository.taskHeartbeatRequest = request return repository.heartbeatTaskResult, repository.taskHeartbeatErr } func (repository *fakeLifecycleRepository) GetActiveClaimTask( _ context.Context, request TaskClaimRepositoryRequest, ) (domain.PurchaseTask, error) { repository.claimedTaskRequest = request return repository.claimedTask, repository.claimedTaskErr } func (repository *fakeLifecycleRepository) ReleaseTask( _ context.Context, request ReleaseTaskRepositoryRequest, ) (domain.PurchaseTask, bool, error) { repository.releaseRequest = request return repository.releaseTask, repository.releaseReplayed, repository.releaseErr } func (repository *fakeLifecycleRepository) AcknowledgeTaskCancellation( _ context.Context, request CancelAcknowledgementRepositoryRequest, ) (domain.PurchaseTask, bool, error) { repository.cancelRequest = request return repository.cancelTask, repository.cancelReplayed, repository.cancelErr } func mustLifecycleService( t *testing.T, repository LifecycleRepository, ) *LifecycleService { t.Helper() service, err := NewLifecycleService( repository, fakeClock{}, &sequenceIDs{}, 10*time.Minute, 90*time.Second, 2*time.Minute, ) if err != nil { t.Fatalf("NewLifecycleService() error = %v", err) } return service } var _ LifecycleRepository = (*fakeLifecycleRepository)(nil)