test(db): add edge-case tests for items, revisions, projects, and files (#75)
- Duplicate part number constraint (PG 23505) - Hard delete, pagination, search filtering - Revision status/labels update, compare, rollback - Project-item association by code, list by project filter - Item file CRUD: create, list, get, delete
This commit is contained in:
121
internal/db/item_files_test.go
Normal file
121
internal/db/item_files_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestItemFileCreate(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
itemRepo := NewItemRepository(database)
|
||||
fileRepo := NewItemFileRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
item := &Item{PartNumber: "FILE-001", ItemType: "part", Description: "file test"}
|
||||
if err := itemRepo.Create(ctx, item, nil); err != nil {
|
||||
t.Fatalf("Create item: %v", err)
|
||||
}
|
||||
|
||||
f := &ItemFile{
|
||||
ItemID: item.ID,
|
||||
Filename: "drawing.pdf",
|
||||
ContentType: "application/pdf",
|
||||
Size: 12345,
|
||||
ObjectKey: "items/FILE-001/files/abc/drawing.pdf",
|
||||
}
|
||||
if err := fileRepo.Create(ctx, f); err != nil {
|
||||
t.Fatalf("Create file: %v", err)
|
||||
}
|
||||
if f.ID == "" {
|
||||
t.Error("expected file ID to be set")
|
||||
}
|
||||
if f.CreatedAt.IsZero() {
|
||||
t.Error("expected created_at to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemFileListByItem(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
itemRepo := NewItemRepository(database)
|
||||
fileRepo := NewItemFileRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
item := &Item{PartNumber: "FLIST-001", ItemType: "part", Description: "file list test"}
|
||||
itemRepo.Create(ctx, item, nil)
|
||||
|
||||
for i, name := range []string{"a.pdf", "b.step"} {
|
||||
fileRepo.Create(ctx, &ItemFile{
|
||||
ItemID: item.ID,
|
||||
Filename: name,
|
||||
ContentType: "application/octet-stream",
|
||||
Size: int64(i * 1000),
|
||||
ObjectKey: "items/FLIST-001/files/" + name,
|
||||
})
|
||||
}
|
||||
|
||||
files, err := fileRepo.ListByItem(ctx, item.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByItem: %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Errorf("expected 2 files, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemFileGet(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
itemRepo := NewItemRepository(database)
|
||||
fileRepo := NewItemFileRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
item := &Item{PartNumber: "FGET-001", ItemType: "part", Description: "file get test"}
|
||||
itemRepo.Create(ctx, item, nil)
|
||||
|
||||
f := &ItemFile{
|
||||
ItemID: item.ID,
|
||||
Filename: "model.FCStd",
|
||||
ContentType: "application/x-freecad",
|
||||
Size: 99999,
|
||||
ObjectKey: "items/FGET-001/files/xyz/model.FCStd",
|
||||
}
|
||||
fileRepo.Create(ctx, f)
|
||||
|
||||
got, err := fileRepo.Get(ctx, f.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.Filename != "model.FCStd" {
|
||||
t.Errorf("filename: got %q, want %q", got.Filename, "model.FCStd")
|
||||
}
|
||||
if got.Size != 99999 {
|
||||
t.Errorf("size: got %d, want %d", got.Size, 99999)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemFileDelete(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
itemRepo := NewItemRepository(database)
|
||||
fileRepo := NewItemFileRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
item := &Item{PartNumber: "FDEL-001", ItemType: "part", Description: "file delete test"}
|
||||
itemRepo.Create(ctx, item, nil)
|
||||
|
||||
f := &ItemFile{
|
||||
ItemID: item.ID,
|
||||
Filename: "temp.bin",
|
||||
ContentType: "application/octet-stream",
|
||||
Size: 100,
|
||||
ObjectKey: "items/FDEL-001/files/tmp/temp.bin",
|
||||
}
|
||||
fileRepo.Create(ctx, f)
|
||||
|
||||
if err := fileRepo.Delete(ctx, f.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
_, err := fileRepo.Get(ctx, f.ID)
|
||||
if err == nil {
|
||||
t.Error("expected error after delete, got nil")
|
||||
}
|
||||
}
|
||||
281
internal/db/items_edge_test.go
Normal file
281
internal/db/items_edge_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestItemCreateDuplicatePartNumber(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
repo := NewItemRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
item := &Item{PartNumber: "DUP-001", ItemType: "part", Description: "first"}
|
||||
if err := repo.Create(ctx, item, nil); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
dup := &Item{PartNumber: "DUP-001", ItemType: "part", Description: "duplicate"}
|
||||
err := repo.Create(ctx, dup, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate part number, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "23505") && !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("expected duplicate key error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemDelete(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
repo := NewItemRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
item := &Item{PartNumber: "HDEL-001", ItemType: "part", Description: "hard delete"}
|
||||
if err := repo.Create(ctx, item, nil); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, item.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.GetByID(ctx, item.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID after delete: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Error("expected nil after hard delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemListPagination(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
repo := NewItemRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
item := &Item{
|
||||
PartNumber: fmt.Sprintf("PAGE-%04d", i),
|
||||
ItemType: "part",
|
||||
Description: fmt.Sprintf("page item %d", i),
|
||||
}
|
||||
if err := repo.Create(ctx, item, nil); err != nil {
|
||||
t.Fatalf("Create #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch page of 2 with offset 2
|
||||
items, err := repo.List(ctx, ListOptions{Limit: 2, Offset: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Errorf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemListSearch(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
repo := NewItemRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
repo.Create(ctx, &Item{PartNumber: "SRCH-001", ItemType: "part", Description: "alpha widget"}, nil)
|
||||
repo.Create(ctx, &Item{PartNumber: "SRCH-002", ItemType: "part", Description: "beta gadget"}, nil)
|
||||
repo.Create(ctx, &Item{PartNumber: "SRCH-003", ItemType: "part", Description: "alpha gizmo"}, nil)
|
||||
|
||||
items, err := repo.List(ctx, ListOptions{Search: "alpha"})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Errorf("expected 2 items matching 'alpha', got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionStatusUpdate(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
repo := NewItemRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
item := &Item{PartNumber: "STAT-001", ItemType: "part", Description: "status test"}
|
||||
if err := repo.Create(ctx, item, map[string]any{"v": 1}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
status := "released"
|
||||
if err := repo.UpdateRevisionStatus(ctx, item.ID, 1, &status, nil); err != nil {
|
||||
t.Fatalf("UpdateRevisionStatus: %v", err)
|
||||
}
|
||||
|
||||
rev, err := repo.GetRevision(ctx, item.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRevision: %v", err)
|
||||
}
|
||||
if rev.Status != "released" {
|
||||
t.Errorf("status: got %q, want %q", rev.Status, "released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionLabelsUpdate(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
repo := NewItemRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
item := &Item{PartNumber: "LBL-001", ItemType: "part", Description: "label test"}
|
||||
if err := repo.Create(ctx, item, nil); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
labels := []string{"prototype", "urgent"}
|
||||
if err := repo.UpdateRevisionStatus(ctx, item.ID, 1, nil, labels); err != nil {
|
||||
t.Fatalf("UpdateRevisionStatus: %v", err)
|
||||
}
|
||||
|
||||
rev, err := repo.GetRevision(ctx, item.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRevision: %v", err)
|
||||
}
|
||||
if len(rev.Labels) != 2 {
|
||||
t.Errorf("labels count: got %d, want 2", len(rev.Labels))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionCompare(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
repo := NewItemRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
item := &Item{PartNumber: "CMP-001", ItemType: "part", Description: "compare test"}
|
||||
if err := repo.Create(ctx, item, map[string]any{"color": "red", "weight": 10}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// Rev 2: change color, remove weight, add size
|
||||
repo.CreateRevision(ctx, &Revision{
|
||||
ItemID: item.ID,
|
||||
Properties: map[string]any{"color": "blue", "size": "large"},
|
||||
})
|
||||
|
||||
diff, err := repo.CompareRevisions(ctx, item.ID, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("CompareRevisions: %v", err)
|
||||
}
|
||||
|
||||
if len(diff.Added) == 0 {
|
||||
t.Error("expected added fields (size)")
|
||||
}
|
||||
if len(diff.Removed) == 0 {
|
||||
t.Error("expected removed fields (weight)")
|
||||
}
|
||||
if len(diff.Changed) == 0 {
|
||||
t.Error("expected changed fields (color)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionRollback(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
repo := NewItemRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
item := &Item{PartNumber: "RBK-001", ItemType: "part", Description: "rollback test"}
|
||||
if err := repo.Create(ctx, item, map[string]any{"version": "original"}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// Rev 2: change property
|
||||
repo.CreateRevision(ctx, &Revision{
|
||||
ItemID: item.ID,
|
||||
Properties: map[string]any{"version": "modified"},
|
||||
})
|
||||
|
||||
// Rollback to rev 1 — should create rev 3
|
||||
comment := "rollback to rev 1"
|
||||
rev3, err := repo.CreateRevisionFromExisting(ctx, item.ID, 1, comment, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRevisionFromExisting: %v", err)
|
||||
}
|
||||
if rev3.RevisionNumber != 3 {
|
||||
t.Errorf("revision number: got %d, want 3", rev3.RevisionNumber)
|
||||
}
|
||||
|
||||
// Rev 3 should have rev 1's properties
|
||||
got, err := repo.GetRevision(ctx, item.ID, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRevision: %v", err)
|
||||
}
|
||||
if got.Properties["version"] != "original" {
|
||||
t.Errorf("rolled back version: got %v, want %q", got.Properties["version"], "original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectItemAssociationsByCode(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
projRepo := NewProjectRepository(database)
|
||||
itemRepo := NewItemRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
proj := &Project{Code: "BYTAG", Name: "Tag Project"}
|
||||
projRepo.Create(ctx, proj)
|
||||
|
||||
item := &Item{PartNumber: "TAG-001", ItemType: "part", Description: "taggable"}
|
||||
itemRepo.Create(ctx, item, nil)
|
||||
|
||||
// Tag by code
|
||||
if err := projRepo.AddItemToProjectByCode(ctx, item.ID, "BYTAG"); err != nil {
|
||||
t.Fatalf("AddItemToProjectByCode: %v", err)
|
||||
}
|
||||
|
||||
projects, err := projRepo.GetProjectsForItem(ctx, item.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProjectsForItem: %v", err)
|
||||
}
|
||||
if len(projects) != 1 {
|
||||
t.Fatalf("expected 1 project, got %d", len(projects))
|
||||
}
|
||||
if projects[0].Code != "BYTAG" {
|
||||
t.Errorf("project code: got %q, want %q", projects[0].Code, "BYTAG")
|
||||
}
|
||||
|
||||
// Untag by code
|
||||
if err := projRepo.RemoveItemFromProjectByCode(ctx, item.ID, "BYTAG"); err != nil {
|
||||
t.Fatalf("RemoveItemFromProjectByCode: %v", err)
|
||||
}
|
||||
|
||||
projects, _ = projRepo.GetProjectsForItem(ctx, item.ID)
|
||||
if len(projects) != 0 {
|
||||
t.Errorf("expected 0 projects after removal, got %d", len(projects))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListByProject(t *testing.T) {
|
||||
database := mustConnectTestDB(t)
|
||||
projRepo := NewProjectRepository(database)
|
||||
itemRepo := NewItemRepository(database)
|
||||
ctx := context.Background()
|
||||
|
||||
proj := &Project{Code: "FILT", Name: "Filter Project"}
|
||||
projRepo.Create(ctx, proj)
|
||||
|
||||
// Create 3 items, tag only 2
|
||||
for i := 0; i < 3; i++ {
|
||||
item := &Item{
|
||||
PartNumber: fmt.Sprintf("FILT-%04d", i),
|
||||
ItemType: "part",
|
||||
Description: fmt.Sprintf("filter item %d", i),
|
||||
}
|
||||
itemRepo.Create(ctx, item, nil)
|
||||
if i < 2 {
|
||||
projRepo.AddItemToProjectByCode(ctx, item.ID, "FILT")
|
||||
}
|
||||
}
|
||||
|
||||
items, err := itemRepo.List(ctx, ListOptions{Project: "FILT"})
|
||||
if err != nil {
|
||||
t.Fatalf("List with project filter: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Errorf("expected 2 items in project FILT, got %d", len(items))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user