generated from VLADIMIR/template
up
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)) })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user