feat(tasks): implement atomic claims and leases
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package usecase
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrDeviceNotReady = errors.New("device readiness is missing or stale")
|
||||
ErrDeviceActiveTask = errors.New("device already has an active task")
|
||||
ErrClaimInvalid = errors.New("task claim is invalid")
|
||||
ErrClaimExpired = errors.New("task claim lease expired")
|
||||
ErrClaimReplayExpired = errors.New("claim idempotency replay is stale")
|
||||
ErrTaskVersionConflict = errors.New("task version conflict")
|
||||
ErrExecutionMismatch = errors.New("task execution does not match")
|
||||
)
|
||||
|
||||
func wrapLifecycleRepositoryError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, ErrDeviceNotReady):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"DEVICE_NOT_READY",
|
||||
"device is not ready to claim a task",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrDeviceActiveTask):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"DEVICE_ACTIVE_TASK",
|
||||
"device already has an active task",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrClaimInvalid):
|
||||
return newError(
|
||||
ErrorKindForbidden,
|
||||
"TASK_CLAIM_INVALID",
|
||||
"task claim is invalid",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrClaimExpired):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"TASK_LEASE_EXPIRED",
|
||||
"task claim lease expired",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrClaimReplayExpired):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"CLAIM_REPLAY_EXPIRED",
|
||||
"claim replay is no longer active",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrTaskVersionConflict):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"TASK_VERSION_CONFLICT",
|
||||
"task version has changed",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrExecutionMismatch):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"TASK_EXECUTION_CONFLICT",
|
||||
"task execution does not match",
|
||||
err,
|
||||
)
|
||||
default:
|
||||
return wrapRepositoryError(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
type DeviceHeartbeatUpdate struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
AppVersion string
|
||||
AndroidVersion string
|
||||
PDDVersion string
|
||||
AccessibilityEnabled bool
|
||||
PDDInstalled bool
|
||||
ReportedAt time.Time
|
||||
}
|
||||
|
||||
type DeviceHeartbeatRecord struct {
|
||||
Device domain.Device
|
||||
ActiveTaskID *string
|
||||
}
|
||||
|
||||
type ClaimNextRepositoryRequest struct {
|
||||
CreatorSubject string
|
||||
UserID string
|
||||
DeviceID string
|
||||
IdempotencyKey string
|
||||
RequestHash string
|
||||
ClaimTokenHash string
|
||||
Now time.Time
|
||||
ExpiresAt time.Time
|
||||
ReadinessAfter time.Time
|
||||
Event domain.TaskEvent
|
||||
}
|
||||
|
||||
type ClaimNextRepositoryResult struct {
|
||||
Task *domain.PurchaseTask
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type StartTaskRepositoryRequest struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ClaimGeneration int64
|
||||
ClaimTokenHash string
|
||||
ExpectedVersion int64
|
||||
IdempotencyKey string
|
||||
RequestHash string
|
||||
Now time.Time
|
||||
ExpiresAt time.Time
|
||||
Execution domain.TaskExecution
|
||||
Event domain.TaskEvent
|
||||
}
|
||||
|
||||
type StartTaskRepositoryResult struct {
|
||||
Task domain.PurchaseTask
|
||||
Execution domain.TaskExecution
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type TaskHeartbeatRepositoryRequest struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
ClaimGeneration int64
|
||||
ClaimTokenHash string
|
||||
Step string
|
||||
Now time.Time
|
||||
MinimumExpiry time.Time
|
||||
}
|
||||
|
||||
type TaskClaimRepositoryRequest struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ClaimGeneration int64
|
||||
ClaimTokenHash string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type TaskHeartbeatRepositoryResult struct {
|
||||
Task domain.PurchaseTask
|
||||
Execution domain.TaskExecution
|
||||
CancelRequested bool
|
||||
}
|
||||
|
||||
type ReleaseTaskRepositoryRequest struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ClaimGeneration int64
|
||||
ClaimTokenHash string
|
||||
ExpectedVersion int64
|
||||
IdempotencyKey string
|
||||
RequestHash string
|
||||
Now time.Time
|
||||
Event domain.TaskEvent
|
||||
}
|
||||
|
||||
type CancelAcknowledgementRepositoryRequest struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
ClaimGeneration int64
|
||||
ClaimTokenHash string
|
||||
ExpectedVersion int64
|
||||
IdempotencyKey string
|
||||
RequestHash string
|
||||
Now time.Time
|
||||
Event domain.TaskEvent
|
||||
}
|
||||
|
||||
type LifecycleRepository interface {
|
||||
RecordDeviceHeartbeat(
|
||||
context.Context,
|
||||
DeviceHeartbeatUpdate,
|
||||
) (DeviceHeartbeatRecord, error)
|
||||
ClaimNext(
|
||||
context.Context,
|
||||
ClaimNextRepositoryRequest,
|
||||
) (ClaimNextRepositoryResult, error)
|
||||
StartTask(
|
||||
context.Context,
|
||||
StartTaskRepositoryRequest,
|
||||
) (StartTaskRepositoryResult, error)
|
||||
HeartbeatTask(
|
||||
context.Context,
|
||||
TaskHeartbeatRepositoryRequest,
|
||||
) (TaskHeartbeatRepositoryResult, error)
|
||||
GetActiveClaimTask(
|
||||
context.Context,
|
||||
TaskClaimRepositoryRequest,
|
||||
) (domain.PurchaseTask, error)
|
||||
ReleaseTask(
|
||||
context.Context,
|
||||
ReleaseTaskRepositoryRequest,
|
||||
) (domain.PurchaseTask, bool, error)
|
||||
AcknowledgeTaskCancellation(
|
||||
context.Context,
|
||||
CancelAcknowledgementRepositoryRequest,
|
||||
) (domain.PurchaseTask, bool, error)
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
lifecycleCreatorSubject = "local-admin"
|
||||
maxLifecycleStepBytes = 64
|
||||
)
|
||||
|
||||
type LifecycleService struct {
|
||||
repository LifecycleRepository
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
claimLease time.Duration
|
||||
runningLease time.Duration
|
||||
readinessTTL time.Duration
|
||||
}
|
||||
|
||||
type DeviceHeartbeatCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
AppVersion string
|
||||
AndroidVersion string
|
||||
PDDVersion string
|
||||
AccessibilityEnabled bool
|
||||
PDDInstalled bool
|
||||
ClientActiveTaskID *string
|
||||
}
|
||||
|
||||
type DeviceHeartbeatResult struct {
|
||||
Device domain.Device
|
||||
ActiveTaskID *string
|
||||
ClientStateMatches bool
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
type ClaimNextCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
IdempotencyKey string
|
||||
ClaimToken string
|
||||
}
|
||||
|
||||
type ClaimNextResult struct {
|
||||
Task *domain.PurchaseTask
|
||||
Replayed bool
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
type StartTaskCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
ExpectedVersion int64
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type StartTaskResult struct {
|
||||
Task domain.PurchaseTask
|
||||
Execution domain.TaskExecution
|
||||
Replayed bool
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
type TaskHeartbeatCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
Step string
|
||||
}
|
||||
|
||||
type TaskHeartbeatResult struct {
|
||||
Task domain.PurchaseTask
|
||||
Execution domain.TaskExecution
|
||||
CancelRequested bool
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
type ReferenceImageCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
}
|
||||
|
||||
type ReleaseTaskCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
ExpectedVersion int64
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type ReleaseTaskResult struct {
|
||||
Task domain.PurchaseTask
|
||||
Replayed bool
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
type AcknowledgeCancellationCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
ExpectedVersion int64
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type AcknowledgeCancellationResult struct {
|
||||
Task domain.PurchaseTask
|
||||
Replayed bool
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
func NewLifecycleService(
|
||||
repository LifecycleRepository,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
claimLease time.Duration,
|
||||
runningLease time.Duration,
|
||||
readinessTTL time.Duration,
|
||||
) (*LifecycleService, error) {
|
||||
if repository == nil || clock == nil || ids == nil {
|
||||
return nil, errors.New("lifecycle service dependencies are required")
|
||||
}
|
||||
if claimLease <= 0 || runningLease <= 0 || readinessTTL <= 0 {
|
||||
return nil, errors.New("lifecycle durations must be positive")
|
||||
}
|
||||
return &LifecycleService{
|
||||
repository: repository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
claimLease: claimLease,
|
||||
runningLease: runningLease,
|
||||
readinessTTL: readinessTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *LifecycleService) HeartbeatDevice(
|
||||
ctx context.Context,
|
||||
command DeviceHeartbeatCommand,
|
||||
) (DeviceHeartbeatResult, error) {
|
||||
command.UserID = strings.TrimSpace(command.UserID)
|
||||
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||
fields := lifecycleIdentityFields(command.UserID, command.DeviceID)
|
||||
appVersion := strings.TrimSpace(command.AppVersion)
|
||||
androidVersion := strings.TrimSpace(command.AndroidVersion)
|
||||
pddVersion := strings.TrimSpace(command.PDDVersion)
|
||||
validateVersionField(fields, "app_version", appVersion)
|
||||
validateVersionField(fields, "android_version", androidVersion)
|
||||
validateVersionField(fields, "pdd_version", pddVersion)
|
||||
if command.ClientActiveTaskID != nil {
|
||||
value := strings.TrimSpace(*command.ClientActiveTaskID)
|
||||
command.ClientActiveTaskID = &value
|
||||
if value != "" && !isUUID(value) {
|
||||
fields["active_task_id"] = "must be a UUID or null"
|
||||
}
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return DeviceHeartbeatResult{}, invalidError(
|
||||
"DEVICE_HEARTBEAT_INVALID",
|
||||
"device heartbeat is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
record, err := service.repository.RecordDeviceHeartbeat(
|
||||
ctx,
|
||||
DeviceHeartbeatUpdate{
|
||||
UserID: command.UserID,
|
||||
DeviceID: command.DeviceID,
|
||||
AppVersion: appVersion,
|
||||
AndroidVersion: androidVersion,
|
||||
PDDVersion: pddVersion,
|
||||
AccessibilityEnabled: command.AccessibilityEnabled,
|
||||
PDDInstalled: command.PDDInstalled,
|
||||
ReportedAt: now,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return DeviceHeartbeatResult{},
|
||||
wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return DeviceHeartbeatResult{
|
||||
Device: record.Device,
|
||||
ActiveTaskID: record.ActiveTaskID,
|
||||
ClientStateMatches: sameOptionalID(
|
||||
command.ClientActiveTaskID,
|
||||
record.ActiveTaskID,
|
||||
),
|
||||
ServerTime: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *LifecycleService) ClaimNext(
|
||||
ctx context.Context,
|
||||
command ClaimNextCommand,
|
||||
) (ClaimNextResult, error) {
|
||||
command.UserID = strings.TrimSpace(command.UserID)
|
||||
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||
fields := lifecycleWriteFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.IdempotencyKey,
|
||||
command.ClaimToken,
|
||||
)
|
||||
if len(fields) > 0 {
|
||||
return ClaimNextResult{}, invalidError(
|
||||
"TASK_CLAIM_INVALID",
|
||||
"task claim request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
requestHash, err := lifecycleRequestHash(struct {
|
||||
UserID string `json:"user_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ClaimTokenHash string `json:"claim_token_sha256"`
|
||||
}{
|
||||
UserID: command.UserID,
|
||||
DeviceID: command.DeviceID,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
})
|
||||
if err != nil {
|
||||
return ClaimNextResult{}, internalLifecycleFailure(err)
|
||||
}
|
||||
event, err := service.newLifecycleEvent(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
"",
|
||||
"TASK_CLAIMED",
|
||||
"task claimed",
|
||||
)
|
||||
if err != nil {
|
||||
return ClaimNextResult{}, err
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
result, err := service.repository.ClaimNext(
|
||||
ctx,
|
||||
ClaimNextRepositoryRequest{
|
||||
CreatorSubject: lifecycleCreatorSubject,
|
||||
UserID: command.UserID,
|
||||
DeviceID: command.DeviceID,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
RequestHash: requestHash,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
Now: now,
|
||||
ExpiresAt: now.Add(service.claimLease),
|
||||
ReadinessAfter: now.Add(-service.readinessTTL),
|
||||
Event: event,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return ClaimNextResult{}, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return ClaimNextResult{
|
||||
Task: result.Task,
|
||||
Replayed: result.Replayed,
|
||||
ServerTime: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *LifecycleService) StartTask(
|
||||
ctx context.Context,
|
||||
command StartTaskCommand,
|
||||
) (StartTaskResult, error) {
|
||||
command = normalizeStartTaskCommand(command)
|
||||
fields := lifecycleTransitionFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
command.ExpectedVersion,
|
||||
command.IdempotencyKey,
|
||||
)
|
||||
if len(fields) > 0 {
|
||||
return StartTaskResult{}, invalidError(
|
||||
"TASK_START_INVALID",
|
||||
"task start request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
executionID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return StartTaskResult{}, internalLifecycleFailure(err)
|
||||
}
|
||||
event, err := service.newLifecycleEvent(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
"TASK_STARTED",
|
||||
"task started",
|
||||
)
|
||||
if err != nil {
|
||||
return StartTaskResult{}, err
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
requestHash, err := lifecycleRequestHash(commandHashView{
|
||||
TaskID: command.TaskID,
|
||||
ClaimGeneration: command.ClaimGeneration,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
ExpectedVersion: command.ExpectedVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return StartTaskResult{}, internalLifecycleFailure(err)
|
||||
}
|
||||
result, err := service.repository.StartTask(
|
||||
ctx,
|
||||
StartTaskRepositoryRequest{
|
||||
UserID: command.UserID,
|
||||
DeviceID: command.DeviceID,
|
||||
TaskID: command.TaskID,
|
||||
ClaimGeneration: command.ClaimGeneration,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
ExpectedVersion: command.ExpectedVersion,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
RequestHash: requestHash,
|
||||
Now: now,
|
||||
ExpiresAt: now.Add(service.runningLease),
|
||||
Execution: domain.TaskExecution{
|
||||
ID: executionID,
|
||||
TaskID: command.TaskID,
|
||||
ClaimGeneration: command.ClaimGeneration,
|
||||
UserID: command.UserID,
|
||||
DeviceID: command.DeviceID,
|
||||
CurrentStep: "PREFLIGHT",
|
||||
OrderSubmitted: false,
|
||||
StartedAt: now,
|
||||
},
|
||||
Event: event,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return StartTaskResult{}, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return StartTaskResult{
|
||||
Task: result.Task,
|
||||
Execution: result.Execution,
|
||||
Replayed: result.Replayed,
|
||||
ServerTime: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *LifecycleService) HeartbeatTask(
|
||||
ctx context.Context,
|
||||
command TaskHeartbeatCommand,
|
||||
) (TaskHeartbeatResult, error) {
|
||||
command.UserID = strings.TrimSpace(command.UserID)
|
||||
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
|
||||
command.Step = strings.TrimSpace(command.Step)
|
||||
fields := lifecycleClaimFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
)
|
||||
if !isUUID(command.ExecutionID) {
|
||||
fields["execution_id"] = "must be a UUID"
|
||||
}
|
||||
if !validLifecycleStep(command.Step) {
|
||||
fields["step"] = "must be 1-64 uppercase ASCII characters"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return TaskHeartbeatResult{}, invalidError(
|
||||
"TASK_HEARTBEAT_INVALID",
|
||||
"task heartbeat is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
result, err := service.repository.HeartbeatTask(
|
||||
ctx,
|
||||
TaskHeartbeatRepositoryRequest{
|
||||
UserID: command.UserID,
|
||||
DeviceID: command.DeviceID,
|
||||
TaskID: command.TaskID,
|
||||
ExecutionID: command.ExecutionID,
|
||||
ClaimGeneration: command.ClaimGeneration,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
Step: command.Step,
|
||||
Now: now,
|
||||
MinimumExpiry: now.Add(service.runningLease),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return TaskHeartbeatResult{},
|
||||
wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return TaskHeartbeatResult{
|
||||
Task: result.Task,
|
||||
Execution: result.Execution,
|
||||
CancelRequested: result.CancelRequested,
|
||||
ServerTime: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *LifecycleService) AuthorizeReferenceImage(
|
||||
ctx context.Context,
|
||||
command ReferenceImageCommand,
|
||||
) (domain.PurchaseTask, error) {
|
||||
command.UserID = strings.TrimSpace(command.UserID)
|
||||
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||
fields := lifecycleClaimFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
)
|
||||
if len(fields) > 0 {
|
||||
return domain.PurchaseTask{}, invalidError(
|
||||
"TASK_REFERENCE_IMAGE_INVALID",
|
||||
"task reference image request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
task, err := service.repository.GetActiveClaimTask(
|
||||
ctx,
|
||||
TaskClaimRepositoryRequest{
|
||||
UserID: command.UserID,
|
||||
DeviceID: command.DeviceID,
|
||||
TaskID: command.TaskID,
|
||||
ClaimGeneration: command.ClaimGeneration,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
Now: service.clock.Now().UTC(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{},
|
||||
wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (service *LifecycleService) ReleaseTask(
|
||||
ctx context.Context,
|
||||
command ReleaseTaskCommand,
|
||||
) (ReleaseTaskResult, error) {
|
||||
command.UserID = strings.TrimSpace(command.UserID)
|
||||
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||
fields := lifecycleTransitionFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
command.ExpectedVersion,
|
||||
command.IdempotencyKey,
|
||||
)
|
||||
if len(fields) > 0 {
|
||||
return ReleaseTaskResult{}, invalidError(
|
||||
"TASK_RELEASE_INVALID",
|
||||
"task release request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
event, err := service.newLifecycleEvent(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
"TASK_RELEASED",
|
||||
"task released",
|
||||
)
|
||||
if err != nil {
|
||||
return ReleaseTaskResult{}, err
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
requestHash, err := lifecycleRequestHash(commandHashView{
|
||||
TaskID: command.TaskID,
|
||||
ClaimGeneration: command.ClaimGeneration,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
ExpectedVersion: command.ExpectedVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return ReleaseTaskResult{}, internalLifecycleFailure(err)
|
||||
}
|
||||
task, replayed, err := service.repository.ReleaseTask(
|
||||
ctx,
|
||||
ReleaseTaskRepositoryRequest{
|
||||
UserID: command.UserID,
|
||||
DeviceID: command.DeviceID,
|
||||
TaskID: command.TaskID,
|
||||
ClaimGeneration: command.ClaimGeneration,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
ExpectedVersion: command.ExpectedVersion,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
RequestHash: requestHash,
|
||||
Now: now,
|
||||
Event: event,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return ReleaseTaskResult{}, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return ReleaseTaskResult{
|
||||
Task: task,
|
||||
Replayed: replayed,
|
||||
ServerTime: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *LifecycleService) AcknowledgeCancellation(
|
||||
ctx context.Context,
|
||||
command AcknowledgeCancellationCommand,
|
||||
) (AcknowledgeCancellationResult, error) {
|
||||
command.UserID = strings.TrimSpace(command.UserID)
|
||||
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
|
||||
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||
fields := lifecycleTransitionFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
command.ExpectedVersion,
|
||||
command.IdempotencyKey,
|
||||
)
|
||||
if !isUUID(command.ExecutionID) {
|
||||
fields["execution_id"] = "must be a UUID"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return AcknowledgeCancellationResult{}, invalidError(
|
||||
"TASK_CANCEL_ACK_INVALID",
|
||||
"task cancellation acknowledgement is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
event, err := service.newLifecycleEvent(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
"TASK_CANCELED",
|
||||
"task cancellation acknowledged",
|
||||
)
|
||||
if err != nil {
|
||||
return AcknowledgeCancellationResult{}, err
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
requestHash, err := lifecycleRequestHash(struct {
|
||||
commandHashView
|
||||
ExecutionID string `json:"execution_id"`
|
||||
}{
|
||||
commandHashView: commandHashView{
|
||||
TaskID: command.TaskID,
|
||||
ClaimGeneration: command.ClaimGeneration,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
ExpectedVersion: command.ExpectedVersion,
|
||||
},
|
||||
ExecutionID: command.ExecutionID,
|
||||
})
|
||||
if err != nil {
|
||||
return AcknowledgeCancellationResult{},
|
||||
internalLifecycleFailure(err)
|
||||
}
|
||||
task, replayed, err := service.repository.AcknowledgeTaskCancellation(
|
||||
ctx,
|
||||
CancelAcknowledgementRepositoryRequest{
|
||||
UserID: command.UserID,
|
||||
DeviceID: command.DeviceID,
|
||||
TaskID: command.TaskID,
|
||||
ExecutionID: command.ExecutionID,
|
||||
ClaimGeneration: command.ClaimGeneration,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
ExpectedVersion: command.ExpectedVersion,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
RequestHash: requestHash,
|
||||
Now: now,
|
||||
Event: event,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return AcknowledgeCancellationResult{},
|
||||
wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return AcknowledgeCancellationResult{
|
||||
Task: task,
|
||||
Replayed: replayed,
|
||||
ServerTime: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type commandHashView struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
ClaimTokenHash string `json:"claim_token_sha256"`
|
||||
ExpectedVersion int64 `json:"expected_version"`
|
||||
}
|
||||
|
||||
func normalizeStartTaskCommand(command StartTaskCommand) StartTaskCommand {
|
||||
command.UserID = strings.TrimSpace(command.UserID)
|
||||
command.DeviceID = strings.TrimSpace(command.DeviceID)
|
||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
||||
return command
|
||||
}
|
||||
|
||||
func lifecycleWriteFields(
|
||||
userID string,
|
||||
deviceID string,
|
||||
idempotencyKey string,
|
||||
claimToken string,
|
||||
) map[string]string {
|
||||
fields := lifecycleIdentityFields(userID, deviceID)
|
||||
validateIdempotencyField(fields, idempotencyKey)
|
||||
if !validOpaqueToken(claimToken) {
|
||||
fields["claim_token"] = "must be a 256-bit Raw URL value"
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func lifecycleClaimFields(
|
||||
userID string,
|
||||
deviceID string,
|
||||
taskID string,
|
||||
claimGeneration int64,
|
||||
claimToken string,
|
||||
) map[string]string {
|
||||
fields := lifecycleIdentityFields(userID, deviceID)
|
||||
if !isUUID(taskID) {
|
||||
fields["task_id"] = "must be a UUID"
|
||||
}
|
||||
if claimGeneration < 1 {
|
||||
fields["claim_generation"] = "must be positive"
|
||||
}
|
||||
if !validOpaqueToken(claimToken) {
|
||||
fields["claim_token"] = "must be a 256-bit Raw URL value"
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func lifecycleTransitionFields(
|
||||
userID string,
|
||||
deviceID string,
|
||||
taskID string,
|
||||
claimGeneration int64,
|
||||
claimToken string,
|
||||
expectedVersion int64,
|
||||
idempotencyKey string,
|
||||
) map[string]string {
|
||||
fields := lifecycleClaimFields(
|
||||
userID,
|
||||
deviceID,
|
||||
taskID,
|
||||
claimGeneration,
|
||||
claimToken,
|
||||
)
|
||||
if expectedVersion < 1 {
|
||||
fields["expected_version"] = "must be positive"
|
||||
}
|
||||
validateIdempotencyField(fields, idempotencyKey)
|
||||
return fields
|
||||
}
|
||||
|
||||
func lifecycleIdentityFields(
|
||||
userID string,
|
||||
deviceID string,
|
||||
) map[string]string {
|
||||
fields := make(map[string]string)
|
||||
if !isUUID(userID) {
|
||||
fields["user_id"] = "must be a UUID"
|
||||
}
|
||||
if !isUUID(deviceID) {
|
||||
fields["device_id"] = "must be a UUID"
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func validateIdempotencyField(
|
||||
fields map[string]string,
|
||||
value string,
|
||||
) {
|
||||
if value == "" ||
|
||||
len([]byte(value)) > maxIdempotencyKeyBytes ||
|
||||
!isPrintableASCII(value) {
|
||||
fields["idempotency_key"] = "invalid"
|
||||
}
|
||||
}
|
||||
|
||||
func validateVersionField(
|
||||
fields map[string]string,
|
||||
name string,
|
||||
value string,
|
||||
) {
|
||||
if value == "" {
|
||||
fields[name] = "required"
|
||||
} else if !utf8.ValidString(value) ||
|
||||
len([]byte(value)) > domain.MaxVersionBytes {
|
||||
fields[name] = "must be valid UTF-8 up to 128 bytes"
|
||||
}
|
||||
}
|
||||
|
||||
func validLifecycleStep(value string) bool {
|
||||
if value == "" || len([]byte(value)) > maxLifecycleStepBytes {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char < 'A' || char > 'Z') &&
|
||||
(char < '0' || char > '9') &&
|
||||
char != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func lifecycleRequestHash(value any) (string, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func (service *LifecycleService) newLifecycleEvent(
|
||||
userID string,
|
||||
deviceID string,
|
||||
taskID string,
|
||||
eventType string,
|
||||
message string,
|
||||
) (domain.TaskEvent, error) {
|
||||
id, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return domain.TaskEvent{}, internalLifecycleFailure(err)
|
||||
}
|
||||
actorUserID := userID
|
||||
actorDeviceID := deviceID
|
||||
return domain.TaskEvent{
|
||||
ID: id,
|
||||
TaskID: taskID,
|
||||
ActorUserID: &actorUserID,
|
||||
ActorDeviceID: &actorDeviceID,
|
||||
Type: eventType,
|
||||
Message: message,
|
||||
OccurredAt: service.clock.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sameOptionalID(left, right *string) bool {
|
||||
if left == nil || strings.TrimSpace(*left) == "" {
|
||||
return right == nil
|
||||
}
|
||||
return right != nil && strings.TrimSpace(*left) == *right
|
||||
}
|
||||
|
||||
func internalLifecycleFailure(err error) error {
|
||||
return newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -82,7 +82,7 @@ type TaskRepository interface {
|
||||
string,
|
||||
string,
|
||||
) (domain.TaskDetail, error)
|
||||
CancelPendingTask(
|
||||
CancelTask(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
|
||||
@@ -378,7 +378,7 @@ func (s *TaskService) Cancel(
|
||||
Message: "task canceled",
|
||||
OccurredAt: now,
|
||||
}
|
||||
task, err := s.repository.CancelPendingTask(
|
||||
task, err := s.repository.CancelTask(
|
||||
ctx,
|
||||
command.CreatorSubject,
|
||||
command.TaskID,
|
||||
|
||||
@@ -203,7 +203,7 @@ func (repository *fakeTaskRepository) GetTaskDetail(
|
||||
return domain.TaskDetail{}, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (repository *fakeTaskRepository) CancelPendingTask(
|
||||
func (repository *fakeTaskRepository) CancelTask(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
_ string,
|
||||
|
||||
Reference in New Issue
Block a user