293 lines
11 KiB
Go
293 lines
11 KiB
Go
package repository
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"testing"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"building-mgmt/internal/models"
|
||
)
|
||
|
||
// photoMeta — каркас метаданных для теста: каждый кейс меняет
|
||
// только то, что проверяет.
|
||
func photoMeta(ticketID int64, uploaderID string, n int, maxPer int) PhotoMeta {
|
||
return PhotoMeta{
|
||
TicketID: ticketID,
|
||
UploaderID: uploaderID,
|
||
ObjectKey: fmt.Sprintf("tickets/%d/%s.jpg", ticketID, uuid.NewString()),
|
||
ContentType: "image/jpeg",
|
||
SizeBytes: int64(100 + n),
|
||
OriginalName: fmt.Sprintf("photo-%d.jpg", n),
|
||
MaxPerTicket: maxPer,
|
||
}
|
||
}
|
||
|
||
// TestPhotoReportLifecycle — сквозной сценарий фотоотчёта: запрет до
|
||
// старта работы, загрузка мастером, лимит, журнал, удаление и финальная
|
||
// стадия заявки. Все правила — SQL, поэтому проверяем именно их.
|
||
func TestPhotoReportLifecycle(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
tickets := NewTicketRepo(q)
|
||
photos := NewPhotoRepo(q)
|
||
|
||
client := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
otherWorker := createProfile(t, ctx, q, "worker")
|
||
admin := createProfile(t, ctx, q, "admin")
|
||
|
||
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||
joinProject(t, ctx, NewProjectRepo(q), projectID, worker.ID, admin.ID)
|
||
|
||
ticket := &models.Ticket{
|
||
ClientID: client.ID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "electric",
|
||
Description: "не горит лампочка",
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, ticket); err != nil {
|
||
t.Fatalf("create ticket: %v", err)
|
||
}
|
||
|
||
// --- 1. Заявка в new: загрузка запрещена даже админу ---
|
||
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, admin.ID, 0, 5), "admin"); !errors.Is(err, ErrConflict) {
|
||
t.Errorf("upload in status new: err = %v, want ErrConflict", err)
|
||
}
|
||
|
||
if err := tickets.Assign(ctx, ticket.ID, worker.ID, client.ID); err != nil {
|
||
t.Fatalf("assign: %v", err)
|
||
}
|
||
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
|
||
// --- 2. Несуществующая заявка — 404, а не конфликт ---
|
||
if _, err := photos.AddPhoto(ctx, photoMeta(999999999, worker.ID, 0, 5), "worker"); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("upload to missing ticket: err = %v, want ErrNotFound", err)
|
||
}
|
||
|
||
// --- 3. Мастер проекта загружает в работу: строка + событие ---
|
||
m1 := photoMeta(ticket.ID, worker.ID, 1, 3)
|
||
p1, err := photos.AddPhoto(ctx, m1, "worker")
|
||
if err != nil {
|
||
t.Fatalf("upload by worker: %v", err)
|
||
}
|
||
if p1.ID == 0 || p1.TicketID != ticket.ID {
|
||
t.Errorf("photo = %+v, want id>0 and ticket %d", p1, ticket.ID)
|
||
}
|
||
if p1.ObjectKey != m1.ObjectKey || p1.ContentType != "image/jpeg" {
|
||
t.Errorf("photo meta mismatch: %+v vs %+v", p1, m1)
|
||
}
|
||
if p1.UploaderID != worker.ID || p1.UploaderName != worker.Name {
|
||
t.Errorf("uploader = %q/%q, want %q/%q", p1.UploaderID, p1.UploaderName, worker.ID, worker.Name)
|
||
}
|
||
|
||
events, _, err := tickets.ListEvents(ctx, ticket.ID, Page{})
|
||
if err != nil {
|
||
t.Fatalf("list events: %v", err)
|
||
}
|
||
var added *models.TicketEvent
|
||
for i := range events {
|
||
if events[i].Action == "photo_added" {
|
||
added = &events[i]
|
||
}
|
||
}
|
||
if added == nil {
|
||
t.Fatal("photo_added event not found")
|
||
}
|
||
if added.ActorID != worker.ID || added.FromStatus != "in_progress" || added.ToStatus != "in_progress" {
|
||
t.Errorf("event = %+v, want actor=%s in_progress→in_progress", added, worker.ID)
|
||
}
|
||
if added.Comment != m1.OriginalName {
|
||
t.Errorf("event comment = %q, want %q", added.Comment, m1.OriginalName)
|
||
}
|
||
|
||
// --- 4. Чужой мастер (не в проекте заявки) — конфликт ---
|
||
mo := photoMeta(ticket.ID, otherWorker.ID, 90, 3)
|
||
if _, err := photos.AddPhoto(ctx, mo, "worker"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
|
||
t.Errorf("upload by stranger: err = %v, want plain ErrConflict", err)
|
||
}
|
||
|
||
// --- 5. Лимит файлов: до потолка — ок, дальше — ErrTooManyPhotos ---
|
||
m2 := photoMeta(ticket.ID, worker.ID, 2, 3)
|
||
p2, err := photos.AddPhoto(ctx, m2, "worker")
|
||
if err != nil {
|
||
t.Fatalf("second upload: %v", err)
|
||
}
|
||
ma := photoMeta(ticket.ID, admin.ID, 3, 3)
|
||
pa, err := photos.AddPhoto(ctx, ma, "admin")
|
||
if err != nil {
|
||
t.Fatalf("admin upload: %v", err)
|
||
}
|
||
// Четвёртый упирается в лимит: и у мастера, и у админа.
|
||
mw := photoMeta(ticket.ID, worker.ID, 4, 3)
|
||
if _, err := photos.AddPhoto(ctx, mw, "worker"); !errors.Is(err, ErrTooManyPhotos) {
|
||
t.Errorf("upload over limit: err = %v, want ErrTooManyPhotos", err)
|
||
}
|
||
ml := photoMeta(ticket.ID, admin.ID, 5, 3)
|
||
if _, err := photos.AddPhoto(ctx, ml, "admin"); !errors.Is(err, ErrTooManyPhotos) {
|
||
t.Errorf("admin upload over limit: err = %v, want ErrTooManyPhotos", err)
|
||
}
|
||
|
||
if n, err := photos.CountPhotos(ctx, ticket.ID); err != nil || n != 3 {
|
||
t.Errorf("count = %d, %v; want 3", n, err)
|
||
}
|
||
|
||
// --- 6. Список: хронология и имена ---
|
||
list, total, err := photos.ListPhotos(ctx, ticket.ID, Page{})
|
||
if err != nil {
|
||
t.Fatalf("list photos: %v", err)
|
||
}
|
||
if total != 3 || len(list) != 3 {
|
||
t.Fatalf("list = %d photos, total %d; want 3/3", len(list), total)
|
||
}
|
||
for i := 1; i < len(list); i++ {
|
||
if list[i-1].ID >= list[i].ID {
|
||
t.Errorf("photos not in id ASC order: %d then %d", list[i-1].ID, list[i].ID)
|
||
}
|
||
}
|
||
if list[0].UploaderName == "" {
|
||
t.Error("uploader name is empty")
|
||
}
|
||
|
||
// --- 7. Удаление: своё — можно, повторно — 404, чужая заявка — 404 ---
|
||
key, err := photos.DeletePhoto(ctx, ticket.ID, p1.ID, worker.ID, "worker")
|
||
if err != nil {
|
||
t.Fatalf("delete own photo: %v", err)
|
||
}
|
||
if key != m1.ObjectKey {
|
||
t.Errorf("deleted key = %q, want %q", key, m1.ObjectKey)
|
||
}
|
||
if _, err := photos.DeletePhoto(ctx, ticket.ID, p1.ID, worker.ID, "worker"); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("second delete: err = %v, want ErrNotFound", err)
|
||
}
|
||
if _, err := photos.DeletePhoto(ctx, 999999999, pa.ID, admin.ID, "admin"); !errors.Is(err, ErrNotFound) {
|
||
t.Errorf("cross-ticket delete: err = %v, want ErrNotFound", err)
|
||
}
|
||
// Чужое фото (мастер чужое не удаляет): осталось фото админа.
|
||
if _, err := photos.DeletePhoto(ctx, ticket.ID, pa.ID, worker.ID, "worker"); !errors.Is(err, ErrPhotoNotRemovable) {
|
||
t.Errorf("delete admin photo by worker: err = %v, want ErrPhotoNotRemovable", err)
|
||
}
|
||
|
||
// --- 8. Работа выполнена (done): отчёт заморожен ---
|
||
if err := tickets.Done(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Fatalf("done: %v", err)
|
||
}
|
||
// Ни мастер, ни админ больше не загружают: лимит тут не причина
|
||
// (maxPer 10 > 3 уже загруженных) — именно статус решает.
|
||
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, worker.ID, 6, 10), "worker"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
|
||
t.Errorf("worker upload in done: err = %v, want plain ErrConflict", err)
|
||
}
|
||
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, admin.ID, 7, 10), "admin"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
|
||
t.Errorf("admin upload in done: err = %v, want plain ErrConflict", err)
|
||
}
|
||
// Мастер в done не удаляет даже своё — админ может (роль выше статуса).
|
||
if _, err := photos.DeletePhoto(ctx, ticket.ID, p2.ID, worker.ID, "worker"); !errors.Is(err, ErrPhotoNotRemovable) {
|
||
t.Errorf("worker delete own photo in done: err = %v, want ErrPhotoNotRemovable", err)
|
||
}
|
||
if _, err := photos.DeletePhoto(ctx, ticket.ID, pa.ID, admin.ID, "admin"); err != nil {
|
||
t.Errorf("admin delete in done: %v", err)
|
||
}
|
||
|
||
// --- 9. Заявка закрыта: мастеру нельзя, админу можно ---
|
||
if err := tickets.Confirm(ctx, ticket.ID, client.ID); err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
if _, err := photos.DeletePhoto(ctx, ticket.ID, p2.ID, worker.ID, "worker"); !errors.Is(err, ErrPhotoNotRemovable) {
|
||
t.Errorf("delete on closed ticket: err = %v, want ErrPhotoNotRemovable", err)
|
||
}
|
||
closedKey, err := photos.DeletePhoto(ctx, ticket.ID, p2.ID, admin.ID, "admin")
|
||
if err != nil {
|
||
t.Fatalf("admin delete on closed ticket: %v", err)
|
||
}
|
||
if closedKey == "" {
|
||
t.Error("admin delete returned empty key")
|
||
}
|
||
|
||
// Журнал: у фото статус не менялся — from = to у каждого
|
||
// photo_added/photo_removed, на какой бы статус ни попало событие.
|
||
events, _, err = tickets.ListEvents(ctx, ticket.ID, Page{})
|
||
if err != nil {
|
||
t.Fatalf("list events: %v", err)
|
||
}
|
||
addedN, removedN := 0, 0
|
||
for i := range events {
|
||
switch events[i].Action {
|
||
case "photo_added":
|
||
addedN++
|
||
case "photo_removed":
|
||
removedN++
|
||
default:
|
||
continue
|
||
}
|
||
if events[i].FromStatus != events[i].ToStatus {
|
||
t.Errorf("%s status = %q→%q, want equal",
|
||
events[i].Action, events[i].FromStatus, events[i].ToStatus)
|
||
}
|
||
}
|
||
if addedN != 3 {
|
||
t.Errorf("photo_added events = %d, want 3", addedN)
|
||
}
|
||
if removedN != 3 {
|
||
t.Errorf("photo_removed events = %d, want 3", removedN)
|
||
}
|
||
}
|
||
|
||
// TestPhotoReportAfterReject — отклонённая заявка (rejected) фото не
|
||
// принимает, но повторный старт открывает отчёт снова: заморозка
|
||
// действует ровно в статусах вне in_progress.
|
||
func TestPhotoReportAfterReject(t *testing.T) {
|
||
q, ctx := newTestQuerier(t)
|
||
tickets := NewTicketRepo(q)
|
||
photos := NewPhotoRepo(q)
|
||
|
||
client := createProfile(t, ctx, q, "client")
|
||
worker := createProfile(t, ctx, q, "worker")
|
||
admin := createProfile(t, ctx, q, "admin")
|
||
|
||
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||
joinProject(t, ctx, NewProjectRepo(q), projectID, worker.ID, admin.ID)
|
||
|
||
ticket := &models.Ticket{
|
||
ClientID: client.ID,
|
||
ProjectID: projectID,
|
||
ObjectID: objectID,
|
||
Address: address,
|
||
Category: "electric",
|
||
Description: "течёт кран",
|
||
Priority: "normal",
|
||
}
|
||
if err := tickets.Create(ctx, ticket); err != nil {
|
||
t.Fatalf("create ticket: %v", err)
|
||
}
|
||
if err := tickets.Assign(ctx, ticket.ID, worker.ID, client.ID); err != nil {
|
||
t.Fatalf("assign: %v", err)
|
||
}
|
||
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
if err := tickets.Done(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Fatalf("done: %v", err)
|
||
}
|
||
if err := tickets.Reject(ctx, ticket.ID, client.ID, "не тот цвет"); err != nil {
|
||
t.Fatalf("reject: %v", err)
|
||
}
|
||
|
||
// В rejected загрузка закрыта (maxPer 10 — причина именно статус).
|
||
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, worker.ID, 1, 10), "worker"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
|
||
t.Errorf("upload in rejected: err = %v, want plain ErrConflict", err)
|
||
}
|
||
|
||
// Повторный старт (rejected → in_progress) открывает отчёт.
|
||
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
|
||
t.Fatalf("restart: %v", err)
|
||
}
|
||
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, worker.ID, 2, 10), "worker"); err != nil {
|
||
t.Errorf("upload after rework start: %v", err)
|
||
}
|
||
}
|