Files
2026-04-19 21:13:48 +07:00

564 lines
16 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/xuri/excelize/v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
func TestParseManualInputFile_ValidSingleRow(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "12345.678", "2026-06-01", "manual seed"},
},
)
location := mustJakartaLocation(t)
sheet, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if sheet != "manual_inputs" {
t.Fatalf("expected selected sheet manual_inputs, got %q", sheet)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].ProjectFlockID != 101 {
t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID)
}
if rows[0].TotalCost != 12345.678 {
t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost)
}
if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" {
t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout))
}
if rows[0].Note == nil || *rows[0].Note != "manual seed" {
t.Fatalf("expected note manual seed, got %+v", rows[0].Note)
}
}
func TestParseManualInputFile_ValidMultiRow(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"102", "1300.5", "2026-06-02", "second"},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(issues) != 0 {
t.Fatalf("expected no issues, got %+v", issues)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
if rows[0].Note != nil {
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
}
if rows[1].Note == nil || *rows[1].Note != "second" {
t.Fatalf("expected second row note second, got %+v", rows[1].Note)
}
}
func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "totalcost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
}
if !hasIssue(issues, 0, "total_cost", "required header is missing") {
t.Fatalf("expected missing total_cost header issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"abc", "1200", "2026-06-01", ""},
{"0", "1300", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") {
t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues)
}
if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") {
t.Fatalf("expected project_flock_id >0 issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidTotalCost(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "abc", "2026-06-01", ""},
{"102", "-1", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "total_cost", "must be numeric") {
t.Fatalf("expected total_cost numeric issue, got %+v", issues)
}
if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") {
t.Fatalf("expected total_cost >=0 issue, got %+v", issues)
}
}
func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "06-01-2026", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected no valid rows, got %d", len(rows))
}
if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") {
t.Fatalf("expected cutover_date format issue, got %+v", issues)
}
}
func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) {
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"101", "1300", "2026-06-02", ""},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows))
}
if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") {
t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues)
}
}
func TestParseManualInputFile_NoteValidation(t *testing.T) {
longNote := strings.Repeat("a", 1001)
filePath := createManualInputWorkbook(
t,
"manual_inputs",
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
[][]string{
{"101", "1200", "2026-06-01", ""},
{"102", "1300", "2026-06-02", longNote},
},
)
location := mustJakartaLocation(t)
_, rows, issues, err := parseManualInputFile(filePath, "", location)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected only first row valid, got %d", len(rows))
}
if rows[0].Note != nil {
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
}
if !hasIssue(issues, 3, "note", "at most 1000 characters") {
t.Fatalf("expected note length issue, got %+v", issues)
}
}
func TestApplyImportRows_Success(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 2000,
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedUpserts) != 2 {
t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts))
}
if len(runner.committedInvalidations) != 2 {
t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations))
}
if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 {
t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations)
}
}
func TestApplyImportRows_RollbackOnError(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{
failUpsertOnProjectFlockID: 102,
}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 2000,
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected error due to upsert failure")
}
if runner.txCalls != 1 {
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
}
if len(runner.committedUpserts) != 0 {
t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts))
}
if len(runner.committedInvalidations) != 0 {
t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations))
}
}
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
err := applyIfRequested(context.Background(), false, runner, rows)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if runner.txCalls != 0 {
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
}
}
func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
t.Helper()
f := excelize.NewFile()
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
if sheetName == "" {
sheetName = defaultSheet
} else if sheetName != defaultSheet {
f.SetSheetName(defaultSheet, sheetName)
}
for idx, header := range headers {
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
if err != nil {
t.Fatalf("failed resolving header cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, header); err != nil {
t.Fatalf("failed setting header cell: %v", err)
}
}
for rowIdx, row := range rows {
for colIdx, value := range row {
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
if err != nil {
t.Fatalf("failed resolving data cell: %v", err)
}
if err := f.SetCellValue(sheetName, cell, value); err != nil {
t.Fatalf("failed setting data cell: %v", err)
}
}
}
path := filepath.Join(t.TempDir(), "manual_inputs.xlsx")
if err := f.SaveAs(path); err != nil {
t.Fatalf("failed saving workbook: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("failed closing workbook: %v", err)
}
return path
}
func mustJakartaLocation(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
t.Fatalf("failed loading Asia/Jakarta location: %v", err)
}
return location
}
func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time {
t.Helper()
value, err := time.ParseInLocation(dateLayout, raw, location)
if err != nil {
t.Fatalf("failed parsing date %q: %v", raw, err)
}
return value
}
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
for _, issue := range issues {
if issue.Row != row {
continue
}
if issue.Field != field {
continue
}
if strings.Contains(issue.Message, messageContains) {
return true
}
}
return false
}
type fakeInvalidation struct {
fromDate time.Time
farmIDs []uint
}
type fakeManualInputStore struct {
failUpsertOnProjectFlockID uint
failDeleteOnProjectFlockID uint
upserts []entity.FarmDepreciationManualInput
invalidations []fakeInvalidation
}
func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error {
if row == nil {
return nil
}
if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID {
return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId)
}
cloned := *row
s.upserts = append(s.upserts, cloned)
return nil
}
func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error {
if s.failDeleteOnProjectFlockID > 0 {
for _, farmID := range farmIDs {
if farmID == s.failDeleteOnProjectFlockID {
return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID)
}
}
}
copiedFarmIDs := append([]uint{}, farmIDs...)
s.invalidations = append(s.invalidations, fakeInvalidation{
fromDate: fromDate,
farmIDs: copiedFarmIDs,
})
return nil
}
type fakeTransactionRunner struct {
txCalls int
failUpsertOnProjectFlockID uint
failDeleteOnProjectFlockID uint
committedUpserts []entity.FarmDepreciationManualInput
committedInvalidations []fakeInvalidation
}
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
r.txCalls++
txStore := &fakeManualInputStore{
failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID,
failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID,
}
if err := fn(txStore); err != nil {
return err
}
r.committedUpserts = append(r.committedUpserts, txStore.upserts...)
r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...)
return nil
}
var _ txRunner = (*fakeTransactionRunner)(nil)
var _ manualInputStore = (*fakeManualInputStore)(nil)
func TestBuildMissingFarmIssues(t *testing.T) {
location := mustJakartaLocation(t)
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
{
RowNumber: 3,
ProjectFlockID: 102,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
issues := buildMissingFarmIssues(rows, map[uint]string{
101: "Farm A",
})
if len(issues) != 1 {
t.Fatalf("expected 1 issue, got %+v", issues)
}
if issues[0].Row != 3 || issues[0].Field != "project_flock_id" {
t.Fatalf("unexpected issue: %+v", issues[0])
}
}
func TestApplyImportRows_PropagatesDeleteError(t *testing.T) {
location := mustJakartaLocation(t)
runner := &fakeTransactionRunner{
failDeleteOnProjectFlockID: 101,
}
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
err := applyImportRows(context.Background(), runner, rows)
if err == nil {
t.Fatal("expected delete failure")
}
if !strings.Contains(err.Error(), "snapshot invalidation failed") {
t.Fatalf("expected snapshot invalidation error message, got %v", err)
}
}
func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) {
workbook := excelize.NewFile()
defer func() {
_ = workbook.Close()
}()
_, err := resolveSheetName(workbook, "unknown")
if err == nil {
t.Fatal("expected error when sheet is missing")
}
}
func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) {
location := mustJakartaLocation(t)
rows := []manualInputImportRow{
{
RowNumber: 2,
ProjectFlockID: 101,
TotalCost: 1000,
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
},
}
runner := &errorTxRunner{err: errors.New("tx failed")}
err := applyIfRequested(context.Background(), true, runner, rows)
if err == nil {
t.Fatal("expected transaction error")
}
if err.Error() != "tx failed" {
t.Fatalf("unexpected error: %v", err)
}
}
type errorTxRunner struct {
err error
}
func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error {
return r.err
}