Files
cmroubao/backend-api/internal/usecase/lifecycle_service_test.go
T

397 lines
11 KiB
Go
Raw Normal View History

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)