adjust validation create daily checklist empty kandang

This commit is contained in:
giovanni
2026-04-23 14:24:13 +07:00
parent 151edf578e
commit eacc460f67
2 changed files with 472 additions and 0 deletions
@@ -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"}).
@@ -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
}