diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 3ae7de26..45533994 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -127,6 +127,8 @@ const ( dailyChecklistCategoryEmptyKandang = "empty_kandang" dailyChecklistStatusRejected = "REJECTED" dailyChecklistStatusDraft = "DRAFT" + dailyChecklistErrEmptyKandangExist = "DailyChecklist cannot be created because empty_kandang already exists for at least one date in range" + dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist" ) func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate, documentSvc commonSvc.DocumentService) DailyChecklistService { @@ -538,10 +540,21 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) targetID := uint(0) err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil { + return err + } + if req.EmptyKandang { + if err := s.validateNoChecklistOverlapForEmptyKandang(tx, req.KandangId, date, endDate); err != nil { + return err + } return s.createBulkDailyChecklists(tx, req.KandangId, date, endDate, category, status, &targetID) } + if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, endDate); err != nil { + return err + } + return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID) }) if err != nil { @@ -552,6 +565,56 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) return s.GetOne(c, targetID) } +func (s *dailyChecklistService) lockKandangForChecklistCreation(tx *gorm.DB, kandangID uint) error { + if kandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id") + } + + var lockedKandangID uint + query := tx.Table("kandang_groups").Select("id").Where("id = ?", kandangID) + if tx.Dialector.Name() != "sqlite" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + + if err := query.Take(&lockedKandangID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Kandang not found") + } + return err + } + return nil +} + +func (s *dailyChecklistService) validateNoChecklistOverlapForEmptyKandang(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error { + var conflictCount int64 + if err := tx.Model(&entity.DailyChecklist{}). + Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", kandangID, startDate, endDate). + Count(&conflictCount).Error; err != nil { + return err + } + + if conflictCount > 0 { + return fiber.NewError(fiber.StatusConflict, dailyChecklistErrDateOverlapExist) + } + + return nil +} + +func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error { + var conflictCount int64 + if err := tx.Model(&entity.DailyChecklist{}). + Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL", kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang). + Count(&conflictCount).Error; err != nil { + return err + } + + if conflictCount > 0 { + return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangExist) + } + + return nil +} + func (s *dailyChecklistService) createOrReuseSingleDailyChecklist(tx *gorm.DB, kandangID uint, date time.Time, category, status string, targetID *uint) error { existing := new(entity.DailyChecklist) err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). diff --git a/internal/modules/daily-checklists/services/daily-checklist.service_test.go b/internal/modules/daily-checklists/services/daily-checklist.service_test.go new file mode 100644 index 00000000..4b3a16c2 --- /dev/null +++ b/internal/modules/daily-checklists/services/daily-checklist.service_test.go @@ -0,0 +1,409 @@ +package service + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" + "gorm.io/gorm" +) + +func TestCreateOneRejectsWhenSameDateHasActiveEmptyKandang(t *testing.T) { + svc, db := setupDailyChecklistServiceTest(t) + + insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr("DRAFT"), nil) + + result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{ + Date: "2026-01-10", + KandangId: 1, + Category: "cleaning", + Status: "DRAFT", + }) + + if result != nil { + t.Fatalf("expected nil result, got %+v", result) + } + assertFiberErrorCode(t, serviceErr, fiber.StatusConflict) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode) + } +} + +func TestCreateOneRejectsWhenSameDateHasRejectedEmptyKandang(t *testing.T) { + svc, db := setupDailyChecklistServiceTest(t) + + insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr(dailyChecklistStatusRejected), nil) + + result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{ + Date: "2026-01-10", + KandangId: 1, + Category: "cleaning", + Status: "DRAFT", + }) + + if result != nil { + t.Fatalf("expected nil result, got %+v", result) + } + assertFiberErrorCode(t, serviceErr, fiber.StatusConflict) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode) + } +} + +func TestCreateOneAllowsWhenOnlySoftDeletedEmptyKandangExists(t *testing.T) { + svc, db := setupDailyChecklistServiceTest(t) + + deletedAt := mustDateTime(t, "2026-01-11 10:00:00") + insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), dailyChecklistCategoryEmptyKandang, strPtr("DRAFT"), &deletedAt) + + result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{ + Date: "2026-01-10", + KandangId: 1, + Category: "cleaning", + Status: "DRAFT", + }) + + if serviceErr != nil { + t.Fatalf("expected no error, got %v", serviceErr) + } + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Category != "cleaning" { + t.Fatalf("expected category cleaning, got %s", result.Category) + } + + var activeCount int64 + if err := db.Model(&entity.DailyChecklist{}). + Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-10"), "cleaning"). + Count(&activeCount).Error; err != nil { + t.Fatalf("failed counting active checklists: %v", err) + } + if activeCount != 1 { + t.Fatalf("expected 1 active cleaning checklist, got %d", activeCount) + } +} + +func TestCreateOneRejectsBulkEmptyKandangWhenDateRangeHasConflict(t *testing.T) { + svc, db := setupDailyChecklistServiceTest(t) + + insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), dailyChecklistCategoryEmptyKandang, strPtr("APPROVED"), nil) + + result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{ + Date: "2026-01-01", + KandangId: 1, + Category: "cleaning", + Status: "DRAFT", + EmptyKandang: true, + EmptyKandangEndDate: "2026-01-05", + }) + + if result != nil { + t.Fatalf("expected nil result, got %+v", result) + } + assertFiberErrorCode(t, serviceErr, fiber.StatusConflict) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode) + } + + var activeInRange int64 + if err := db.Model(&entity.DailyChecklist{}). + Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")). + Count(&activeInRange).Error; err != nil { + t.Fatalf("failed counting checklists in range: %v", err) + } + if activeInRange != 1 { + t.Fatalf("expected only pre-existing row to remain in range, got %d rows", activeInRange) + } +} + +func TestCreateOneRejectsBulkEmptyKandangWhenRangeHasNonEmptyChecklist(t *testing.T) { + svc, db := setupDailyChecklistServiceTest(t) + + insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr("APPROVED"), nil) + + result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{ + Date: "2026-01-01", + KandangId: 1, + Category: "cleaning", + Status: "DRAFT", + EmptyKandang: true, + EmptyKandangEndDate: "2026-01-05", + }) + + if result != nil { + t.Fatalf("expected nil result, got %+v", result) + } + assertFiberErrorCode(t, serviceErr, fiber.StatusConflict) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode) + } +} + +func TestCreateOneRejectsBulkEmptyKandangWhenRangeHasRejectedChecklist(t *testing.T) { + svc, db := setupDailyChecklistServiceTest(t) + + insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr(dailyChecklistStatusRejected), nil) + + result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{ + Date: "2026-01-01", + KandangId: 1, + Category: "cleaning", + Status: "DRAFT", + EmptyKandang: true, + EmptyKandangEndDate: "2026-01-05", + }) + + if result != nil { + t.Fatalf("expected nil result, got %+v", result) + } + assertFiberErrorCode(t, serviceErr, fiber.StatusConflict) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected HTTP status %d, got %d", fiber.StatusConflict, resp.StatusCode) + } +} + +func TestCreateOneAllowsBulkEmptyKandangWhenRangeHasOnlySoftDeletedChecklist(t *testing.T) { + svc, db := setupDailyChecklistServiceTest(t) + + deletedAt := mustDateTime(t, "2026-01-11 10:00:00") + insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-03"), "cleaning", strPtr("APPROVED"), &deletedAt) + + result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{ + Date: "2026-01-01", + KandangId: 1, + Category: "cleaning", + Status: "DRAFT", + EmptyKandang: true, + EmptyKandangEndDate: "2026-01-05", + }) + + if serviceErr != nil { + t.Fatalf("expected no error, got %v", serviceErr) + } + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Category != dailyChecklistCategoryEmptyKandang { + t.Fatalf("expected category %s, got %s", dailyChecklistCategoryEmptyKandang, result.Category) + } + + var activeInRange int64 + if err := db.Model(&entity.DailyChecklist{}). + Where("kandang_id = ? AND date BETWEEN ? AND ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-01"), mustDate(t, "2026-01-05")). + Count(&activeInRange).Error; err != nil { + t.Fatalf("failed counting checklists in range: %v", err) + } + if activeInRange != 5 { + t.Fatalf("expected 5 active checklists created for range, got %d", activeInRange) + } +} + +func TestCreateOneReusesExistingChecklistWhenNoEmptyKandangConflict(t *testing.T) { + svc, db := setupDailyChecklistServiceTest(t) + + existingID := insertDailyChecklistRow(t, db, 1, mustDate(t, "2026-01-10"), "cleaning", strPtr("APPROVED"), nil) + + result, serviceErr, resp := runCreateOneRequest(t, svc, &validation.Create{ + Date: "2026-01-10", + KandangId: 1, + Category: "cleaning", + Status: "DRAFT", + }) + + if serviceErr != nil { + t.Fatalf("expected no error, got %v", serviceErr) + } + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected HTTP status %d, got %d", fiber.StatusCreated, resp.StatusCode) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Id != existingID { + t.Fatalf("expected existing checklist id %d to be reused, got %d", existingID, result.Id) + } + + var activeCount int64 + if err := db.Model(&entity.DailyChecklist{}). + Where("kandang_id = ? AND date = ? AND category = ? AND deleted_at IS NULL", 1, mustDate(t, "2026-01-10"), "cleaning"). + Count(&activeCount).Error; err != nil { + t.Fatalf("failed counting active checklists: %v", err) + } + if activeCount != 1 { + t.Fatalf("expected 1 active cleaning checklist, got %d", activeCount) + } +} + +func setupDailyChecklistServiceTest(t *testing.T) (DailyChecklistService, *gorm.DB) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + + statements := []string{ + `CREATE TABLE areas ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + created_by INTEGER NOT NULL, + created_at DATETIME NULL, + updated_at DATETIME NULL, + deleted_at DATETIME NULL + )`, + `CREATE TABLE locations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + address TEXT NOT NULL, + area_id INTEGER NOT NULL, + created_by INTEGER NOT NULL, + created_at DATETIME NULL, + updated_at DATETIME NULL, + deleted_at DATETIME NULL + )`, + `CREATE TABLE kandang_groups ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + status TEXT NOT NULL, + location_id INTEGER NOT NULL, + pic_id INTEGER NOT NULL, + created_by INTEGER NOT NULL, + created_at DATETIME NULL, + updated_at DATETIME NULL, + deleted_at DATETIME NULL + )`, + `CREATE TABLE daily_checklists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kandang_id INTEGER NOT NULL, + checklist_id INTEGER NULL, + date DATE NOT NULL, + name TEXT NULL, + status TEXT NULL, + category TEXT NOT NULL, + total_score INTEGER NULL, + document_path TEXT NULL, + reject_reason TEXT NULL, + created_by INTEGER NULL, + deleted_by INTEGER NULL, + created_at DATETIME NULL, + updated_at DATETIME NULL, + deleted_at DATETIME NULL + )`, + `INSERT INTO areas (id, name, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Area A', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, + `INSERT INTO locations (id, name, address, area_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Farm A', 'Address', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, + `INSERT INTO kandang_groups (id, name, status, location_id, pic_id, created_by, created_at, updated_at, deleted_at) VALUES (1, 'Kandang A', 'ACTIVE', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`, + } + + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + repo := repository.NewDailyChecklistRepository(db) + svc := NewDailyChecklistService(repo, nil, validator.New(), nil) + return svc, db +} + +func runCreateOneRequest(t *testing.T, svc DailyChecklistService, req *validation.Create) (*entity.DailyChecklist, error, *http.Response) { + t.Helper() + + app := fiber.New() + var ( + result *entity.DailyChecklist + serviceErr error + ) + + app.Post("/", func(c *fiber.Ctx) error { + result, serviceErr = svc.CreateOne(c, req) + if serviceErr != nil { + return serviceErr + } + return c.SendStatus(fiber.StatusCreated) + }) + + resp, err := app.Test(httptest.NewRequest(http.MethodPost, "/", nil)) + if err != nil { + t.Fatalf("failed running fiber request: %v", err) + } + + return result, serviceErr, resp +} + +func insertDailyChecklistRow(t *testing.T, db *gorm.DB, kandangID uint, date time.Time, category string, status *string, deletedAt *time.Time) uint { + t.Helper() + + row := &entity.DailyChecklist{ + KandangId: kandangID, + Date: date, + Category: category, + Status: status, + } + if deletedAt != nil { + row.DeletedAt = gorm.DeletedAt{ + Time: *deletedAt, + Valid: true, + } + } + + if err := db.Create(row).Error; err != nil { + t.Fatalf("failed inserting daily checklist row: %v", err) + } + return row.Id +} + +func assertFiberErrorCode(t *testing.T, err error, expectedCode int) { + t.Helper() + + if err == nil { + t.Fatal("expected error, got nil") + } + + var fiberErr *fiber.Error + if !errors.As(err, &fiberErr) { + t.Fatalf("expected *fiber.Error, got %T (%v)", err, err) + } + if fiberErr.Code != expectedCode { + t.Fatalf("expected fiber error code %d, got %d", expectedCode, fiberErr.Code) + } +} + +func mustDate(t *testing.T, raw string) time.Time { + t.Helper() + + value, err := time.Parse(dailyChecklistDateLayout, raw) + if err != nil { + t.Fatalf("failed parsing date %q: %v", raw, err) + } + return value +} + +func mustDateTime(t *testing.T, raw string) time.Time { + t.Helper() + + value, err := time.Parse("2006-01-02 15:04:05", raw) + if err != nil { + t.Fatalf("failed parsing datetime %q: %v", raw, err) + } + return value +} + +func strPtr(value string) *string { + v := value + return &v +}