397 lines
11 KiB
Go
397 lines
11 KiB
Go
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)
|