feat(backend): implement task creation and admin web
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
assetUploadOperation = "UPLOAD_TASK_REFERENCE"
|
||||
maxIdempotencyKeyBytes = 128
|
||||
)
|
||||
|
||||
type AssetService struct {
|
||||
repository AssetRepository
|
||||
store ReferenceImageStore
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
}
|
||||
|
||||
type UploadTaskReferenceCommand struct {
|
||||
CreatorSubject string
|
||||
IdempotencyKey string
|
||||
DeclaredMediaType string
|
||||
Content io.Reader
|
||||
}
|
||||
|
||||
type UploadTaskReferenceResult struct {
|
||||
Asset domain.Asset
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type AssetContent struct {
|
||||
Asset domain.Asset
|
||||
Content io.ReadCloser
|
||||
}
|
||||
|
||||
func NewAssetService(
|
||||
repository AssetRepository,
|
||||
store ReferenceImageStore,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
) (*AssetService, error) {
|
||||
if repository == nil || store == nil || clock == nil || ids == nil {
|
||||
return nil, errors.New("asset service dependencies are required")
|
||||
}
|
||||
return &AssetService{
|
||||
repository: repository,
|
||||
store: store,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssetService) UploadTaskReference(
|
||||
ctx context.Context,
|
||||
command UploadTaskReferenceCommand,
|
||||
) (UploadTaskReferenceResult, error) {
|
||||
if err := validateWriteIdentity(
|
||||
command.CreatorSubject,
|
||||
command.IdempotencyKey,
|
||||
); err != nil {
|
||||
return UploadTaskReferenceResult{}, err
|
||||
}
|
||||
if command.Content == nil {
|
||||
return UploadTaskReferenceResult{}, invalidError(
|
||||
"ASSET_FILE_REQUIRED",
|
||||
"reference image is required",
|
||||
map[string]string{"file": "required"},
|
||||
)
|
||||
}
|
||||
assetID, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return UploadTaskReferenceResult{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
normalized, err := s.store.Put(
|
||||
ctx,
|
||||
assetID,
|
||||
command.DeclaredMediaType,
|
||||
command.Content,
|
||||
)
|
||||
if err != nil {
|
||||
return UploadTaskReferenceResult{}, mapImageStoreError(err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = s.store.Delete(context.Background(), normalized.StorageKey)
|
||||
}
|
||||
|
||||
requestHash := sha256.Sum256([]byte(
|
||||
domain.AssetPurposeTaskReference + "\x00" +
|
||||
normalized.InputMediaType + "\x00" +
|
||||
normalized.InputSHA256,
|
||||
))
|
||||
candidate := domain.Asset{
|
||||
ID: assetID,
|
||||
CreatorSubject: strings.TrimSpace(command.CreatorSubject),
|
||||
Purpose: domain.AssetPurposeTaskReference,
|
||||
MediaType: normalized.MediaType,
|
||||
SizeBytes: normalized.SizeBytes,
|
||||
SHA256: normalized.SHA256,
|
||||
StorageKey: normalized.StorageKey,
|
||||
CreatedAt: s.clock.Now().UTC(),
|
||||
}
|
||||
asset, created, err := s.repository.CreateAssetIdempotent(
|
||||
ctx,
|
||||
candidate,
|
||||
strings.TrimSpace(command.IdempotencyKey),
|
||||
hex.EncodeToString(requestHash[:]),
|
||||
)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return UploadTaskReferenceResult{}, wrapRepositoryError(err)
|
||||
}
|
||||
if !created {
|
||||
cleanup()
|
||||
}
|
||||
return UploadTaskReferenceResult{
|
||||
Asset: asset,
|
||||
Replayed: !created,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssetService) OpenTaskReference(
|
||||
ctx context.Context,
|
||||
creatorSubject string,
|
||||
assetID string,
|
||||
) (AssetContent, error) {
|
||||
if strings.TrimSpace(creatorSubject) == "" ||
|
||||
!isUUID(assetID) {
|
||||
return AssetContent{}, newError(
|
||||
ErrorKindNotFound,
|
||||
"ASSET_NOT_FOUND",
|
||||
"asset not found",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
asset, err := s.repository.GetAsset(
|
||||
ctx,
|
||||
strings.TrimSpace(creatorSubject),
|
||||
assetID,
|
||||
)
|
||||
if err != nil {
|
||||
result := wrapRepositoryError(err)
|
||||
if typed, ok := result.(*Error); ok &&
|
||||
typed.Kind == ErrorKindNotFound {
|
||||
typed.Code = "ASSET_NOT_FOUND"
|
||||
typed.Message = "asset not found"
|
||||
}
|
||||
return AssetContent{}, result
|
||||
}
|
||||
content, err := s.store.Open(ctx, asset.StorageKey)
|
||||
if err != nil {
|
||||
return AssetContent{}, mapImageStoreError(err)
|
||||
}
|
||||
return AssetContent{Asset: asset, Content: content}, nil
|
||||
}
|
||||
|
||||
func validateWriteIdentity(subject, key string) error {
|
||||
if strings.TrimSpace(subject) == "" {
|
||||
return invalidError(
|
||||
"REQUEST_VALIDATION_FAILED",
|
||||
"request validation failed",
|
||||
map[string]string{"creator_subject": "required"},
|
||||
)
|
||||
}
|
||||
trimmedKey := strings.TrimSpace(key)
|
||||
if trimmedKey == "" {
|
||||
return invalidError(
|
||||
"IDEMPOTENCY_KEY_REQUIRED",
|
||||
"idempotency key is required",
|
||||
map[string]string{"idempotency_key": "required"},
|
||||
)
|
||||
}
|
||||
if len([]byte(trimmedKey)) > maxIdempotencyKeyBytes ||
|
||||
!isPrintableASCII(trimmedKey) {
|
||||
return invalidError(
|
||||
"REQUEST_VALIDATION_FAILED",
|
||||
"request validation failed",
|
||||
map[string]string{"idempotency_key": "invalid"},
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPrintableASCII(value string) bool {
|
||||
for _, char := range value {
|
||||
if char < 0x21 || char > 0x7e {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type ImageStoreErrorKind string
|
||||
|
||||
const (
|
||||
ImageStoreErrorTooLarge ImageStoreErrorKind = "TOO_LARGE"
|
||||
ImageStoreErrorUnsupported ImageStoreErrorKind = "UNSUPPORTED"
|
||||
ImageStoreErrorInvalid ImageStoreErrorKind = "INVALID"
|
||||
ImageStoreErrorUnavailable ImageStoreErrorKind = "UNAVAILABLE"
|
||||
ImageStoreErrorNotFound ImageStoreErrorKind = "NOT_FOUND"
|
||||
)
|
||||
|
||||
type ImageStoreError struct {
|
||||
Kind ImageStoreErrorKind
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *ImageStoreError) Error() string {
|
||||
return "reference image store operation failed"
|
||||
}
|
||||
|
||||
func (e *ImageStoreError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func mapImageStoreError(err error) error {
|
||||
var storeError *ImageStoreError
|
||||
if !errors.As(err, &storeError) {
|
||||
return newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
switch storeError.Kind {
|
||||
case ImageStoreErrorTooLarge:
|
||||
return newError(
|
||||
ErrorKindInvalid,
|
||||
"ASSET_TOO_LARGE",
|
||||
"reference image exceeds the allowed size",
|
||||
err,
|
||||
)
|
||||
case ImageStoreErrorUnsupported:
|
||||
return newError(
|
||||
ErrorKindInvalid,
|
||||
"ASSET_MEDIA_TYPE_UNSUPPORTED",
|
||||
"reference image media type is not supported",
|
||||
err,
|
||||
)
|
||||
case ImageStoreErrorInvalid:
|
||||
return invalidError(
|
||||
"ASSET_IMAGE_INVALID",
|
||||
"reference image is invalid",
|
||||
map[string]string{"file": "must be a decodable image"},
|
||||
)
|
||||
case ImageStoreErrorNotFound:
|
||||
return newError(
|
||||
ErrorKindNotFound,
|
||||
"ASSET_NOT_FOUND",
|
||||
"asset not found",
|
||||
err,
|
||||
)
|
||||
default:
|
||||
result := newError(
|
||||
ErrorKindUnavailable,
|
||||
"ASSET_STORAGE_UNAVAILABLE",
|
||||
"asset storage is temporarily unavailable",
|
||||
err,
|
||||
)
|
||||
result.Retryable = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestAssetServiceUploadPersistsNormalizedMetadata(t *testing.T) {
|
||||
repository := &fakeAssetRepository{}
|
||||
store := &fakeReferenceImageStore{
|
||||
result: NormalizedReferenceImage{
|
||||
StorageKey: "aa/file.jpg",
|
||||
InputMediaType: "image/png",
|
||||
InputSHA256: strings.Repeat("1", 64),
|
||||
MediaType: "image/jpeg",
|
||||
SizeBytes: 123,
|
||||
SHA256: strings.Repeat("2", 64),
|
||||
},
|
||||
}
|
||||
service := mustAssetService(t, repository, store)
|
||||
result, err := service.UploadTaskReference(
|
||||
context.Background(),
|
||||
UploadTaskReferenceCommand{
|
||||
CreatorSubject: " local-admin ",
|
||||
IdempotencyKey: " upload-1 ",
|
||||
DeclaredMediaType: "image/png",
|
||||
Content: bytes.NewReader([]byte("fixture")),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadTaskReference() error = %v", err)
|
||||
}
|
||||
if result.Replayed ||
|
||||
result.Asset.CreatorSubject != "local-admin" ||
|
||||
result.Asset.Purpose != domain.AssetPurposeTaskReference ||
|
||||
result.Asset.StorageKey != store.result.StorageKey ||
|
||||
len(repository.hash) != 64 {
|
||||
t.Fatalf("result/repository = %+v/%+v", result, repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetServiceDeletesNewFileWhenRepositoryReplaysOrFails(
|
||||
t *testing.T,
|
||||
) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
replay bool
|
||||
createErr error
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "replay", replay: true},
|
||||
{name: "failure", createErr: ErrRepositoryUnavailable, wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repository := &fakeAssetRepository{
|
||||
replay: test.replay,
|
||||
createErr: test.createErr,
|
||||
}
|
||||
store := &fakeReferenceImageStore{
|
||||
result: NormalizedReferenceImage{
|
||||
StorageKey: "aa/candidate.jpg",
|
||||
InputMediaType: "image/jpeg",
|
||||
InputSHA256: strings.Repeat("1", 64),
|
||||
MediaType: "image/jpeg",
|
||||
SizeBytes: 123,
|
||||
SHA256: strings.Repeat("2", 64),
|
||||
},
|
||||
}
|
||||
service := mustAssetService(t, repository, store)
|
||||
_, err := service.UploadTaskReference(
|
||||
context.Background(),
|
||||
UploadTaskReferenceCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
IdempotencyKey: "upload-1",
|
||||
DeclaredMediaType: "image/jpeg",
|
||||
Content: bytes.NewReader([]byte("fixture")),
|
||||
},
|
||||
)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("UploadTaskReference() error = %v", err)
|
||||
}
|
||||
if store.deleted != "aa/candidate.jpg" {
|
||||
t.Fatalf("deleted key = %q", store.deleted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetServiceMapsImageValidationError(t *testing.T) {
|
||||
service := mustAssetService(
|
||||
t,
|
||||
&fakeAssetRepository{},
|
||||
&fakeReferenceImageStore{
|
||||
err: &ImageStoreError{
|
||||
Kind: ImageStoreErrorInvalid,
|
||||
Cause: errors.New("private decoder detail"),
|
||||
},
|
||||
},
|
||||
)
|
||||
_, err := service.UploadTaskReference(
|
||||
context.Background(),
|
||||
UploadTaskReferenceCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
IdempotencyKey: "upload-1",
|
||||
DeclaredMediaType: "image/jpeg",
|
||||
Content: bytes.NewReader([]byte("bad")),
|
||||
},
|
||||
)
|
||||
assertUsecaseError(t, err, ErrorKindInvalid, "ASSET_IMAGE_INVALID")
|
||||
if strings.Contains(err.Error(), "private") {
|
||||
t.Fatal("public error leaked decoder detail")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAssetRepository struct {
|
||||
asset domain.Asset
|
||||
replay bool
|
||||
createErr error
|
||||
hash string
|
||||
}
|
||||
|
||||
func (repository *fakeAssetRepository) CreateAssetIdempotent(
|
||||
_ context.Context,
|
||||
asset domain.Asset,
|
||||
_ string,
|
||||
hash string,
|
||||
) (domain.Asset, bool, error) {
|
||||
repository.asset = asset
|
||||
repository.hash = hash
|
||||
if repository.createErr != nil {
|
||||
return asset, false, repository.createErr
|
||||
}
|
||||
if repository.replay {
|
||||
return asset, false, nil
|
||||
}
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (repository *fakeAssetRepository) GetAsset(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.Asset, error) {
|
||||
return repository.asset, nil
|
||||
}
|
||||
|
||||
type fakeReferenceImageStore struct {
|
||||
result NormalizedReferenceImage
|
||||
err error
|
||||
deleted string
|
||||
}
|
||||
|
||||
func (store *fakeReferenceImageStore) Put(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
io.Reader,
|
||||
) (NormalizedReferenceImage, error) {
|
||||
return store.result, store.err
|
||||
}
|
||||
|
||||
func (store *fakeReferenceImageStore) Open(
|
||||
context.Context,
|
||||
string,
|
||||
) (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(nil)), nil
|
||||
}
|
||||
|
||||
func (store *fakeReferenceImageStore) Delete(
|
||||
_ context.Context,
|
||||
key string,
|
||||
) error {
|
||||
store.deleted = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustAssetService(
|
||||
t *testing.T,
|
||||
repository AssetRepository,
|
||||
store ReferenceImageStore,
|
||||
) *AssetService {
|
||||
t.Helper()
|
||||
service, err := NewAssetService(
|
||||
repository,
|
||||
store,
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAssetService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
var _ Clock = fakeClock{}
|
||||
@@ -0,0 +1,126 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ErrorKind string
|
||||
|
||||
const (
|
||||
ErrorKindInvalid ErrorKind = "INVALID"
|
||||
ErrorKindNotFound ErrorKind = "NOT_FOUND"
|
||||
ErrorKindConflict ErrorKind = "CONFLICT"
|
||||
ErrorKindUnavailable ErrorKind = "UNAVAILABLE"
|
||||
ErrorKindInternal ErrorKind = "INTERNAL"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
Kind ErrorKind
|
||||
Code string
|
||||
Message string
|
||||
Retryable bool
|
||||
Fields map[string]string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return "use case failed"
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func newError(
|
||||
kind ErrorKind,
|
||||
code string,
|
||||
message string,
|
||||
cause error,
|
||||
) *Error {
|
||||
return &Error{
|
||||
Kind: kind,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Cause: cause,
|
||||
Fields: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func invalidError(code, message string, fields map[string]string) *Error {
|
||||
return &Error{
|
||||
Kind: ErrorKindInvalid,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Fields: fields,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrRepositoryNotFound = errors.New("repository resource not found")
|
||||
ErrIdempotencyConflict = errors.New("idempotency key payload conflict")
|
||||
ErrSourceReferenceConflict = errors.New("source reference conflict")
|
||||
ErrAssetUnavailable = errors.New("asset is unavailable")
|
||||
ErrTaskStateConflict = errors.New("task state conflict")
|
||||
ErrRepositoryUnavailable = errors.New("repository unavailable")
|
||||
ErrRepositoryInvariant = errors.New("repository invariant failed")
|
||||
)
|
||||
|
||||
func wrapRepositoryError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, ErrRepositoryNotFound):
|
||||
return newError(
|
||||
ErrorKindNotFound,
|
||||
"RESOURCE_NOT_FOUND",
|
||||
"resource not found",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrIdempotencyConflict):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"IDEMPOTENCY_KEY_CONFLICT",
|
||||
"idempotency key was already used for a different request",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrSourceReferenceConflict):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"TASK_SOURCE_REF_CONFLICT",
|
||||
"source reference already exists",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrAssetUnavailable):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"TASK_ASSET_UNAVAILABLE",
|
||||
"reference image is not available for this task",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrTaskStateConflict):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"TASK_STATE_CONFLICT",
|
||||
"task state does not allow this operation",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrRepositoryUnavailable):
|
||||
result := newError(
|
||||
ErrorKindUnavailable,
|
||||
"STORAGE_UNAVAILABLE",
|
||||
"storage is temporarily unavailable",
|
||||
err,
|
||||
)
|
||||
result.Retryable = true
|
||||
return result
|
||||
default:
|
||||
return newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
fmt.Errorf("%w: %v", ErrRepositoryInvariant, err),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
type Clock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
type IDGenerator interface {
|
||||
NewID() (string, error)
|
||||
}
|
||||
|
||||
type NormalizedReferenceImage struct {
|
||||
StorageKey string
|
||||
InputMediaType string
|
||||
InputSHA256 string
|
||||
MediaType string
|
||||
SizeBytes int64
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
type ReferenceImageStore interface {
|
||||
Put(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
io.Reader,
|
||||
) (NormalizedReferenceImage, error)
|
||||
Open(context.Context, string) (io.ReadCloser, error)
|
||||
Delete(context.Context, string) error
|
||||
}
|
||||
|
||||
type AssetRepository interface {
|
||||
CreateAssetIdempotent(
|
||||
context.Context,
|
||||
domain.Asset,
|
||||
string,
|
||||
string,
|
||||
) (domain.Asset, bool, error)
|
||||
GetAsset(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.Asset, error)
|
||||
}
|
||||
|
||||
type TaskCursor struct {
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
}
|
||||
|
||||
type TaskListFilter struct {
|
||||
CreatorSubject string
|
||||
Status *domain.TaskStatus
|
||||
Query string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Limit int
|
||||
After *TaskCursor
|
||||
}
|
||||
|
||||
type TaskRepository interface {
|
||||
CreateTaskIdempotent(
|
||||
context.Context,
|
||||
domain.PurchaseTask,
|
||||
domain.TaskEvent,
|
||||
string,
|
||||
string,
|
||||
) (domain.PurchaseTask, bool, error)
|
||||
ListTasks(
|
||||
context.Context,
|
||||
TaskListFilter,
|
||||
) ([]domain.PurchaseTask, error)
|
||||
GetTaskDetail(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.TaskDetail, error)
|
||||
CancelPendingTask(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
time.Time,
|
||||
domain.TaskEvent,
|
||||
) (domain.PurchaseTask, error)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SystemClock struct{}
|
||||
|
||||
func (SystemClock) Now() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
type UUIDGenerator struct{}
|
||||
|
||||
func (UUIDGenerator) NewID() (string, error) {
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
now := uint64(time.Now().UnixNano())
|
||||
binary.BigEndian.PutUint64(value[:8], now)
|
||||
binary.BigEndian.PutUint64(value[8:], fallbackUUID.Add(1))
|
||||
}
|
||||
value[6] = (value[6] & 0x0f) | 0x40
|
||||
value[8] = (value[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf(
|
||||
"%08x-%04x-%04x-%04x-%012x",
|
||||
value[0:4],
|
||||
value[4:6],
|
||||
value[6:8],
|
||||
value[8:10],
|
||||
value[10:16],
|
||||
), nil
|
||||
}
|
||||
|
||||
var fallbackUUID atomic.Uint64
|
||||
@@ -0,0 +1,459 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
createTaskOperation = "CREATE_PURCHASE_TASK"
|
||||
defaultTaskListLimit = 20
|
||||
maxTaskListLimit = 100
|
||||
maxTaskQueryBytes = 256
|
||||
)
|
||||
|
||||
type TaskService struct {
|
||||
repository TaskRepository
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
}
|
||||
|
||||
type CreateTaskCommand struct {
|
||||
CreatorSubject string
|
||||
IdempotencyKey string
|
||||
SourceRef *string
|
||||
Title string
|
||||
Description string
|
||||
SKU string
|
||||
ImageAssetID string
|
||||
Quantity int
|
||||
MaxBudget *string
|
||||
}
|
||||
|
||||
type CreateTaskResult struct {
|
||||
Task domain.PurchaseTask
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type ListTasksQuery struct {
|
||||
CreatorSubject string
|
||||
Status *string
|
||||
Query string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Limit int
|
||||
Cursor string
|
||||
}
|
||||
|
||||
type TaskPage struct {
|
||||
Items []domain.PurchaseTask
|
||||
NextCursor string
|
||||
}
|
||||
|
||||
type CancelTaskCommand struct {
|
||||
CreatorSubject string
|
||||
TaskID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func NewTaskService(
|
||||
repository TaskRepository,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
) (*TaskService, error) {
|
||||
if repository == nil || clock == nil || ids == nil {
|
||||
return nil, errors.New("task service dependencies are required")
|
||||
}
|
||||
return &TaskService{
|
||||
repository: repository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) Create(
|
||||
ctx context.Context,
|
||||
command CreateTaskCommand,
|
||||
) (CreateTaskResult, error) {
|
||||
if err := validateWriteIdentity(
|
||||
command.CreatorSubject,
|
||||
command.IdempotencyKey,
|
||||
); err != nil {
|
||||
return CreateTaskResult{}, err
|
||||
}
|
||||
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
||||
command.Title = strings.TrimSpace(command.Title)
|
||||
command.SKU = strings.TrimSpace(command.SKU)
|
||||
command.ImageAssetID = strings.TrimSpace(command.ImageAssetID)
|
||||
if command.SourceRef != nil {
|
||||
value := strings.TrimSpace(*command.SourceRef)
|
||||
command.SourceRef = &value
|
||||
}
|
||||
if !isUUID(command.ImageAssetID) {
|
||||
return CreateTaskResult{}, invalidError(
|
||||
"TASK_VALIDATION_FAILED",
|
||||
"task validation failed",
|
||||
map[string]string{"image_asset_id": "must be a UUID"},
|
||||
)
|
||||
}
|
||||
if err := domain.ValidateTaskInput(
|
||||
command.CreatorSubject,
|
||||
command.SourceRef,
|
||||
command.Title,
|
||||
command.Description,
|
||||
command.SKU,
|
||||
command.ImageAssetID,
|
||||
command.Quantity,
|
||||
); err != nil {
|
||||
var validationError *domain.TaskValidationError
|
||||
if errors.As(err, &validationError) {
|
||||
return CreateTaskResult{}, invalidError(
|
||||
"TASK_VALIDATION_FAILED",
|
||||
"task validation failed",
|
||||
validationError.Fields,
|
||||
)
|
||||
}
|
||||
return CreateTaskResult{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
budget, err := domain.ParseOptionalCNY(command.MaxBudget)
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, invalidError(
|
||||
"TASK_VALIDATION_FAILED",
|
||||
"task validation failed",
|
||||
map[string]string{"max_budget": "must be a positive CNY amount with at most two decimals"},
|
||||
)
|
||||
}
|
||||
taskID, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
eventID, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
now := s.clock.Now().UTC()
|
||||
task := domain.PurchaseTask{
|
||||
ID: taskID,
|
||||
CreatorSubject: command.CreatorSubject,
|
||||
SourceRef: command.SourceRef,
|
||||
Title: command.Title,
|
||||
Description: command.Description,
|
||||
SKU: command.SKU,
|
||||
ImageAssetID: command.ImageAssetID,
|
||||
Quantity: command.Quantity,
|
||||
MaxBudgetCents: budget,
|
||||
Currency: domain.CurrencyCNY,
|
||||
Status: domain.TaskStatusPending,
|
||||
Version: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
event := domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: taskID,
|
||||
Type: "TASK_CREATED",
|
||||
Message: "task created",
|
||||
OccurredAt: now,
|
||||
}
|
||||
requestHash, err := hashCreateTaskCommand(command, budget)
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
createdTask, created, err := s.repository.CreateTaskIdempotent(
|
||||
ctx,
|
||||
task,
|
||||
event,
|
||||
strings.TrimSpace(command.IdempotencyKey),
|
||||
requestHash,
|
||||
)
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, wrapRepositoryError(err)
|
||||
}
|
||||
return CreateTaskResult{
|
||||
Task: createdTask,
|
||||
Replayed: !created,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) List(
|
||||
ctx context.Context,
|
||||
query ListTasksQuery,
|
||||
) (TaskPage, error) {
|
||||
query.CreatorSubject = strings.TrimSpace(query.CreatorSubject)
|
||||
if query.CreatorSubject == "" {
|
||||
return TaskPage{}, invalidError(
|
||||
"REQUEST_VALIDATION_FAILED",
|
||||
"request validation failed",
|
||||
map[string]string{"creator_subject": "required"},
|
||||
)
|
||||
}
|
||||
filter := TaskListFilter{
|
||||
CreatorSubject: query.CreatorSubject,
|
||||
Query: strings.TrimSpace(query.Query),
|
||||
CreatedFrom: query.CreatedFrom,
|
||||
CreatedTo: query.CreatedTo,
|
||||
Limit: query.Limit,
|
||||
}
|
||||
if len([]byte(filter.Query)) > maxTaskQueryBytes {
|
||||
return TaskPage{}, invalidError(
|
||||
"TASK_LIST_FILTER_INVALID",
|
||||
"task list filter is invalid",
|
||||
map[string]string{"q": "too long"},
|
||||
)
|
||||
}
|
||||
if filter.Limit == 0 {
|
||||
filter.Limit = defaultTaskListLimit
|
||||
}
|
||||
if filter.Limit < 1 || filter.Limit > maxTaskListLimit {
|
||||
return TaskPage{}, invalidError(
|
||||
"TASK_LIST_FILTER_INVALID",
|
||||
"task list filter is invalid",
|
||||
map[string]string{"limit": "must be between 1 and 100"},
|
||||
)
|
||||
}
|
||||
if query.Status != nil {
|
||||
status := domain.TaskStatus(strings.TrimSpace(*query.Status))
|
||||
if !domain.IsValidTaskStatus(status) {
|
||||
return TaskPage{}, invalidError(
|
||||
"TASK_LIST_FILTER_INVALID",
|
||||
"task list filter is invalid",
|
||||
map[string]string{"status": "unknown status"},
|
||||
)
|
||||
}
|
||||
filter.Status = &status
|
||||
}
|
||||
if query.CreatedFrom != nil && query.CreatedTo != nil &&
|
||||
query.CreatedFrom.After(*query.CreatedTo) {
|
||||
return TaskPage{}, invalidError(
|
||||
"TASK_LIST_FILTER_INVALID",
|
||||
"task list filter is invalid",
|
||||
map[string]string{"created_from": "must not be after created_to"},
|
||||
)
|
||||
}
|
||||
if query.Cursor != "" {
|
||||
cursor, err := decodeTaskCursor(query.Cursor)
|
||||
if err != nil {
|
||||
return TaskPage{}, invalidError(
|
||||
"TASK_CURSOR_INVALID",
|
||||
"task cursor is invalid",
|
||||
map[string]string{"cursor": "invalid"},
|
||||
)
|
||||
}
|
||||
filter.After = &cursor
|
||||
}
|
||||
filter.Limit++
|
||||
items, err := s.repository.ListTasks(ctx, filter)
|
||||
if err != nil {
|
||||
return TaskPage{}, wrapRepositoryError(err)
|
||||
}
|
||||
page := TaskPage{Items: items}
|
||||
if len(items) >= filter.Limit {
|
||||
page.Items = items[:filter.Limit-1]
|
||||
last := page.Items[len(page.Items)-1]
|
||||
page.NextCursor = encodeTaskCursor(TaskCursor{
|
||||
CreatedAt: last.CreatedAt,
|
||||
ID: last.ID,
|
||||
})
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) Get(
|
||||
ctx context.Context,
|
||||
creatorSubject string,
|
||||
taskID string,
|
||||
) (domain.TaskDetail, error) {
|
||||
creatorSubject = strings.TrimSpace(creatorSubject)
|
||||
if creatorSubject == "" || !isUUID(taskID) {
|
||||
return domain.TaskDetail{}, newError(
|
||||
ErrorKindNotFound,
|
||||
"TASK_NOT_FOUND",
|
||||
"task not found",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
detail, err := s.repository.GetTaskDetail(
|
||||
ctx,
|
||||
creatorSubject,
|
||||
taskID,
|
||||
)
|
||||
if err != nil {
|
||||
result := wrapRepositoryError(err)
|
||||
if typed, ok := result.(*Error); ok &&
|
||||
typed.Kind == ErrorKindNotFound {
|
||||
typed.Code = "TASK_NOT_FOUND"
|
||||
typed.Message = "task not found"
|
||||
}
|
||||
return domain.TaskDetail{}, result
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) Cancel(
|
||||
ctx context.Context,
|
||||
command CancelTaskCommand,
|
||||
) (domain.PurchaseTask, error) {
|
||||
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||
command.Reason = strings.TrimSpace(command.Reason)
|
||||
fields := make(map[string]string)
|
||||
if command.CreatorSubject == "" {
|
||||
fields["creator_subject"] = "required"
|
||||
}
|
||||
if !isUUID(command.TaskID) {
|
||||
fields["task_id"] = "must be a UUID"
|
||||
}
|
||||
if len([]byte(command.Reason)) > domain.MaxCancelReasonBytes {
|
||||
fields["reason"] = "too long"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return domain.PurchaseTask{}, invalidError(
|
||||
"TASK_CANCEL_INVALID",
|
||||
"task cancellation is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
eventID, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
now := s.clock.Now().UTC()
|
||||
event := domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: command.TaskID,
|
||||
Type: "TASK_CANCELED",
|
||||
Message: "task canceled",
|
||||
OccurredAt: now,
|
||||
}
|
||||
task, err := s.repository.CancelPendingTask(
|
||||
ctx,
|
||||
command.CreatorSubject,
|
||||
command.TaskID,
|
||||
command.Reason,
|
||||
now,
|
||||
event,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, wrapRepositoryError(err)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func hashCreateTaskCommand(
|
||||
command CreateTaskCommand,
|
||||
budget *int64,
|
||||
) (string, error) {
|
||||
payload := struct {
|
||||
SourceRef *string `json:"source_ref"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
SKU string `json:"sku"`
|
||||
ImageAssetID string `json:"image_asset_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
MaxBudgetCents *int64 `json:"max_budget_cents"`
|
||||
}{
|
||||
SourceRef: command.SourceRef,
|
||||
Title: command.Title,
|
||||
Description: command.Description,
|
||||
SKU: command.SKU,
|
||||
ImageAssetID: command.ImageAssetID,
|
||||
Quantity: command.Quantity,
|
||||
MaxBudgetCents: budget,
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
func encodeTaskCursor(cursor TaskCursor) string {
|
||||
payload := struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
}{
|
||||
CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
ID: cursor.ID,
|
||||
}
|
||||
encoded, _ := json.Marshal(payload)
|
||||
return base64.RawURLEncoding.EncodeToString(encoded)
|
||||
}
|
||||
|
||||
func decodeTaskCursor(value string) (TaskCursor, error) {
|
||||
encoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return TaskCursor{}, err
|
||||
}
|
||||
var payload struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(encoded)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return TaskCursor{}, err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return TaskCursor{}, errors.New("task cursor has trailing data")
|
||||
}
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, payload.CreatedAt)
|
||||
if err != nil || !isUUID(payload.ID) {
|
||||
return TaskCursor{}, errors.New("invalid task cursor")
|
||||
}
|
||||
return TaskCursor{CreatedAt: createdAt.UTC(), ID: payload.ID}, nil
|
||||
}
|
||||
|
||||
func isUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, char := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if char != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !((char >= '0' && char <= '9') ||
|
||||
(char >= 'a' && char <= 'f') ||
|
||||
(char >= 'A' && char <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
|
||||
repository := &fakeTaskRepository{}
|
||||
service := mustTaskService(t, repository)
|
||||
budget := "20.00"
|
||||
sourceRef := " source-1 "
|
||||
|
||||
result, err := service.Create(context.Background(), CreateTaskCommand{
|
||||
CreatorSubject: " local-admin ",
|
||||
IdempotencyKey: " create-1 ",
|
||||
SourceRef: &sourceRef,
|
||||
Title: " Demo title ",
|
||||
Description: "description",
|
||||
SKU: " SKU-1 ",
|
||||
ImageAssetID: "00000000-0000-4000-8000-000000000001",
|
||||
Quantity: 2,
|
||||
MaxBudget: &budget,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if result.Task.Status != domain.TaskStatusPending ||
|
||||
result.Task.Title != "Demo title" ||
|
||||
result.Task.SKU != "SKU-1" ||
|
||||
result.Task.SourceRef == nil ||
|
||||
*result.Task.SourceRef != "source-1" ||
|
||||
result.Task.MaxBudgetCents == nil ||
|
||||
*result.Task.MaxBudgetCents != 2000 {
|
||||
t.Fatalf("created task = %+v", result.Task)
|
||||
}
|
||||
if repository.key != "create-1" || len(repository.hash) != 64 {
|
||||
t.Fatalf(
|
||||
"idempotency key/hash = %q/%q",
|
||||
repository.key,
|
||||
repository.hash,
|
||||
)
|
||||
}
|
||||
if repository.event.Type != "TASK_CREATED" ||
|
||||
repository.event.TaskID != result.Task.ID {
|
||||
t.Fatalf("event = %+v", repository.event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceCreateMapsValidationAndRepositoryErrors(t *testing.T) {
|
||||
repository := &fakeTaskRepository{createErr: ErrAssetUnavailable}
|
||||
service := mustTaskService(t, repository)
|
||||
_, err := service.Create(context.Background(), CreateTaskCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
IdempotencyKey: "create-1",
|
||||
Title: "title",
|
||||
SKU: "sku",
|
||||
ImageAssetID: "00000000-0000-4000-8000-000000000001",
|
||||
Quantity: 1,
|
||||
})
|
||||
assertUsecaseError(t, err, ErrorKindConflict, "TASK_ASSET_UNAVAILABLE")
|
||||
|
||||
_, err = service.Create(context.Background(), CreateTaskCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
IdempotencyKey: "create-2",
|
||||
Title: "",
|
||||
SKU: "",
|
||||
ImageAssetID: "bad",
|
||||
Quantity: 0,
|
||||
})
|
||||
assertUsecaseError(t, err, ErrorKindInvalid, "TASK_VALIDATION_FAILED")
|
||||
}
|
||||
|
||||
func TestTaskServiceListUsesStableOpaqueCursor(t *testing.T) {
|
||||
createdAt := time.Date(2026, 7, 26, 1, 2, 3, 4, time.UTC)
|
||||
repository := &fakeTaskRepository{
|
||||
listResult: []domain.PurchaseTask{
|
||||
{ID: "00000000-0000-4000-8000-000000000003", CreatedAt: createdAt},
|
||||
{ID: "00000000-0000-4000-8000-000000000002", CreatedAt: createdAt},
|
||||
{ID: "00000000-0000-4000-8000-000000000001", CreatedAt: createdAt},
|
||||
},
|
||||
}
|
||||
service := mustTaskService(t, repository)
|
||||
page, err := service.List(context.Background(), ListTasksQuery{
|
||||
CreatorSubject: "local-admin",
|
||||
Limit: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if len(page.Items) != 2 || page.NextCursor == "" {
|
||||
t.Fatalf("page = %+v", page)
|
||||
}
|
||||
if repository.filter.Limit != 3 {
|
||||
t.Fatalf("repository limit = %d", repository.filter.Limit)
|
||||
}
|
||||
cursor, err := decodeTaskCursor(page.NextCursor)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeTaskCursor() error = %v", err)
|
||||
}
|
||||
if cursor.ID != page.Items[1].ID || !cursor.CreatedAt.Equal(createdAt) {
|
||||
t.Fatalf("cursor = %+v", cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceCancelMapsStateConflict(t *testing.T) {
|
||||
repository := &fakeTaskRepository{cancelErr: ErrTaskStateConflict}
|
||||
service := mustTaskService(t, repository)
|
||||
_, err := service.Cancel(context.Background(), CancelTaskCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
TaskID: "00000000-0000-4000-8000-000000000001",
|
||||
Reason: "no longer needed",
|
||||
})
|
||||
assertUsecaseError(t, err, ErrorKindConflict, "TASK_STATE_CONFLICT")
|
||||
}
|
||||
|
||||
type fakeClock struct{}
|
||||
|
||||
func (fakeClock) Now() time.Time {
|
||||
return time.Date(2026, 7, 26, 1, 2, 3, 4, time.UTC)
|
||||
}
|
||||
|
||||
type sequenceIDs struct {
|
||||
next int
|
||||
}
|
||||
|
||||
func (generator *sequenceIDs) NewID() (string, error) {
|
||||
generator.next++
|
||||
return "00000000-0000-4000-8000-" +
|
||||
pad12(generator.next), nil
|
||||
}
|
||||
|
||||
func pad12(value int) string {
|
||||
result := "000000000000"
|
||||
digits := []byte{}
|
||||
for value > 0 {
|
||||
digits = append([]byte{byte('0' + value%10)}, digits...)
|
||||
value /= 10
|
||||
}
|
||||
if len(digits) == 0 {
|
||||
digits = []byte{'0'}
|
||||
}
|
||||
return result[:12-len(digits)] + string(digits)
|
||||
}
|
||||
|
||||
type fakeTaskRepository struct {
|
||||
createErr error
|
||||
cancelErr error
|
||||
listResult []domain.PurchaseTask
|
||||
task domain.PurchaseTask
|
||||
event domain.TaskEvent
|
||||
key string
|
||||
hash string
|
||||
filter TaskListFilter
|
||||
}
|
||||
|
||||
func (repository *fakeTaskRepository) CreateTaskIdempotent(
|
||||
_ context.Context,
|
||||
task domain.PurchaseTask,
|
||||
event domain.TaskEvent,
|
||||
key string,
|
||||
hash string,
|
||||
) (domain.PurchaseTask, bool, error) {
|
||||
repository.task = task
|
||||
repository.event = event
|
||||
repository.key = key
|
||||
repository.hash = hash
|
||||
return task, true, repository.createErr
|
||||
}
|
||||
|
||||
func (repository *fakeTaskRepository) ListTasks(
|
||||
_ context.Context,
|
||||
filter TaskListFilter,
|
||||
) ([]domain.PurchaseTask, error) {
|
||||
repository.filter = filter
|
||||
return repository.listResult, nil
|
||||
}
|
||||
|
||||
func (repository *fakeTaskRepository) GetTaskDetail(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.TaskDetail, error) {
|
||||
return domain.TaskDetail{}, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (repository *fakeTaskRepository) CancelPendingTask(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
_ string,
|
||||
_ string,
|
||||
_ time.Time,
|
||||
_ domain.TaskEvent,
|
||||
) (domain.PurchaseTask, error) {
|
||||
return domain.PurchaseTask{}, repository.cancelErr
|
||||
}
|
||||
|
||||
func mustTaskService(
|
||||
t *testing.T,
|
||||
repository TaskRepository,
|
||||
) *TaskService {
|
||||
t.Helper()
|
||||
service, err := NewTaskService(
|
||||
repository,
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTaskService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func assertUsecaseError(
|
||||
t *testing.T,
|
||||
err error,
|
||||
kind ErrorKind,
|
||||
code string,
|
||||
) {
|
||||
t.Helper()
|
||||
var typed *Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %v, want *Error", err)
|
||||
}
|
||||
if typed.Kind != kind || typed.Code != code {
|
||||
t.Fatalf("error = %+v, want kind=%s code=%s", typed, kind, code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user