This commit is contained in:
2026-10-07 23:09:17 +07:00
parent e0caa0b409
commit 56bc009311
21 changed files with 3310 additions and 221 deletions
+100
View File
@@ -0,0 +1,100 @@
// Package task_service implements the shared developer board.
package task_service
import (
"context"
"strings"
"time"
"unicode/utf8"
"evening_detective_server/internal/repos/tasks_repo"
)
var (
ErrInvalidTask = tasks_repo.ErrInvalidTask
ErrNotFound = tasks_repo.ErrNotFound
ErrConflict = tasks_repo.ErrConflict
ErrInvalidAssignee = tasks_repo.ErrInvalidAssignee
)
type Task = tasks_repo.Task
type Assignee = tasks_repo.Assignee
type ITasksRepo interface {
List(context.Context) ([]*Task, error)
ListAssignees(context.Context) ([]Assignee, error)
Create(context.Context, Task) (*Task, error)
Update(context.Context, Task) (*Task, error)
SetStatus(context.Context, int, int, string) (*Task, error)
Delete(context.Context, int, int) error
}
type TaskService struct{ repo ITasksRepo }
func NewTaskService(repo ITasksRepo) *TaskService { return &TaskService{repo: repo} }
func (s *TaskService) List(ctx context.Context) ([]*Task, error) { return s.repo.List(ctx) }
func (s *TaskService) ListAssignees(ctx context.Context) ([]Assignee, error) {
return s.repo.ListAssignees(ctx)
}
// ParseDueDate preserves calendar dates without a local time zone conversion.
func ParseDueDate(value string) (*time.Time, error) {
if value == "" {
return nil, nil
}
date, err := time.Parse("2006-01-02", value)
if err != nil || date.Year() < 1 {
return nil, ErrInvalidTask
}
return &date, nil
}
func validStatus(status string) bool {
return status == "new" || status == "in_progress" || status == "review" || status == "done"
}
func validate(task *Task) error {
task.Title = strings.TrimSpace(task.Title)
if task.Title == "" || strings.ContainsRune(task.Title, 0) || strings.ContainsRune(task.Description, 0) ||
utf8.RuneCountInString(task.Title) > 255 ||
utf8.RuneCountInString(task.Description) > 10000 || !validStatus(task.Status) ||
(task.AssigneeID != nil && *task.AssigneeID <= 0) {
return ErrInvalidTask
}
return nil
}
func (s *TaskService) Create(ctx context.Context, task Task) (*Task, error) {
if task.Status == "" {
task.Status = "new"
}
if err := validate(&task); err != nil {
return nil, err
}
return s.repo.Create(ctx, task)
}
func (s *TaskService) Update(ctx context.Context, task Task) (*Task, error) {
if task.ID <= 0 || task.Version <= 0 {
return nil, ErrInvalidTask
}
if err := validate(&task); err != nil {
return nil, err
}
return s.repo.Update(ctx, task)
}
func (s *TaskService) SetStatus(ctx context.Context, id, version int, status string) (*Task, error) {
if id <= 0 || version <= 0 || !validStatus(status) {
return nil, ErrInvalidTask
}
return s.repo.SetStatus(ctx, id, version, status)
}
func (s *TaskService) Delete(ctx context.Context, id, version int) error {
if id <= 0 || version <= 0 {
return ErrInvalidTask
}
return s.repo.Delete(ctx, id, version)
}
@@ -0,0 +1,133 @@
package task_service
import (
"context"
"errors"
"strings"
"testing"
)
type fakeTasks struct {
task Task
err error
calls int
}
func (f *fakeTasks) List(context.Context) ([]*Task, error) { return []*Task{&f.task}, f.err }
func (f *fakeTasks) ListAssignees(context.Context) ([]Assignee, error) { return nil, f.err }
func (f *fakeTasks) Create(_ context.Context, task Task) (*Task, error) {
f.calls++
f.task = task
return &f.task, f.err
}
func (f *fakeTasks) Update(ctx context.Context, task Task) (*Task, error) { return f.Create(ctx, task) }
func (f *fakeTasks) SetStatus(_ context.Context, id, version int, status string) (*Task, error) {
f.calls++
f.task.ID, f.task.Version, f.task.Status = id, version, status
return &f.task, f.err
}
func (f *fakeTasks) Delete(context.Context, int, int) error { f.calls++; return f.err }
func TestTaskValidationAndErrors(t *testing.T) {
zero := 0
tests := []struct {
name string
task Task
repoErr error
wantErr error
wantCalls int
}{
{name: "default status and trimmed title", task: Task{Title: " Задача "}, wantCalls: 1},
{name: "unicode limit", task: Task{Title: strings.Repeat("Я", 255)}, wantCalls: 1},
{name: "NUL in title", task: Task{Title: "Task\x00"}, wantErr: ErrInvalidTask},
{name: "NUL in description", task: Task{Title: "Task", Description: "\x00"}, wantErr: ErrInvalidTask},
{name: "blank title", task: Task{Title: " "}, wantErr: ErrInvalidTask},
{name: "long title", task: Task{Title: strings.Repeat("Я", 256)}, wantErr: ErrInvalidTask},
{name: "long description", task: Task{Title: "Task", Description: strings.Repeat("Я", 10001)}, wantErr: ErrInvalidTask},
{name: "unknown status", task: Task{Title: "Task", Status: "cancelled"}, wantErr: ErrInvalidTask},
{name: "invalid assignee ID", task: Task{Title: "Task", AssigneeID: &zero}, wantErr: ErrInvalidTask},
{name: "ineligible assignee", task: Task{Title: "Task"}, repoErr: ErrInvalidAssignee, wantErr: ErrInvalidAssignee, wantCalls: 1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
repo := &fakeTasks{err: tc.repoErr}
_, err := NewTaskService(repo).Create(context.Background(), tc.task)
if !errors.Is(err, tc.wantErr) || repo.calls != tc.wantCalls {
t.Fatalf("error=%v calls=%d", err, repo.calls)
}
if err == nil && (repo.task.Title != strings.TrimSpace(tc.task.Title) || repo.task.Status != "new") {
t.Fatalf("saved task=%+v", repo.task)
}
})
}
}
func TestWritesValidateVersionAndPropagateErrors(t *testing.T) {
for _, operation := range []string{"update", "status", "delete"} {
for _, tc := range []struct {
name string
id, version int
repoErr, wantErr error
}{
{"valid", 1, 1, nil, nil},
{"invalid ID", 0, 1, nil, ErrInvalidTask},
{"invalid version", 1, 0, nil, ErrInvalidTask},
{"conflict", 1, 1, ErrConflict, ErrConflict},
{"missing", 1, 1, ErrNotFound, ErrNotFound},
} {
t.Run(operation+"/"+tc.name, func(t *testing.T) {
repo := &fakeTasks{err: tc.repoErr}
service := NewTaskService(repo)
var err error
switch operation {
case "update":
_, err = service.Update(context.Background(), Task{ID: tc.id, Version: tc.version, Title: "Task", Status: "new"})
case "status":
_, err = service.SetStatus(context.Background(), tc.id, tc.version, "review")
case "delete":
err = service.Delete(context.Background(), tc.id, tc.version)
}
if !errors.Is(err, tc.wantErr) {
t.Fatalf("error=%v want=%v", err, tc.wantErr)
}
})
}
}
}
func TestStatusTransitionsAllowAllFourStates(t *testing.T) {
for _, status := range []string{"new", "in_progress", "review", "done", "unknown"} {
t.Run(status, func(t *testing.T) {
repo := &fakeTasks{}
_, err := NewTaskService(repo).SetStatus(context.Background(), 1, 1, status)
if status == "unknown" {
if !errors.Is(err, ErrInvalidTask) || repo.calls != 0 {
t.Fatalf("error=%v calls=%d", err, repo.calls)
}
} else if err != nil || repo.task.Status != status {
t.Fatalf("error=%v task=%+v", err, repo.task)
}
})
}
}
func TestDueDatesRemainCalendarDates(t *testing.T) {
for _, tc := range []struct {
value string
valid bool
}{
{"", true}, {"2026-10-07", true}, {"2024-02-29", true}, {"2026-02-29", false},
{"2026-10-07T00:00:00Z", false}, {"2026-1-2", false}, {"0000-01-01", false},
} {
t.Run(tc.value, func(t *testing.T) {
date, err := ParseDueDate(tc.value)
if tc.valid {
if err != nil || (tc.value != "" && date.Format("2006-01-02") != tc.value) || (tc.value == "" && date != nil) {
t.Fatalf("date=%v err=%v", date, err)
}
} else if !errors.Is(err, ErrInvalidTask) {
t.Fatalf("error=%v", err)
}
})
}
}