generated from VLADIMIR/template
up
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ var (
|
||||
"users_page",
|
||||
"reviews_page",
|
||||
},
|
||||
rolesModule.Developer: {
|
||||
"tasks_page",
|
||||
},
|
||||
rolesModule.Author: {
|
||||
"scenarios_page",
|
||||
"scenario_editor_page",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package ui_service
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTasksPermission(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
role string
|
||||
allowed bool
|
||||
}{
|
||||
{"developer", true}, {"admin", true}, {"user", false}, {"author", false}, {"organizer", false},
|
||||
} {
|
||||
t.Run(tc.role, func(t *testing.T) {
|
||||
got := NewUIService().GetPermissions([]string{tc.role})
|
||||
if slices.Contains(got, "tasks_page") != tc.allowed {
|
||||
t.Fatalf("permissions=%v", got)
|
||||
}
|
||||
if tc.role == "developer" && slices.Contains(got, "users_page") {
|
||||
t.Fatal("developer has administrative permission")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package users_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeveloperRoleCanBeAssignedAndRemoved(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
role string
|
||||
wantErr error
|
||||
}{
|
||||
{"developer", nil}, {"invalid", ErrInvalidRole},
|
||||
} {
|
||||
t.Run(tc.role, func(t *testing.T) {
|
||||
service := NewUsersService(&fakeRepo{}, nil, nil, nil, nil, "")
|
||||
for _, change := range []func(context.Context, int, string) error{service.AddUserRole, service.DeleteUserRole} {
|
||||
if err := change(context.Background(), 1, tc.role); !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user