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
+4
View File
@@ -12,6 +12,7 @@ import (
"evening_detective_server/internal/services/scenarios_service"
"evening_detective_server/internal/services/screen_plan_service"
"evening_detective_server/internal/services/screen_service"
"evening_detective_server/internal/services/task_service"
"evening_detective_server/internal/services/team_service"
"evening_detective_server/internal/services/ticket_service"
"evening_detective_server/internal/services/ui_service"
@@ -24,6 +25,7 @@ type server struct {
usersService *users_service.UsersService
uiService *ui_service.UIService
taskService *task_service.TaskService
fileService *file_service.FileService
scenarioService *scenarios_service.ScenarioService
gameService *game_service.GameService
@@ -56,8 +58,10 @@ func NewServer(
playlistService *playlist_service.Service,
screenPlanService *screen_plan_service.ScreenPlanService,
mapService *map_service.MapService,
taskService *task_service.TaskService,
) proto.EveningDetectiveServerServer {
return &server{
taskService: taskService,
usersService: usersService,
uiService: uiService,
fileService: fileService,
+107
View File
@@ -0,0 +1,107 @@
package app
import (
"context"
"evening_detective_server/internal/modules/roles"
proto "evening_detective_server/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func taskActor(ctx context.Context) (int, error) {
claims, err := claimsFromContext(ctx)
if err != nil {
return 0, err
}
if !roles.HasRole(claims, roles.Developer) {
return 0, status.Error(codes.PermissionDenied, "Нет доступа к задачам")
}
return claims.UserID, nil
}
func (s *server) GetTasks(ctx context.Context, _ *proto.GetTasksReq) (*proto.GetTasksRsp, error) {
if _, err := taskActor(ctx); err != nil {
return nil, err
}
tasks, err := s.taskService.List(ctx)
if err != nil {
return nil, taskErrorStatus(err)
}
result := &proto.GetTasksRsp{Tasks: make([]*proto.DeveloperTask, 0, len(tasks))}
for _, task := range tasks {
result.Tasks = append(result.Tasks, mapTask(task))
}
return result, nil
}
func (s *server) GetTaskAssignees(ctx context.Context, _ *proto.GetTaskAssigneesReq) (*proto.GetTaskAssigneesRsp, error) {
if _, err := taskActor(ctx); err != nil {
return nil, err
}
users, err := s.taskService.ListAssignees(ctx)
if err != nil {
return nil, taskErrorStatus(err)
}
result := &proto.GetTaskAssigneesRsp{Assignees: make([]*proto.TaskAssignee, 0, len(users))}
for _, user := range users {
result.Assignees = append(result.Assignees, mapTaskAssignee(user))
}
return result, nil
}
func (s *server) CreateTask(ctx context.Context, req *proto.CreateTaskReq) (*proto.TaskRsp, error) {
actorID, err := taskActor(ctx)
if err != nil {
return nil, err
}
task, err := taskInput(req.GetTitle(), req.GetDescription(), req.GetStatus(), req.AssigneeID, req.GetDueDate())
if err != nil {
return nil, taskErrorStatus(err)
}
task.AuthorID = &actorID
result, err := s.taskService.Create(ctx, task)
if err != nil {
return nil, taskErrorStatus(err)
}
return &proto.TaskRsp{Task: mapTask(result)}, nil
}
func (s *server) UpdateTask(ctx context.Context, req *proto.UpdateTaskReq) (*proto.TaskRsp, error) {
if _, err := taskActor(ctx); err != nil {
return nil, err
}
task, err := taskInput(req.GetTitle(), req.GetDescription(), req.GetStatus(), req.AssigneeID, req.GetDueDate())
if err != nil {
return nil, taskErrorStatus(err)
}
task.ID = int(req.GetID())
task.Version = int(req.GetVersion())
result, err := s.taskService.Update(ctx, task)
if err != nil {
return nil, taskErrorStatus(err)
}
return &proto.TaskRsp{Task: mapTask(result)}, nil
}
func (s *server) SetTaskStatus(ctx context.Context, req *proto.SetTaskStatusReq) (*proto.TaskRsp, error) {
if _, err := taskActor(ctx); err != nil {
return nil, err
}
result, err := s.taskService.SetStatus(ctx, int(req.GetID()), int(req.GetVersion()), req.GetStatus())
if err != nil {
return nil, taskErrorStatus(err)
}
return &proto.TaskRsp{Task: mapTask(result)}, nil
}
func (s *server) DeleteTask(ctx context.Context, req *proto.DeleteTaskReq) (*proto.DeleteTaskRsp, error) {
if _, err := taskActor(ctx); err != nil {
return nil, err
}
if err := s.taskService.Delete(ctx, int(req.GetID()), int(req.GetVersion())); err != nil {
return nil, taskErrorStatus(err)
}
return &proto.DeleteTaskRsp{}, nil
}
+27
View File
@@ -0,0 +1,27 @@
package app
import (
"errors"
"log"
"evening_detective_server/internal/services/task_service"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func taskErrorStatus(err error) error {
switch {
case errors.Is(err, task_service.ErrInvalidTask):
return status.Error(codes.InvalidArgument, task_service.ErrInvalidTask.Error())
case errors.Is(err, task_service.ErrInvalidAssignee):
return status.Error(codes.InvalidArgument, task_service.ErrInvalidAssignee.Error())
case errors.Is(err, task_service.ErrNotFound):
return status.Error(codes.NotFound, task_service.ErrNotFound.Error())
case errors.Is(err, task_service.ErrConflict):
return status.Error(codes.Aborted, task_service.ErrConflict.Error())
default:
log.Printf("task operation failed: %v", err)
return status.Error(codes.Internal, "Не удалось выполнить операцию с задачей")
}
}
+65
View File
@@ -0,0 +1,65 @@
package app
import (
"evening_detective_server/internal/services/task_service"
proto "evening_detective_server/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
func taskInput(title, description, status string, assigneeID *int32, dueDate string) (task_service.Task, error) {
date, err := task_service.ParseDueDate(dueDate)
if err != nil {
return task_service.Task{}, err
}
return task_service.Task{
Title: title,
Description: description,
Status: status,
AssigneeID: taskDomainID(assigneeID),
DueDate: date,
}, nil
}
func taskDomainID(value *int32) *int {
if value == nil {
return nil
}
result := int(*value)
return &result
}
func taskProtoID(value *int) *int32 {
if value == nil {
return nil
}
result := int32(*value)
return &result
}
func mapTask(task *task_service.Task) *proto.DeveloperTask {
dueDate := ""
if task.DueDate != nil {
dueDate = task.DueDate.Format("2006-01-02")
}
return &proto.DeveloperTask{
ID: int32(task.ID),
Title: task.Title,
Description: task.Description,
Status: task.Status,
AssigneeID: taskProtoID(task.AssigneeID),
AssigneeName: task.AssigneeName,
AuthorID: taskProtoID(task.AuthorID),
DueDate: dueDate,
CreatedAt: timestamppb.New(task.CreatedAt),
UpdatedAt: timestamppb.New(task.UpdatedAt),
Version: int32(task.Version),
}
}
func mapTaskAssignee(user task_service.Assignee) *proto.TaskAssignee {
return &proto.TaskAssignee{
ID: int32(user.ID),
Name: user.Name,
}
}
+106
View File
@@ -0,0 +1,106 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/services/task_service"
proto "evening_detective_server/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestTaskEndpointsRejectUnauthorizedRoles(t *testing.T) {
s := &server{}
operations := []struct {
name string
call func(context.Context) error
}{
{"list", func(ctx context.Context) error { _, err := s.GetTasks(ctx, &proto.GetTasksReq{}); return err }},
{"assignees", func(ctx context.Context) error {
_, err := s.GetTaskAssignees(ctx, &proto.GetTaskAssigneesReq{})
return err
}},
{"create", func(ctx context.Context) error { _, err := s.CreateTask(ctx, &proto.CreateTaskReq{}); return err }},
{"update", func(ctx context.Context) error { _, err := s.UpdateTask(ctx, &proto.UpdateTaskReq{}); return err }},
{"status", func(ctx context.Context) error { _, err := s.SetTaskStatus(ctx, &proto.SetTaskStatusReq{}); return err }},
{"delete", func(ctx context.Context) error { _, err := s.DeleteTask(ctx, &proto.DeleteTaskReq{}); return err }},
}
for _, operation := range operations {
for _, role := range []string{"", "user", "author", "organizer"} {
t.Run(operation.name+"/"+role, func(t *testing.T) {
ctx := context.Background()
want := codes.Unauthenticated
if role != "" {
ctx = processor_jwt.ContextWithClaims(ctx, &processor_jwt.JWTClaims{UserID: 1, Roles: []string{role}})
want = codes.PermissionDenied
}
if err := operation.call(ctx); status.Code(err) != want {
t.Fatalf("error=%v want=%v", err, want)
}
})
}
}
}
func TestTaskActorAllowsDeveloperAndAdmin(t *testing.T) {
for _, role := range []string{"developer", "admin"} {
t.Run(role, func(t *testing.T) {
ctx := processor_jwt.ContextWithClaims(context.Background(), &processor_jwt.JWTClaims{UserID: 42, Roles: []string{role}})
id, err := taskActor(ctx)
if id != 42 || err != nil {
t.Fatalf("id=%d error=%v", id, err)
}
})
}
}
func TestTaskErrorsUseSafeTransportCodes(t *testing.T) {
for _, tc := range []struct {
err error
code codes.Code
}{
{task_service.ErrInvalidTask, codes.InvalidArgument},
{task_service.ErrInvalidAssignee, codes.InvalidArgument},
{task_service.ErrNotFound, codes.NotFound},
{task_service.ErrConflict, codes.Aborted},
{errors.New("database-password-secret"), codes.Internal},
} {
t.Run(tc.err.Error(), func(t *testing.T) {
err := taskErrorStatus(fmt.Errorf("database-password-secret: %w", tc.err))
if status.Code(err) != tc.code || strings.Contains(err.Error(), "database-password-secret") {
t.Fatalf("unsafe or incorrect error=%v", err)
}
})
}
}
func TestTaskMappingPreservesOptionalFields(t *testing.T) {
id := 4
date := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
assignee *int
date *time.Time
wantDate string
}{
{"unset", nil, nil, ""}, {"set", &id, &date, "2026-10-07"},
} {
t.Run(tc.name, func(t *testing.T) {
task := mapTask(&task_service.Task{ID: 1, AssigneeID: tc.assignee, DueDate: tc.date, CreatedAt: date, UpdatedAt: date, Version: 2})
if task.DueDate != tc.wantDate || (task.AssigneeID == nil) != (tc.assignee == nil) || task.Version != 2 {
t.Fatalf("task=%+v", task)
}
input, err := taskInput("Task", "", "new", task.AssigneeID, task.DueDate)
if err != nil || (input.AssigneeID == nil) != (tc.assignee == nil) {
t.Fatalf("input=%+v error=%v", input, err)
}
})
}
}
+2
View File
@@ -3,6 +3,7 @@ package roles
const (
Admin = "admin"
Developer = "developer"
Author = "author"
Organizer = "organizer"
User = "user"
@@ -10,6 +11,7 @@ const (
var AllRoles = []string{
Admin,
Developer,
Author,
Organizer,
User,
+256
View File
@@ -0,0 +1,256 @@
// Package tasks_repo persists developer tasks.
package tasks_repo
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrInvalidTask = errors.New("некорректные поля задачи")
ErrNotFound = errors.New("задача не найдена")
ErrConflict = errors.New("задача изменена другим пользователем; обновите данные")
ErrInvalidAssignee = errors.New("ответственный должен иметь роль разработчика")
)
// Task is a persisted developer task.
type Task struct {
ID int
Title string
Description string
Status string
AssigneeID *int
AssigneeName string
AuthorID *int
DueDate *time.Time
CreatedAt time.Time
UpdatedAt time.Time
Version int
}
// Assignee contains only the public identity needed for task assignment.
type Assignee struct {
ID int
Name string
}
// TasksRepo persists the shared task board.
type TasksRepo struct{ pool *pgxpool.Pool }
func NewTasksRepo(pool *pgxpool.Pool) *TasksRepo { return &TasksRepo{pool: pool} }
const taskColumns = `
id,
title,
description,
status,
assignee_id,
author_id,
due_date,
created_at,
updated_at,
version,
COALESCE((
SELECT username
FROM users
WHERE users.id = developer_tasks.assignee_id
), '')`
func scanTask(row pgx.Row) (*Task, error) {
var task Task
err := row.Scan(&task.ID, &task.Title, &task.Description, &task.Status,
&task.AssigneeID, &task.AuthorID, &task.DueDate, &task.CreatedAt, &task.UpdatedAt, &task.Version, &task.AssigneeName)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &task, err
}
func (r *TasksRepo) Get(ctx context.Context, id int) (*Task, error) {
return scanTask(r.pool.QueryRow(
ctx,
`SELECT `+taskColumns+`
FROM developer_tasks
WHERE id = $1`,
id,
))
}
func (r *TasksRepo) List(ctx context.Context) ([]*Task, error) {
rows, err := r.pool.Query(
ctx,
`SELECT
t.id,
t.title,
t.description,
t.status,
t.assignee_id,
t.author_id,
t.due_date,
t.created_at,
t.updated_at,
t.version,
COALESCE(u.username, '')
FROM developer_tasks t
LEFT JOIN users u ON u.id = t.assignee_id
ORDER BY t.created_at DESC, t.id DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
tasks := make([]*Task, 0)
for rows.Next() {
var task Task
if err := rows.Scan(&task.ID, &task.Title, &task.Description, &task.Status,
&task.AssigneeID, &task.AuthorID, &task.DueDate, &task.CreatedAt,
&task.UpdatedAt, &task.Version, &task.AssigneeName); err != nil {
return nil, err
}
tasks = append(tasks, &task)
}
return tasks, rows.Err()
}
func (r *TasksRepo) ListAssignees(ctx context.Context) ([]Assignee, error) {
rows, err := r.pool.Query(
ctx,
`SELECT
id,
username
FROM users
WHERE roles @> '["developer"]'::jsonb
ORDER BY username, id`,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]Assignee, 0)
for rows.Next() {
var user Assignee
if err := rows.Scan(&user.ID, &user.Name); err != nil {
return nil, err
}
result = append(result, user)
}
return result, rows.Err()
}
func (r *TasksRepo) Create(ctx context.Context, task Task) (*Task, error) {
result, err := scanTask(r.pool.QueryRow(
ctx,
`INSERT INTO developer_tasks (title, description, status, assignee_id, author_id, due_date)
SELECT $1, $2, $3, $4::integer, $5::integer, $6::date
WHERE $4::integer IS NULL OR EXISTS (
SELECT 1
FROM users
WHERE id = $4 AND roles @> '["developer"]'::jsonb
)
RETURNING `+taskColumns,
task.Title,
task.Description,
task.Status,
task.AssigneeID,
task.AuthorID,
task.DueDate,
))
if errors.Is(err, ErrNotFound) {
return nil, ErrInvalidAssignee
}
return result, writeError(err)
}
func (r *TasksRepo) Update(ctx context.Context, task Task) (*Task, error) {
result, err := scanTask(r.pool.QueryRow(
ctx,
`UPDATE developer_tasks
SET title = $1, description = $2, status = $3,
assignee_id = $4, due_date = $5, updated_at = NOW(), version = version + 1
WHERE id = $6 AND version = $7 AND (
$4::integer IS NULL OR assignee_id = $4 OR EXISTS (
SELECT 1
FROM users
WHERE id = $4 AND roles @> '["developer"]'::jsonb
)
)
RETURNING `+taskColumns,
task.Title,
task.Description,
task.Status,
task.AssigneeID,
task.DueDate,
task.ID,
task.Version,
))
if errors.Is(err, ErrNotFound) {
current, getErr := r.Get(ctx, task.ID)
if getErr != nil {
return nil, getErr
}
if current.Version != task.Version {
return nil, ErrConflict
}
return nil, ErrInvalidAssignee
}
return result, writeError(err)
}
func (r *TasksRepo) SetStatus(ctx context.Context, id, version int, status string) (*Task, error) {
result, err := scanTask(r.pool.QueryRow(
ctx,
`UPDATE developer_tasks
SET status = $1, updated_at = NOW(), version = version + 1
WHERE id = $2 AND version = $3
RETURNING `+taskColumns,
status,
id,
version,
))
if errors.Is(err, ErrNotFound) {
return nil, r.missingWrite(ctx, id)
}
return result, err
}
func (r *TasksRepo) Delete(ctx context.Context, id, version int) error {
tag, err := r.pool.Exec(
ctx,
`DELETE FROM developer_tasks
WHERE id = $1 AND version = $2`,
id,
version,
)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return r.missingWrite(ctx, id)
}
return nil
}
func (r *TasksRepo) missingWrite(ctx context.Context, id int) error {
if _, err := r.Get(ctx, id); err != nil {
return err
}
return ErrConflict
}
func writeError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23503":
return ErrInvalidAssignee
case "23514", "22001":
return ErrInvalidTask
}
}
return err
}
+279
View File
@@ -0,0 +1,279 @@
package tasks_repo_test
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"evening_detective_server/internal/repos/tasks_repo"
"evening_detective_server/internal/repos/user_agreements_repo"
"evening_detective_server/internal/repos/users_repo"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type taskFixture struct {
repo *tasks_repo.TasksRepo
users *users_repo.UsersRepo
developerID int
otherID int
pool *pgxpool.Pool
}
// newTaskFixture prepares an isolated schema using the application's migrations.
// Scenarios below create and inspect data only through public repository methods.
func newTaskFixture(t *testing.T) taskFixture {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set TEST_DATABASE_URL to run PostgreSQL integration checks")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
schema := fmt.Sprintf("tasks_test_%d", time.Now().UnixNano())
quoted := pgx.Identifier{schema}.Sanitize()
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil {
admin.Close()
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := admin.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE"); err != nil {
t.Error(err)
}
admin.Close()
})
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
config.ConnConfig.RuntimeParams["search_path"] = schema
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
for _, migration := range []string{
"20260628175120_create_users_table.sql",
"20260704192907_refresh_tokens_table.sql",
"20260818231943_create_user_agreements_table.sql",
"20261007130000_create_developer_tasks.sql",
} {
applyTaskMigration(t, pool, migration, false)
}
users := users_repo.NewUserRepo(pool)
agreements := []user_agreements_repo.Agreement{{AgreementType: "terms", Version: "test"}}
developerID, err := users.AddUserWithAgreements(ctx, "developer", "dev@test.invalid", "test-hash", []string{"user"}, agreements)
if err != nil {
t.Fatal(err)
}
if err := users.AddUserRole(ctx, developerID, "developer"); err != nil {
t.Fatal(err)
}
otherID, err := users.AddUserWithAgreements(ctx, "other", "other@test.invalid", "test-hash", []string{"user"}, agreements)
if err != nil {
t.Fatal(err)
}
return taskFixture{repo: tasks_repo.NewTasksRepo(pool), users: users, developerID: developerID, otherID: otherID, pool: pool}
}
// applyTaskMigration is schema infrastructure, not a task data access path.
func applyTaskMigration(t *testing.T, pool *pgxpool.Pool, name string, down bool) {
t.Helper()
data, err := os.ReadFile("../../../migrations/" + name)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(string(data), "-- +goose Down")
sql := parts[0]
if down {
sql = parts[1]
}
if _, err := pool.Exec(context.Background(), sql); err != nil {
t.Fatal(err)
}
}
func (f taskFixture) create(t *testing.T) *tasks_repo.Task {
t.Helper()
date := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
task, err := f.repo.Create(context.Background(), tasks_repo.Task{
Title: "Task",
Description: "Original",
Status: "new",
AssigneeID: &f.developerID,
AuthorID: &f.developerID,
DueDate: &date,
})
if err != nil {
t.Fatal(err)
}
return task
}
func TestTaskPersistencePostgres(t *testing.T) {
tests := []struct {
name string
run func(*testing.T, taskFixture)
}{
{"fields and clearing", func(t *testing.T, f taskFixture) {
task := f.create(t)
if task.Version != 1 || task.AssigneeName != "developer" || task.DueDate.Format("2006-01-02") != "2026-10-07" {
t.Fatalf("created task=%+v", task)
}
task.Title, task.Description, task.Status = "Updated", "", "done"
task.AssigneeID, task.DueDate = nil, nil
updated, err := f.repo.Update(context.Background(), *task)
if err != nil {
t.Fatal(err)
}
if updated.Version != 2 || updated.AssigneeID != nil || updated.DueDate != nil || updated.AssigneeName != "" || updated.AuthorID == nil {
t.Fatalf("updated task=%+v", updated)
}
list, err := f.repo.List(context.Background())
if err != nil || len(list) != 1 || list[0].Title != "Updated" {
t.Fatalf("list=%v error=%v", list, err)
}
}},
{"assignment requires developer", func(t *testing.T, f taskFixture) {
candidates, err := f.repo.ListAssignees(context.Background())
if err != nil || len(candidates) != 1 || candidates[0].ID != f.developerID {
t.Fatalf("assignees=%v error=%v", candidates, err)
}
_, err = f.repo.Create(context.Background(), tasks_repo.Task{Title: "Invalid", Status: "new", AssigneeID: &f.otherID})
if !errors.Is(err, tasks_repo.ErrInvalidAssignee) {
t.Fatalf("error=%v", err)
}
task := f.create(t)
task.AssigneeID = &f.otherID
_, err = f.repo.Update(context.Background(), *task)
if !errors.Is(err, tasks_repo.ErrInvalidAssignee) {
t.Fatalf("error=%v", err)
}
}},
{"revoked role preserves current assignment", func(t *testing.T, f taskFixture) {
task := f.create(t)
if err := f.users.DeleteUserRole(context.Background(), f.developerID, "developer"); err != nil {
t.Fatal(err)
}
task.Title = "Retained assignment"
updated, err := f.repo.Update(context.Background(), *task)
if err != nil || updated.AssigneeID == nil || *updated.AssigneeID != f.developerID {
t.Fatalf("updated=%v error=%v", updated, err)
}
candidates, err := f.repo.ListAssignees(context.Background())
if err != nil || len(candidates) != 0 {
t.Fatalf("assignees=%v error=%v", candidates, err)
}
_, err = f.repo.Create(context.Background(), tasks_repo.Task{Title: "New assignment", Status: "new", AssigneeID: &f.developerID})
if !errors.Is(err, tasks_repo.ErrInvalidAssignee) {
t.Fatalf("error=%v", err)
}
}},
{"status writes protect other fields", func(t *testing.T, f taskFixture) {
task := f.create(t)
for _, state := range []string{"in_progress", "review", "done", "new"} {
updated, err := f.repo.SetStatus(context.Background(), task.ID, task.Version, state)
if err != nil {
t.Fatal(err)
}
if updated.Status != state || updated.Title != task.Title || updated.Description != task.Description ||
updated.Version != task.Version+1 || updated.AssigneeID == nil || updated.DueDate == nil {
t.Fatalf("updated=%+v", updated)
}
task = updated
}
}},
{"concurrent writes and stale deletion", func(t *testing.T, f taskFixture) {
task := f.create(t)
results := make(chan error, 4)
for range 4 {
go func() {
_, err := f.repo.SetStatus(context.Background(), task.ID, task.Version, "review")
results <- err
}()
}
successes := 0
for range 4 {
err := <-results
if err == nil {
successes++
} else if !errors.Is(err, tasks_repo.ErrConflict) {
t.Fatal(err)
}
}
if successes != 1 {
t.Fatalf("successes=%d", successes)
}
if err := f.repo.Delete(context.Background(), task.ID, task.Version); !errors.Is(err, tasks_repo.ErrConflict) {
t.Fatalf("error=%v", err)
}
_, err := f.repo.Update(context.Background(), *task)
if !errors.Is(err, tasks_repo.ErrConflict) {
t.Fatalf("error=%v", err)
}
current, err := f.repo.Get(context.Background(), task.ID)
if err != nil {
t.Fatal(err)
}
if err := f.repo.Delete(context.Background(), current.ID, current.Version); err != nil {
t.Fatal(err)
}
if _, err := f.repo.Get(context.Background(), task.ID); !errors.Is(err, tasks_repo.ErrNotFound) {
t.Fatalf("error=%v", err)
}
if err := f.repo.Delete(context.Background(), task.ID, current.Version); !errors.Is(err, tasks_repo.ErrNotFound) {
t.Fatalf("error=%v", err)
}
if _, err := f.repo.Update(context.Background(), *task); !errors.Is(err, tasks_repo.ErrNotFound) {
t.Fatalf("error=%v", err)
}
if _, err := f.repo.SetStatus(context.Background(), task.ID, current.Version, "new"); !errors.Is(err, tasks_repo.ErrNotFound) {
t.Fatalf("error=%v", err)
}
}},
{"user deletion retains tasks", func(t *testing.T, f taskFixture) {
task := f.create(t)
if err := f.users.DeleteUser(context.Background(), f.developerID); err != nil {
t.Fatal(err)
}
current, err := f.repo.Get(context.Background(), task.ID)
if err != nil || current.AssigneeID != nil || current.AuthorID != nil || current.Title != task.Title {
t.Fatalf("task=%v error=%v", current, err)
}
}},
{"database constraints", func(t *testing.T, f taskFixture) {
for _, task := range []tasks_repo.Task{
{Title: " ", Status: "new"}, {Title: strings.Repeat("Я", 256), Status: "new"},
{Title: "Task", Status: "invalid"}, {Title: "Task", Status: "new", Description: strings.Repeat("Я", 10001)},
} {
if _, err := f.repo.Create(context.Background(), task); !errors.Is(err, tasks_repo.ErrInvalidTask) {
t.Fatalf("error=%v", err)
}
}
}},
{"migration rollback and reapply", func(t *testing.T, f taskFixture) {
f.create(t)
const migration = "20261007130000_create_developer_tasks.sql"
applyTaskMigration(t, f.pool, migration, true)
if _, err := f.repo.List(context.Background()); err == nil {
t.Fatal("table still exists after Down")
}
applyTaskMigration(t, f.pool, migration, false)
tasks, err := f.repo.List(context.Background())
if err != nil || len(tasks) != 0 {
t.Fatalf("tasks=%v error=%v", tasks, err)
}
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { tc.run(t, newTaskFixture(t)) })
}
}
+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)
}
})
}
}
+3
View File
@@ -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)
}
}
})
}
}