feat(tasks): implement atomic claims and leases
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user