first commit

This commit is contained in:
2026-06-22 14:31:01 +05:00
commit 109e01a656
35 changed files with 2120 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
package app
import (
"context"
"database/sql"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9"
"secunda-test/internal/cache"
"secunda-test/internal/config"
"secunda-test/internal/email"
"secunda-test/internal/handler"
"secunda-test/internal/httpx"
mysqlrepo "secunda-test/internal/repository/mysql"
"secunda-test/internal/service"
)
type Container struct {
Config config.Config
DB *sql.DB
Redis *redis.Client
Server *http.Server
}
func New(ctx context.Context) (*Container, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}
db, err := sql.Open("mysql", cfg.Database.DSN)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.Database.MaxOpenConns)
db.SetMaxIdleConns(cfg.Database.MaxIdleConns)
db.SetConnMaxLifetime(time.Hour)
if err := db.PingContext(ctx); err != nil {
return nil, err
}
redisClient := redis.NewClient(&redis.Options{Addr: cfg.Redis.Addr})
requests := prometheus.NewCounterVec(prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests."}, []string{"method", "path", "status"})
duration := prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "http_request_duration_seconds", Help: "HTTP request duration."}, []string{"method", "path"})
prometheus.MustRegister(requests, duration)
users := mysqlrepo.NewUserRepository(db)
teams := mysqlrepo.NewTeamRepository(db)
tasks := mysqlrepo.NewTaskRepository(db)
taskCache := cache.NewTaskCache(redisClient, time.Duration(cfg.Redis.TTLSeconds)*time.Second)
emailSender := email.NewSender(cfg.Email.Endpoint)
authService := service.NewAuthService(users, cfg.JWT.Secret, time.Duration(cfg.JWT.TTLMinutes)*time.Minute)
teamService := service.NewTeamService(teams, users, emailSender)
taskService := service.NewTaskService(tasks, teams, taskCache)
middleware := httpx.NewMiddleware(cfg.JWT.Secret, cfg.RateLimit.RequestsPerMinute, requests, duration)
h := handler.New(authService, teamService, taskService, middleware)
root := http.NewServeMux()
root.Handle("/", h.Routes())
root.Handle("/metrics", promhttp.Handler())
return &Container{
Config: cfg,
DB: db,
Redis: redisClient,
Server: &http.Server{Addr: cfg.HTTP.Addr, Handler: root, ReadHeaderTimeout: 5 * time.Second},
}, nil
}
func (c *Container) Migrate(ctx context.Context) error {
files, err := filepath.Glob("migrations/*.sql")
if err != nil {
return err
}
if len(files) == 0 {
files, err = filepath.Glob("/app/migrations/*.sql")
if err != nil {
return err
}
}
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
return err
}
for _, stmt := range strings.Split(string(data), ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := c.DB.ExecContext(ctx, stmt); err != nil {
return err
}
}
}
return nil
}
func (c *Container) Close() error {
var errs []error
if c.Redis != nil {
errs = append(errs, c.Redis.Close())
}
if c.DB != nil {
errs = append(errs, c.DB.Close())
}
return errors.Join(errs...)
}
+60
View File
@@ -0,0 +1,60 @@
package cache
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"secunda-test/internal/domain"
)
type TaskCache struct {
client *redis.Client
ttl time.Duration
}
func NewTaskCache(client *redis.Client, ttl time.Duration) *TaskCache {
return &TaskCache{client: client, ttl: ttl}
}
func (c *TaskCache) GetTasks(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, bool, error) {
data, err := c.client.Get(ctx, c.key(filter)).Bytes()
if err == redis.Nil {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
var tasks []domain.Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, false, err
}
return tasks, true, nil
}
func (c *TaskCache) SetTasks(ctx context.Context, filter domain.TaskFilter, tasks []domain.Task) error {
data, err := json.Marshal(tasks)
if err != nil {
return err
}
return c.client.Set(ctx, c.key(filter), data, c.ttl).Err()
}
func (c *TaskCache) DeleteTeam(ctx context.Context, teamID int64) error {
iter := c.client.Scan(ctx, 0, fmt.Sprintf("tasks:team:%d:*", teamID), 100).Iterator()
for iter.Next(ctx) {
_ = c.client.Del(ctx, iter.Val()).Err()
}
return iter.Err()
}
func (c *TaskCache) key(filter domain.TaskFilter) string {
raw, _ := json.Marshal(filter)
sum := sha1.Sum(raw)
return fmt.Sprintf("tasks:team:%d:%s", filter.TeamID, hex.EncodeToString(sum[:]))
}
+74
View File
@@ -0,0 +1,74 @@
package config
import (
"os"
"strconv"
"gopkg.in/yaml.v3"
)
type Config struct {
HTTP struct {
Addr string `yaml:"addr"`
} `yaml:"http"`
Database struct {
DSN string `yaml:"dsn"`
MaxOpenConns int `yaml:"max_open_conns"`
MaxIdleConns int `yaml:"max_idle_conns"`
} `yaml:"database"`
Redis struct {
Addr string `yaml:"addr"`
TTLSeconds int `yaml:"ttl_seconds"`
} `yaml:"redis"`
JWT struct {
Secret string `yaml:"secret"`
TTLMinutes int `yaml:"ttl_minutes"`
} `yaml:"jwt"`
RateLimit struct {
RequestsPerMinute int `yaml:"requests_per_minute"`
} `yaml:"rate_limit"`
Email struct {
Endpoint string `yaml:"endpoint"`
} `yaml:"email"`
}
func Load() (Config, error) {
path := getenv("APP_CONFIG", "config.yaml")
data, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
override(&cfg)
return cfg, nil
}
func override(cfg *Config) {
if v := os.Getenv("HTTP_ADDR"); v != "" {
cfg.HTTP.Addr = v
}
if v := os.Getenv("DB_DSN"); v != "" {
cfg.Database.DSN = v
}
if v := os.Getenv("REDIS_ADDR"); v != "" {
cfg.Redis.Addr = v
}
if v := os.Getenv("JWT_SECRET"); v != "" {
cfg.JWT.Secret = v
}
if v := os.Getenv("RATE_LIMIT_RPM"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.RateLimit.RequestsPerMinute = n
}
}
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+81
View File
@@ -0,0 +1,81 @@
package domain
import "time"
type Role string
const (
RoleOwner Role = "owner"
RoleAdmin Role = "admin"
RoleMember Role = "member"
)
type TaskStatus string
const (
StatusTodo TaskStatus = "todo"
StatusInProgress TaskStatus = "in_progress"
StatusDone TaskStatus = "done"
)
type User struct {
ID int64
Email string
PasswordHash string
Name string
CreatedAt time.Time
}
type Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedBy int64 `json:"created_by"`
Role Role `json:"role,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Task struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
Title string `json:"title"`
Description string `json:"description"`
Status TaskStatus `json:"status"`
AssigneeID *int64 `json:"assignee_id,omitempty"`
CreatedBy int64 `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type TaskHistory struct {
ID int64 `json:"id"`
TaskID int64 `json:"task_id"`
ChangedBy int64 `json:"changed_by"`
FieldName string `json:"field_name"`
OldValue *string `json:"old_value,omitempty"`
NewValue *string `json:"new_value,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type TaskFilter struct {
TeamID int64
Status TaskStatus
AssigneeID *int64
Page int
PageSize int
}
type TeamSummary struct {
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name"`
MembersCount int64 `json:"members_count"`
DoneLast7Days int64 `json:"done_last_7_days"`
}
type TopCreator struct {
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name"`
UserID int64 `json:"user_id"`
UserName string `json:"user_name"`
TasksCreated int64 `json:"tasks_created"`
Rank int64 `json:"rank"`
}
+44
View File
@@ -0,0 +1,44 @@
package email
import (
"context"
"errors"
"time"
"github.com/sony/gobreaker"
)
type Sender struct {
endpoint string
breaker *gobreaker.CircuitBreaker
}
func NewSender(endpoint string) *Sender {
return &Sender{
endpoint: endpoint,
breaker: gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "email-service",
MaxRequests: 3,
Interval: time.Minute,
Timeout: 10 * time.Second,
}),
}
}
func (s *Sender) SendInvite(ctx context.Context, email, teamName string) error {
_, err := s.breaker.Execute(func() (any, error) {
if s.endpoint == "" {
return nil, errors.New("email endpoint is empty")
}
if s.endpoint == "mock://email" {
return nil, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(20 * time.Millisecond):
return nil, nil
}
})
return err
}
+177
View File
@@ -0,0 +1,177 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"secunda-test/internal/domain"
"secunda-test/internal/httpx"
"secunda-test/internal/service"
)
type Handler struct {
auth *service.AuthService
teams *service.TeamService
tasks *service.TaskService
mw *httpx.Middleware
}
func New(auth *service.AuthService, teams *service.TeamService, tasks *service.TaskService, mw *httpx.Middleware) *Handler {
return &Handler{auth: auth, teams: teams, tasks: tasks, mw: mw}
}
func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/register", h.register)
mux.HandleFunc("POST /api/v1/login", h.login)
mux.Handle("POST /api/v1/teams", h.mw.Auth(http.HandlerFunc(h.createTeam)))
mux.Handle("GET /api/v1/teams", h.mw.Auth(http.HandlerFunc(h.listTeams)))
mux.Handle("POST /api/v1/teams/", h.mw.Auth(http.HandlerFunc(h.teamSubroutes)))
mux.Handle("POST /api/v1/tasks", h.mw.Auth(http.HandlerFunc(h.createTask)))
mux.Handle("GET /api/v1/tasks", h.mw.Auth(http.HandlerFunc(h.listTasks)))
mux.Handle("PUT /api/v1/tasks/", h.mw.Auth(http.HandlerFunc(h.taskSubroutes)))
mux.Handle("GET /api/v1/tasks/", h.mw.Auth(http.HandlerFunc(h.taskSubroutes)))
mux.Handle("GET /api/v1/reports/team-summary", h.mw.Auth(http.HandlerFunc(h.teamSummary)))
mux.Handle("GET /api/v1/reports/top-creators", h.mw.Auth(http.HandlerFunc(h.topCreators)))
mux.Handle("GET /api/v1/reports/invalid-assignees", h.mw.Auth(http.HandlerFunc(h.invalidAssignees)))
return h.mw.Chain(mux)
}
func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
var req struct{ Email, Password, Name string }
if !decode(w, r, &req) {
return
}
id, err := h.auth.Register(r.Context(), req.Email, req.Password, req.Name)
if err != nil {
httpx.WriteError(w, httpx.StatusFromError(err), err.Error())
return
}
httpx.WriteJSON(w, http.StatusCreated, map[string]int64{"id": id})
}
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
var req struct{ Email, Password string }
if !decode(w, r, &req) {
return
}
token, err := h.auth.Login(r.Context(), req.Email, req.Password)
if err != nil {
httpx.WriteError(w, httpx.StatusFromError(err), err.Error())
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]string{"token": token})
}
func (h *Handler) createTeam(w http.ResponseWriter, r *http.Request) {
var req struct{ Name string }
if !decode(w, r, &req) {
return
}
team, err := h.teams.Create(r.Context(), mustUserID(r), req.Name)
respond(w, team, http.StatusCreated, err)
}
func (h *Handler) listTeams(w http.ResponseWriter, r *http.Request) {
teams, err := h.teams.List(r.Context(), mustUserID(r))
respond(w, teams, http.StatusOK, err)
}
func (h *Handler) teamSubroutes(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/teams/"), "/")
if len(parts) == 2 && parts[1] == "invite" {
teamID, _ := strconv.ParseInt(parts[0], 10, 64)
var req struct{ Email string }
if !decode(w, r, &req) {
return
}
err := h.teams.Invite(r.Context(), mustUserID(r), teamID, req.Email)
respond(w, map[string]string{"status": "invited"}, http.StatusOK, err)
return
}
http.NotFound(w, r)
}
func (h *Handler) createTask(w http.ResponseWriter, r *http.Request) {
var req domain.Task
if !decode(w, r, &req) {
return
}
task, err := h.tasks.Create(r.Context(), mustUserID(r), req)
respond(w, task, http.StatusCreated, err)
}
func (h *Handler) listTasks(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
teamID, _ := strconv.ParseInt(q.Get("team_id"), 10, 64)
page, _ := strconv.Atoi(q.Get("page"))
pageSize, _ := strconv.Atoi(q.Get("page_size"))
var assignee *int64
if q.Get("assignee_id") != "" {
v, _ := strconv.ParseInt(q.Get("assignee_id"), 10, 64)
assignee = &v
}
tasks, err := h.tasks.List(r.Context(), mustUserID(r), domain.TaskFilter{
TeamID: teamID, Status: domain.TaskStatus(q.Get("status")), AssigneeID: assignee, Page: page, PageSize: pageSize,
})
respond(w, tasks, http.StatusOK, err)
}
func (h *Handler) taskSubroutes(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/")
parts := strings.Split(path, "/")
id, _ := strconv.ParseInt(parts[0], 10, 64)
if len(parts) == 2 && parts[1] == "history" && r.Method == http.MethodGet {
history, err := h.tasks.History(r.Context(), mustUserID(r), id)
respond(w, history, http.StatusOK, err)
return
}
if len(parts) == 1 && r.Method == http.MethodPut {
var req domain.Task
if !decode(w, r, &req) {
return
}
task, err := h.tasks.Update(r.Context(), mustUserID(r), id, req)
respond(w, task, http.StatusOK, err)
return
}
http.NotFound(w, r)
}
func (h *Handler) teamSummary(w http.ResponseWriter, r *http.Request) {
out, err := h.tasks.TeamSummary(r.Context())
respond(w, out, http.StatusOK, err)
}
func (h *Handler) topCreators(w http.ResponseWriter, r *http.Request) {
out, err := h.tasks.TopCreators(r.Context())
respond(w, out, http.StatusOK, err)
}
func (h *Handler) invalidAssignees(w http.ResponseWriter, r *http.Request) {
out, err := h.tasks.InvalidAssignees(r.Context())
respond(w, out, http.StatusOK, err)
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid json")
return false
}
return true
}
func respond(w http.ResponseWriter, v any, status int, err error) {
if err != nil {
httpx.WriteError(w, httpx.StatusFromError(err), err.Error())
return
}
httpx.WriteJSON(w, status, v)
}
func mustUserID(r *http.Request) int64 {
id, _ := httpx.UserID(r.Context())
return id
}
+14
View File
@@ -0,0 +1,14 @@
package httpx
import "context"
type userIDKey struct{}
func WithUserID(ctx context.Context, userID int64) context.Context {
return context.WithValue(ctx, userIDKey{}, userID)
}
func UserID(ctx context.Context) (int64, bool) {
v, ok := ctx.Value(userIDKey{}).(int64)
return v, ok
}
+119
View File
@@ -0,0 +1,119 @@
package httpx
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/prometheus/client_golang/prometheus"
)
type Middleware struct {
jwtSecret []byte
limit int
mu sync.Mutex
buckets map[string]*bucket
requests *prometheus.CounterVec
duration *prometheus.HistogramVec
}
type bucket struct {
count int
reset time.Time
}
func NewMiddleware(jwtSecret string, rpm int, requests *prometheus.CounterVec, duration *prometheus.HistogramVec) *Middleware {
return &Middleware{jwtSecret: []byte(jwtSecret), limit: rpm, buckets: map[string]*bucket{}, requests: requests, duration: duration}
}
func (m *Middleware) Chain(next http.Handler) http.Handler {
return m.Metrics(m.RateLimit(next))
}
func (m *Middleware) Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
WriteError(w, http.StatusUnauthorized, "missing token")
return
}
token, err := jwt.Parse(strings.TrimPrefix(header, "Bearer "), func(t *jwt.Token) (any, error) {
return m.jwtSecret, nil
})
if err != nil || !token.Valid {
WriteError(w, http.StatusUnauthorized, "invalid token")
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
WriteError(w, http.StatusUnauthorized, "invalid claims")
return
}
sub, err := claims.GetSubject()
if err != nil {
if f, ok := claims["sub"].(float64); ok {
sub = strconv.FormatInt(int64(f), 10)
}
}
id, err := strconv.ParseInt(sub, 10, 64)
if err != nil {
WriteError(w, http.StatusUnauthorized, "invalid subject")
return
}
next.ServeHTTP(w, r.WithContext(WithUserID(r.Context(), id)))
})
}
func (m *Middleware) RateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.RemoteAddr
if id, ok := UserID(r.Context()); ok {
key = strconv.FormatInt(id, 10)
}
if !m.allow(key) {
WriteError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
next.ServeHTTP(w, r)
})
}
func (m *Middleware) Metrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(rec, r)
code := strconv.Itoa(rec.status)
m.requests.WithLabelValues(r.Method, r.URL.Path, code).Inc()
m.duration.WithLabelValues(r.Method, r.URL.Path).Observe(time.Since(start).Seconds())
})
}
func (m *Middleware) allow(key string) bool {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
b := m.buckets[key]
if b == nil || now.After(b.reset) {
m.buckets[key] = &bucket{count: 1, reset: now.Add(time.Minute)}
return true
}
if b.count >= m.limit {
return false
}
b.count++
return true
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
+36
View File
@@ -0,0 +1,36 @@
package httpx
import (
"encoding/json"
"errors"
"net/http"
"secunda-test/internal/service"
)
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func WriteError(w http.ResponseWriter, status int, message string) {
WriteJSON(w, status, map[string]string{"error": message})
}
func StatusFromError(err error) int {
switch {
case errors.Is(err, service.ErrUnauthorized):
return http.StatusUnauthorized
case errors.Is(err, service.ErrForbidden):
return http.StatusForbidden
case errors.Is(err, service.ErrNotFound):
return http.StatusNotFound
case errors.Is(err, service.ErrBadRequest):
return http.StatusBadRequest
case errors.Is(err, service.ErrConflict):
return http.StatusConflict
default:
return http.StatusInternalServerError
}
}
+220
View File
@@ -0,0 +1,220 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"strings"
"secunda-test/internal/domain"
"secunda-test/internal/service"
)
type TaskRepository struct{ db *sql.DB }
func NewTaskRepository(db *sql.DB) *TaskRepository { return &TaskRepository{db: db} }
func (r *TaskRepository) Create(ctx context.Context, task domain.Task) (domain.Task, error) {
res, err := r.db.ExecContext(ctx, `
INSERT INTO tasks(team_id,title,description,status,assignee_id,created_by)
VALUES(?,?,?,?,?,?)`,
task.TeamID, task.Title, task.Description, valueOrDefault(string(task.Status), string(domain.StatusTodo)), task.AssigneeID, task.CreatedBy)
if err != nil {
return domain.Task{}, err
}
id, _ := res.LastInsertId()
return r.Get(ctx, id)
}
func (r *TaskRepository) Get(ctx context.Context, id int64) (domain.Task, error) {
var t domain.Task
err := r.db.QueryRowContext(ctx, `
SELECT id,team_id,title,description,status,assignee_id,created_by,created_at,updated_at
FROM tasks WHERE id=?`, id).
Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt)
if err == sql.ErrNoRows {
return domain.Task{}, service.ErrNotFound
}
return t, err
}
func (r *TaskRepository) Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error) {
old, err := r.Get(ctx, task.ID)
if err != nil {
return domain.Task{}, err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.Task{}, err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `
UPDATE tasks SET title=?,description=?,status=?,assignee_id=? WHERE id=?`,
task.Title, task.Description, task.Status, task.AssigneeID, task.ID)
if err != nil {
return domain.Task{}, err
}
addHistory := func(field, oldValue, newValue string) error {
if oldValue == newValue {
return nil
}
_, err := tx.ExecContext(ctx, `INSERT INTO task_history(task_id,changed_by,field_name,old_value,new_value) VALUES(?,?,?,?,?)`,
task.ID, changedBy, field, oldValue, newValue)
return err
}
if err := addHistory("title", old.Title, task.Title); err != nil {
return domain.Task{}, err
}
if err := addHistory("description", old.Description, task.Description); err != nil {
return domain.Task{}, err
}
if err := addHistory("status", string(old.Status), string(task.Status)); err != nil {
return domain.Task{}, err
}
if err := addHistory("assignee_id", ptrString(old.AssigneeID), ptrString(task.AssigneeID)); err != nil {
return domain.Task{}, err
}
if err := tx.Commit(); err != nil {
return domain.Task{}, err
}
return r.Get(ctx, task.ID)
}
func (r *TaskRepository) List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error) {
args := []any{filter.TeamID}
where := []string{"team_id=?"}
if filter.Status != "" {
where = append(where, "status=?")
args = append(args, filter.Status)
}
if filter.AssigneeID != nil {
where = append(where, "assignee_id=?")
args = append(args, *filter.AssigneeID)
}
limit, offset := filter.PageSize, (filter.Page-1)*filter.PageSize
args = append(args, limit, offset)
query := fmt.Sprintf(`
SELECT id,team_id,title,description,status,assignee_id,created_by,created_at,updated_at
FROM tasks WHERE %s ORDER BY created_at DESC LIMIT ? OFFSET ?`, strings.Join(where, " AND "))
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.Task
for rows.Next() {
var t domain.Task
if err := rows.Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (r *TaskRepository) History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id,task_id,changed_by,field_name,old_value,new_value,created_at
FROM task_history WHERE task_id=? ORDER BY created_at DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.TaskHistory
for rows.Next() {
var h domain.TaskHistory
if err := rows.Scan(&h.ID, &h.TaskID, &h.ChangedBy, &h.FieldName, &h.OldValue, &h.NewValue, &h.CreatedAt); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
func (r *TaskRepository) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT t.id, t.name, COUNT(DISTINCT tm.user_id) AS members_count,
COUNT(DISTINCT CASE WHEN ta.status='done' AND ta.updated_at >= NOW() - INTERVAL 7 DAY THEN ta.id END) AS done_last_7_days
FROM teams t
LEFT JOIN team_members tm ON tm.team_id=t.id
LEFT JOIN tasks ta ON ta.team_id=t.id
GROUP BY t.id, t.name
ORDER BY t.name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.TeamSummary
for rows.Next() {
var s domain.TeamSummary
if err := rows.Scan(&s.TeamID, &s.TeamName, &s.MembersCount, &s.DoneLast7Days); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (r *TaskRepository) TopCreators(ctx context.Context) ([]domain.TopCreator, error) {
rows, err := r.db.QueryContext(ctx, `
WITH ranked AS (
SELECT t.team_id, te.name AS team_name, t.created_by AS user_id, u.name AS user_name,
COUNT(*) AS tasks_created,
DENSE_RANK() OVER (PARTITION BY t.team_id ORDER BY COUNT(*) DESC) AS user_rank
FROM tasks t
JOIN teams te ON te.id=t.team_id
JOIN users u ON u.id=t.created_by
WHERE t.created_at >= NOW() - INTERVAL 1 MONTH
GROUP BY t.team_id, te.name, t.created_by, u.name
)
SELECT team_id,team_name,user_id,user_name,tasks_created,user_rank
FROM ranked WHERE user_rank <= 3 ORDER BY team_id,user_rank`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.TopCreator
for rows.Next() {
var c domain.TopCreator
if err := rows.Scan(&c.TeamID, &c.TeamName, &c.UserID, &c.UserName, &c.TasksCreated, &c.Rank); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *TaskRepository) InvalidAssignees(ctx context.Context) ([]domain.Task, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT ta.id,ta.team_id,ta.title,ta.description,ta.status,ta.assignee_id,ta.created_by,ta.created_at,ta.updated_at
FROM tasks ta
LEFT JOIN team_members tm ON tm.team_id=ta.team_id AND tm.user_id=ta.assignee_id
WHERE ta.assignee_id IS NOT NULL AND tm.user_id IS NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.Task
for rows.Next() {
var t domain.Task
if err := rows.Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func valueOrDefault(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
func ptrString(v *int64) string {
if v == nil {
return ""
}
return fmt.Sprint(*v)
}
@@ -0,0 +1,82 @@
//go:build integration
package mysql
import (
"context"
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/testcontainers/testcontainers-go"
tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestTaskRepositoryReportsWithMySQL(t *testing.T) {
ctx := context.Background()
dsn := testDSN(ctx, t)
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
migrate(ctx, t, db)
repo := NewTaskRepository(db)
if _, err := repo.TeamSummary(context.Background()); err != nil {
t.Fatalf("team summary query failed: %v", err)
}
if _, err := repo.TopCreators(context.Background()); err != nil {
t.Fatalf("top creators query failed: %v", err)
}
if _, err := repo.InvalidAssignees(context.Background()); err != nil {
t.Fatalf("invalid assignees query failed: %v", err)
}
}
func testDSN(ctx context.Context, t *testing.T) string {
t.Helper()
if dsn := os.Getenv("TEST_MYSQL_DSN"); dsn != "" {
return dsn
}
container, err := tcmysql.Run(ctx,
"mysql:8.4",
tcmysql.WithDatabase("task_service_test"),
tcmysql.WithUsername("app"),
tcmysql.WithPassword("app"),
testcontainers.WithWaitStrategy(wait.ForListeningPort("3306/tcp").WithStartupTimeout(2*time.Minute)),
)
if err != nil {
t.Fatalf("start mysql container: %v", err)
}
t.Cleanup(func() {
_ = testcontainers.TerminateContainer(container)
})
dsn, err := container.ConnectionString(ctx, "parseTime=true", "multiStatements=true")
if err != nil {
t.Fatalf("mysql dsn: %v", err)
}
return dsn
}
func migrate(ctx context.Context, t *testing.T, db *sql.DB) {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "..", "..", "migrations", "001_init.sql"))
if err != nil {
t.Fatal(err)
}
for _, stmt := range strings.Split(string(data), ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := db.ExecContext(ctx, stmt); err != nil {
t.Fatalf("migration failed: %v\nsql: %s", err, stmt)
}
}
}
+62
View File
@@ -0,0 +1,62 @@
package mysql
import (
"context"
"database/sql"
"secunda-test/internal/domain"
)
type TeamRepository struct{ db *sql.DB }
func NewTeamRepository(db *sql.DB) *TeamRepository { return &TeamRepository{db: db} }
func (r *TeamRepository) Create(ctx context.Context, name string, createdBy int64) (domain.Team, error) {
res, err := r.db.ExecContext(ctx, `INSERT INTO teams(name,created_by) VALUES(?,?)`, name, createdBy)
if err != nil {
return domain.Team{}, err
}
id, _ := res.LastInsertId()
return domain.Team{ID: id, Name: name, CreatedBy: createdBy}, nil
}
func (r *TeamRepository) AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO team_members(user_id,team_id,role) VALUES(?,?,?)
ON DUPLICATE KEY UPDATE role=VALUES(role)`, userID, teamID, role)
return err
}
func (r *TeamRepository) ListByUser(ctx context.Context, userID int64) ([]domain.Team, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT t.id,t.name,t.created_by,tm.role,t.created_at
FROM teams t
JOIN team_members tm ON tm.team_id=t.id
WHERE tm.user_id=?
ORDER BY t.created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.Team
for rows.Next() {
var t domain.Team
if err := rows.Scan(&t.ID, &t.Name, &t.CreatedBy, &t.Role, &t.CreatedAt); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (r *TeamRepository) MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error) {
var role domain.Role
err := r.db.QueryRowContext(ctx, `SELECT role FROM team_members WHERE team_id=? AND user_id=?`, teamID, userID).Scan(&role)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, err
}
return role, true, nil
}
+39
View File
@@ -0,0 +1,39 @@
package mysql
import (
"context"
"database/sql"
"secunda-test/internal/domain"
"secunda-test/internal/service"
)
type UserRepository struct{ db *sql.DB }
func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} }
func (r *UserRepository) Create(ctx context.Context, email, passwordHash, name string) (domain.User, error) {
res, err := r.db.ExecContext(ctx, `INSERT INTO users(email,password_hash,name) VALUES(?,?,?)`, email, passwordHash, name)
if err != nil {
return domain.User{}, err
}
id, _ := res.LastInsertId()
return r.FindByID(ctx, id)
}
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (domain.User, error) {
return r.scan(r.db.QueryRowContext(ctx, `SELECT id,email,password_hash,name,created_at FROM users WHERE email=?`, email))
}
func (r *UserRepository) FindByID(ctx context.Context, id int64) (domain.User, error) {
return r.scan(r.db.QueryRowContext(ctx, `SELECT id,email,password_hash,name,created_at FROM users WHERE id=?`, id))
}
func (r *UserRepository) scan(row *sql.Row) (domain.User, error) {
var u domain.User
err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.CreatedAt)
if err == sql.ErrNoRows {
return domain.User{}, service.ErrNotFound
}
return u, err
}
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"context"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type AuthService struct {
users UserRepository
jwtSecret []byte
jwtTTL time.Duration
}
func NewAuthService(users UserRepository, jwtSecret string, jwtTTL time.Duration) *AuthService {
return &AuthService{users: users, jwtSecret: []byte(jwtSecret), jwtTTL: jwtTTL}
}
func (s *AuthService) Register(ctx context.Context, email, password, name string) (int64, error) {
if email == "" || password == "" || name == "" {
return 0, ErrBadRequest
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return 0, err
}
u, err := s.users.Create(ctx, email, string(hash), name)
if err != nil {
return 0, err
}
return u.ID, nil
}
func (s *AuthService) Login(ctx context.Context, email, password string) (string, error) {
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
return "", ErrUnauthorized
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
return "", ErrUnauthorized
}
claims := jwt.MapClaims{
"sub": u.ID,
"exp": time.Now().Add(s.jwtTTL).Unix(),
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
}
+41
View File
@@ -0,0 +1,41 @@
package service
import (
"context"
"secunda-test/internal/domain"
)
type UserRepository interface {
Create(ctx context.Context, email, passwordHash, name string) (domain.User, error)
FindByEmail(ctx context.Context, email string) (domain.User, error)
FindByID(ctx context.Context, id int64) (domain.User, error)
}
type TeamRepository interface {
Create(ctx context.Context, name string, createdBy int64) (domain.Team, error)
AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error
ListByUser(ctx context.Context, userID int64) ([]domain.Team, error)
MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error)
}
type TaskRepository interface {
Create(ctx context.Context, task domain.Task) (domain.Task, error)
Get(ctx context.Context, id int64) (domain.Task, error)
Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error)
List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error)
History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error)
TeamSummary(ctx context.Context) ([]domain.TeamSummary, error)
TopCreators(ctx context.Context) ([]domain.TopCreator, error)
InvalidAssignees(ctx context.Context) ([]domain.Task, error)
}
type TaskCache interface {
GetTasks(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, bool, error)
SetTasks(ctx context.Context, filter domain.TaskFilter, tasks []domain.Task) error
DeleteTeam(ctx context.Context, teamID int64) error
}
type EmailSender interface {
SendInvite(ctx context.Context, email, teamName string) error
}
+11
View File
@@ -0,0 +1,11 @@
package service
import "errors"
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrNotFound = errors.New("not found")
ErrBadRequest = errors.New("bad request")
ErrConflict = errors.New("conflict")
)
+127
View File
@@ -0,0 +1,127 @@
package service
import (
"context"
"secunda-test/internal/domain"
)
type TaskService struct {
tasks TaskRepository
teams TeamRepository
cache TaskCache
}
func NewTaskService(tasks TaskRepository, teams TeamRepository, cache TaskCache) *TaskService {
return &TaskService{tasks: tasks, teams: teams, cache: cache}
}
func (s *TaskService) Create(ctx context.Context, userID int64, task domain.Task) (domain.Task, error) {
if task.TeamID == 0 || task.Title == "" {
return domain.Task{}, ErrBadRequest
}
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, userID); err != nil {
return domain.Task{}, err
} else if !ok {
return domain.Task{}, ErrForbidden
}
if task.AssigneeID != nil {
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, *task.AssigneeID); err != nil {
return domain.Task{}, err
} else if !ok {
return domain.Task{}, ErrBadRequest
}
}
task.CreatedBy = userID
created, err := s.tasks.Create(ctx, task)
if err == nil {
_ = s.cache.DeleteTeam(ctx, task.TeamID)
}
return created, err
}
func (s *TaskService) List(ctx context.Context, userID int64, filter domain.TaskFilter) ([]domain.Task, error) {
if filter.TeamID == 0 {
return nil, ErrBadRequest
}
if _, ok, err := s.teams.MemberRole(ctx, filter.TeamID, userID); err != nil {
return nil, err
} else if !ok {
return nil, ErrForbidden
}
if filter.Page < 1 {
filter.Page = 1
}
if filter.PageSize <= 0 || filter.PageSize > 100 {
filter.PageSize = 20
}
if cached, ok, err := s.cache.GetTasks(ctx, filter); err == nil && ok {
return cached, nil
}
tasks, err := s.tasks.List(ctx, filter)
if err == nil {
_ = s.cache.SetTasks(ctx, filter, tasks)
}
return tasks, err
}
func (s *TaskService) Update(ctx context.Context, userID, taskID int64, patch domain.Task) (domain.Task, error) {
current, err := s.tasks.Get(ctx, taskID)
if err != nil {
return domain.Task{}, err
}
role, ok, err := s.teams.MemberRole(ctx, current.TeamID, userID)
if err != nil {
return domain.Task{}, err
}
if !ok || (role == domain.RoleMember && current.CreatedBy != userID && (current.AssigneeID == nil || *current.AssigneeID != userID)) {
return domain.Task{}, ErrForbidden
}
if patch.Title != "" {
current.Title = patch.Title
}
if patch.Description != "" {
current.Description = patch.Description
}
if patch.Status != "" {
current.Status = patch.Status
}
if patch.AssigneeID != nil {
if _, ok, err := s.teams.MemberRole(ctx, current.TeamID, *patch.AssigneeID); err != nil {
return domain.Task{}, err
} else if !ok {
return domain.Task{}, ErrBadRequest
}
current.AssigneeID = patch.AssigneeID
}
updated, err := s.tasks.Update(ctx, current, userID)
if err == nil {
_ = s.cache.DeleteTeam(ctx, current.TeamID)
}
return updated, err
}
func (s *TaskService) History(ctx context.Context, userID, taskID int64) ([]domain.TaskHistory, error) {
task, err := s.tasks.Get(ctx, taskID)
if err != nil {
return nil, err
}
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, userID); err != nil {
return nil, err
} else if !ok {
return nil, ErrForbidden
}
return s.tasks.History(ctx, taskID)
}
func (s *TaskService) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
return s.tasks.TeamSummary(ctx)
}
func (s *TaskService) TopCreators(ctx context.Context) ([]domain.TopCreator, error) {
return s.tasks.TopCreators(ctx)
}
func (s *TaskService) InvalidAssignees(ctx context.Context) ([]domain.Task, error) {
return s.tasks.InvalidAssignees(ctx)
}
+117
View File
@@ -0,0 +1,117 @@
package service
import (
"context"
"errors"
"testing"
"secunda-test/internal/domain"
)
func TestTaskServiceCreateRequiresTeamMembership(t *testing.T) {
svc := NewTaskService(&fakeTaskRepo{}, &fakeTeamRepo{members: map[int64]map[int64]domain.Role{}}, noopCache{})
_, err := svc.Create(context.Background(), 10, domain.Task{TeamID: 1, Title: "task"})
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected forbidden, got %v", err)
}
}
func TestTaskServiceRejectsAssigneeOutsideTeam(t *testing.T) {
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
svc := NewTaskService(&fakeTaskRepo{}, teams, noopCache{})
assigneeID := int64(20)
_, err := svc.Create(context.Background(), 10, domain.Task{TeamID: 1, Title: "task", AssigneeID: &assigneeID})
if !errors.Is(err, ErrBadRequest) {
t.Fatalf("expected bad request, got %v", err)
}
}
func TestTaskServiceMemberCanUpdateAssignedTask(t *testing.T) {
assigneeID := int64(10)
repo := &fakeTaskRepo{task: domain.Task{ID: 7, TeamID: 1, Title: "old", Status: domain.StatusTodo, AssigneeID: &assigneeID, CreatedBy: 99}}
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
svc := NewTaskService(repo, teams, noopCache{})
updated, err := svc.Update(context.Background(), 10, 7, domain.Task{Title: "new", Status: domain.StatusDone})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated.Title != "new" || updated.Status != domain.StatusDone {
t.Fatalf("unexpected task: %+v", updated)
}
}
type fakeTaskRepo struct {
task domain.Task
}
func (r *fakeTaskRepo) Create(ctx context.Context, task domain.Task) (domain.Task, error) {
task.ID = 1
r.task = task
return task, nil
}
func (r *fakeTaskRepo) Get(ctx context.Context, id int64) (domain.Task, error) {
if r.task.ID == 0 {
return domain.Task{}, ErrNotFound
}
return r.task, nil
}
func (r *fakeTaskRepo) Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error) {
r.task = task
return task, nil
}
func (r *fakeTaskRepo) List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error) {
return nil, nil
}
func (r *fakeTaskRepo) History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error) {
return nil, nil
}
func (r *fakeTaskRepo) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
return nil, nil
}
func (r *fakeTaskRepo) TopCreators(ctx context.Context) ([]domain.TopCreator, error) { return nil, nil }
func (r *fakeTaskRepo) InvalidAssignees(ctx context.Context) ([]domain.Task, error) { return nil, nil }
type fakeTeamRepo struct {
members map[int64]map[int64]domain.Role
}
func (r *fakeTeamRepo) Create(ctx context.Context, name string, createdBy int64) (domain.Team, error) {
return domain.Team{ID: 1, Name: name, CreatedBy: createdBy}, nil
}
func (r *fakeTeamRepo) AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error {
if r.members == nil {
r.members = map[int64]map[int64]domain.Role{}
}
if r.members[teamID] == nil {
r.members[teamID] = map[int64]domain.Role{}
}
r.members[teamID][userID] = role
return nil
}
func (r *fakeTeamRepo) ListByUser(ctx context.Context, userID int64) ([]domain.Team, error) {
return nil, nil
}
func (r *fakeTeamRepo) MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error) {
role, ok := r.members[teamID][userID]
return role, ok, nil
}
type noopCache struct{}
func (noopCache) GetTasks(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, bool, error) {
return nil, false, nil
}
func (noopCache) SetTasks(ctx context.Context, filter domain.TaskFilter, tasks []domain.Task) error {
return nil
}
func (noopCache) DeleteTeam(ctx context.Context, teamID int64) error { return nil }
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"context"
"secunda-test/internal/domain"
)
type TeamService struct {
teams TeamRepository
users UserRepository
email EmailSender
}
func NewTeamService(teams TeamRepository, users UserRepository, email EmailSender) *TeamService {
return &TeamService{teams: teams, users: users, email: email}
}
func (s *TeamService) Create(ctx context.Context, userID int64, name string) (domain.Team, error) {
if name == "" {
return domain.Team{}, ErrBadRequest
}
t, err := s.teams.Create(ctx, name, userID)
if err != nil {
return domain.Team{}, err
}
if err := s.teams.AddMember(ctx, t.ID, userID, domain.RoleOwner); err != nil {
return domain.Team{}, err
}
t.Role = domain.RoleOwner
return t, nil
}
func (s *TeamService) List(ctx context.Context, userID int64) ([]domain.Team, error) {
return s.teams.ListByUser(ctx, userID)
}
func (s *TeamService) Invite(ctx context.Context, actorID, teamID int64, email string) error {
role, ok, err := s.teams.MemberRole(ctx, teamID, actorID)
if err != nil {
return err
}
if !ok || (role != domain.RoleOwner && role != domain.RoleAdmin) {
return ErrForbidden
}
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
return ErrNotFound
}
if err := s.teams.AddMember(ctx, teamID, u.ID, domain.RoleMember); err != nil {
return err
}
return s.email.SendInvite(ctx, email, "")
}
+57
View File
@@ -0,0 +1,57 @@
package service
import (
"context"
"errors"
"testing"
"secunda-test/internal/domain"
)
func TestTeamServiceInviteRequiresOwnerOrAdmin(t *testing.T) {
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
users := &fakeUserRepo{user: domain.User{ID: 20, Email: "new@example.com"}}
svc := NewTeamService(teams, users, fakeEmail{})
err := svc.Invite(context.Background(), 10, 1, "new@example.com")
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected forbidden, got %v", err)
}
}
func TestTeamServiceInviteAddsMember(t *testing.T) {
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleAdmin}}}
users := &fakeUserRepo{user: domain.User{ID: 20, Email: "new@example.com"}}
svc := NewTeamService(teams, users, fakeEmail{})
if err := svc.Invite(context.Background(), 10, 1, "new@example.com"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if role, ok := teams.members[1][20]; !ok || role != domain.RoleMember {
t.Fatalf("expected invited member, got role=%q ok=%v", role, ok)
}
}
type fakeUserRepo struct {
user domain.User
}
func (r *fakeUserRepo) Create(ctx context.Context, email, passwordHash, name string) (domain.User, error) {
return domain.User{ID: 1, Email: email, PasswordHash: passwordHash, Name: name}, nil
}
func (r *fakeUserRepo) FindByEmail(ctx context.Context, email string) (domain.User, error) {
if r.user.Email == email {
return r.user, nil
}
return domain.User{}, ErrNotFound
}
func (r *fakeUserRepo) FindByID(ctx context.Context, id int64) (domain.User, error) {
if r.user.ID == id {
return r.user, nil
}
return domain.User{}, ErrNotFound
}
type fakeEmail struct{}
func (fakeEmail) SendInvite(ctx context.Context, email, teamName string) error { return nil }