package repository import ( "context" "errors" "path/filepath" "sync" "testing" "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/dhao2001/mygo/internal/config" "github.com/dhao2001/mygo/internal/model" ) func setupFileRepo(t *testing.T) FileRepository { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("open db: %v", err) } if err := db.AutoMigrate(&model.File{}); err != nil { t.Fatalf("migrate: %v", err) } return NewFileRepository(db) } func createFileRecord(ctx context.Context, repo FileRepository, file *model.File) error { var err error if file.IsDir { _, err = repo.CreateDirectory(ctx, DirectoryParams{ ID: file.ID, UserID: file.UserID, ParentID: file.ParentID, Name: file.Name, }) } else { _, err = repo.CreateUploadedFile(ctx, UploadedFileParams{ ID: file.ID, UserID: file.UserID, ParentID: file.ParentID, Name: file.Name, Size: file.Size, MimeType: file.MimeType, StoragePath: file.StoragePath, Hash: file.Hash, }) } if err != nil { return err } if file.Status == model.StatusUserDeleted { return repo.SoftDeleteOwned(ctx, file.UserID, file.ID) } return nil } func setupConcurrentFileRepo(t *testing.T) FileRepository { t.Helper() db, err := Open(config.DatabaseConfig{ Driver: "sqlite3", SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "concurrency.db")}, }) if err != nil { t.Fatalf("open db: %v", err) } if err := AutoMigrate(db); err != nil { t.Fatalf("migrate: %v", err) } t.Cleanup(func() { sqlDB, err := db.DB() if err == nil { _ = sqlDB.Close() } }) return NewFileRepository(db) } func TestFileRepository_Create(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() file := &model.File{ ID: "file-1", UserID: "user-1", Name: "test.txt", Size: 1024, Status: model.StatusActive, } if err := createFileRecord(ctx, repo, file); err != nil { t.Fatalf("Create = %v", err) } } func TestFileRepository_FindByID(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() file := &model.File{ ID: "file-1", UserID: "user-1", Name: "test.txt", Status: model.StatusActive, } if err := createFileRecord(ctx, repo, file); err != nil { t.Fatalf("Create = %v", err) } found, err := repo.FindByID(ctx, "file-1") if err != nil { t.Fatalf("FindByID = %v", err) } if found.Name != "test.txt" { t.Errorf("name = %q, want %q", found.Name, "test.txt") } } func TestFileRepository_FindByIDNotFound(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() _, err := repo.FindByID(ctx, "nonexistent") if err != model.ErrNotFound { t.Fatalf("expected ErrNotFound, got %v", err) } } func TestFileRepository_FindByUserID(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() files := []*model.File{ {ID: "f-1", UserID: "user-1", Name: "a.txt", Status: model.StatusActive}, {ID: "f-2", UserID: "user-1", Name: "b.txt", Status: model.StatusActive}, {ID: "f-3", UserID: "user-2", Name: "c.txt", Status: model.StatusActive}, } for _, f := range files { if err := createFileRecord(ctx, repo, f); err != nil { t.Fatalf("Create = %v", err) } } result, total, err := repo.FindByUserID(ctx, "user-1", 0, 10) if err != nil { t.Fatalf("FindByUserID = %v", err) } if len(result) != 2 { t.Errorf("len(result) = %d, want 2", len(result)) } if total != 2 { t.Errorf("total = %d, want 2", total) } } func TestFileRepository_FindByParentID(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() parentID := "dir-1" if err := createFileRecord(ctx, repo, &model.File{ID: parentID, UserID: "user-1", Name: "dir", Status: model.StatusActive, IsDir: true}); err != nil { t.Fatalf("Create parent = %v", err) } files := []*model.File{ {ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive}, {ID: "f-2", UserID: "user-1", ParentID: &parentID, Name: "b.txt", Status: model.StatusActive}, {ID: "f-3", UserID: "user-1", Name: "c.txt", Status: model.StatusActive}, } for _, f := range files { if err := createFileRecord(ctx, repo, f); err != nil { t.Fatalf("Create = %v", err) } } children, err := repo.FindByParentID(ctx, "user-1", &parentID) if err != nil { t.Fatalf("FindByParentID = %v", err) } if len(children) != 2 { t.Errorf("len(children) = %d, want 2", len(children)) } } func TestFileRepository_FindByParentIDNull(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() parentID := "dir-1" if err := createFileRecord(ctx, repo, &model.File{ID: parentID, UserID: "user-1", Name: "dir", Status: model.StatusActive, IsDir: true}); err != nil { t.Fatalf("Create parent = %v", err) } files := []*model.File{ {ID: "f-1", UserID: "user-1", ParentID: &parentID, Name: "a.txt", Status: model.StatusActive}, {ID: "f-2", UserID: "user-1", Name: "root.txt", Status: model.StatusActive}, } for _, f := range files { if err := createFileRecord(ctx, repo, f); err != nil { t.Fatalf("Create = %v", err) } } children, err := repo.FindByParentID(ctx, "user-1", nil) if err != nil { t.Fatalf("FindByParentID(nil) = %v", err) } if len(children) != 2 { t.Errorf("len(children) = %d, want 2", len(children)) } } func TestFileRepository_Update(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() file := &model.File{ ID: "file-1", UserID: "user-1", Name: "original.txt", Size: 1024, MimeType: "text/plain", StoragePath: "data/user-1/file-1", Hash: "original-hash", Status: model.StatusActive, } if err := createFileRecord(ctx, repo, file); err != nil { t.Fatalf("Create = %v", err) } before, err := repo.FindByID(ctx, file.ID) if err != nil { t.Fatalf("FindByID before update = %v", err) } newName := "renamed.txt" if _, err := repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: file.ID, UserID: file.UserID, NewName: &newName}); err != nil { t.Fatalf("Update = %v", err) } found, err := repo.FindByID(ctx, "file-1") if err != nil { t.Fatalf("FindByID = %v", err) } if found.Name != "renamed.txt" { t.Errorf("name = %q, want %q", found.Name, "renamed.txt") } if found.UserID != before.UserID || found.Status != before.Status || found.IsDir != before.IsDir || found.Size != before.Size || found.MimeType != before.MimeType || found.StoragePath != before.StoragePath || found.Hash != before.Hash || !found.CreatedAt.Equal(before.CreatedAt) { t.Fatalf("metadata update changed protected fields: before=%+v after=%+v", before, found) } } func TestFileRepository_Delete(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() file := &model.File{ID: "file-1", UserID: "user-1", Name: "test.txt", Status: model.StatusActive} if err := createFileRecord(ctx, repo, file); err != nil { t.Fatalf("Create = %v", err) } if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil { t.Fatalf("Delete = %v", err) } _, err := repo.FindByID(ctx, "file-1") if err != model.ErrNotFound { t.Fatalf("expected ErrNotFound after delete, got %v", err) } } func TestFileRepository_SoftDelete(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() file := &model.File{ ID: "file-1", UserID: "user-1", Name: "test.txt", Status: model.StatusActive, } if err := createFileRecord(ctx, repo, file); err != nil { t.Fatalf("Create = %v", err) } if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil { t.Fatalf("Delete = %v", err) } _, err := repo.FindByID(ctx, "file-1") if err != model.ErrNotFound { t.Fatalf("expected ErrNotFound after soft-delete, got %v", err) } } func TestFileRepository_StatusFilter(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() activeFile := &model.File{ ID: "f-1", UserID: "user-1", Name: "active.txt", Status: model.StatusActive, } deletedFile := &model.File{ ID: "f-2", UserID: "user-1", Name: "deleted.txt", Status: model.StatusUserDeleted, } if err := createFileRecord(ctx, repo, activeFile); err != nil { t.Fatalf("Create active = %v", err) } if err := createFileRecord(ctx, repo, deletedFile); err != nil { t.Fatalf("Create deleted = %v", err) } result, total, err := repo.FindByUserID(ctx, "user-1", 0, 10) if err != nil { t.Fatalf("FindByUserID = %v", err) } if len(result) != 1 { t.Errorf("len(result) = %d, want 1", len(result)) } if total != 1 { t.Errorf("total = %d, want 1", total) } if result[0].ID != "f-1" { t.Errorf("result[0].ID = %q, want %q", result[0].ID, "f-1") } } func TestFileRepository_DeleteReturnsNotFoundAfterDeletion(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() file := &model.File{ ID: "file-1", UserID: "user-1", Name: "test.txt", Status: model.StatusActive, } if err := createFileRecord(ctx, repo, file); err != nil { t.Fatalf("Create = %v", err) } if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); err != nil { t.Fatalf("first Delete = %v", err) } if err := repo.SoftDeleteOwned(ctx, "user-1", "file-1"); !errors.Is(err, model.ErrNotFound) { t.Fatalf("second Delete = %v, want ErrNotFound", err) } if err := repo.SoftDeleteOwned(ctx, "user-1", "missing-file"); !errors.Is(err, model.ErrNotFound) { t.Fatalf("missing Delete = %v, want ErrNotFound", err) } } func TestFileRepository_StatusFilterCount(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() activeFile := &model.File{ ID: "f-1", UserID: "user-1", Name: "active.txt", Status: model.StatusActive, } deletedFile := &model.File{ ID: "f-2", UserID: "user-1", Name: "deleted.txt", Status: model.StatusUserDeleted, } if err := createFileRecord(ctx, repo, activeFile); err != nil { t.Fatalf("Create active = %v", err) } if err := createFileRecord(ctx, repo, deletedFile); err != nil { t.Fatalf("Create deleted = %v", err) } _, total, err := repo.FindByUserID(ctx, "user-1", 0, 10) if err != nil { t.Fatalf("FindByUserID = %v", err) } if total != 1 { t.Errorf("total = %d, want 1 (soft-deleted excluded from count)", total) } } func TestFileRepository_UpdateCannotReviveDeletedFile(t *testing.T) { repo := setupFileRepo(t) ctx := context.Background() file := &model.File{ID: "file-1", UserID: "user-1", Name: "before.txt", Status: model.StatusActive} if err := createFileRecord(ctx, repo, file); err != nil { t.Fatalf("Create = %v", err) } if err := repo.SoftDeleteOwned(ctx, file.UserID, file.ID); err != nil { t.Fatalf("SoftDeleteOwned = %v", err) } newName := "after.txt" _, err := repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: file.ID, UserID: file.UserID, NewName: &newName}) if !errors.Is(err, model.ErrNotFound) { t.Fatalf("UpdateOwnedMetadata = %v, want ErrNotFound", err) } if _, err := repo.FindByID(ctx, file.ID); !errors.Is(err, model.ErrNotFound) { t.Fatalf("deleted file became active again: %v", err) } } func TestFileRepository_ConcurrentUpdateAndDeleteCannotReviveFile(t *testing.T) { repo := setupConcurrentFileRepo(t) ctx := context.Background() if _, err := repo.CreateUploadedFile(ctx, UploadedFileParams{ ID: "file-1", UserID: "user-1", Name: "before.txt", StoragePath: "data/user-1/file-1", }); err != nil { t.Fatalf("CreateUploadedFile = %v", err) } newName := "after.txt" start := make(chan struct{}) var wg sync.WaitGroup wg.Add(2) var updateErr, deleteErr error go func() { defer wg.Done() <-start _, updateErr = repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{ FileID: "file-1", UserID: "user-1", NewName: &newName, }) }() go func() { defer wg.Done() <-start deleteErr = repo.SoftDeleteOwned(ctx, "user-1", "file-1") }() close(start) wg.Wait() if deleteErr != nil { t.Fatalf("SoftDeleteOwned = %v, want success", deleteErr) } if updateErr != nil && !errors.Is(updateErr, model.ErrNotFound) { t.Fatalf("UpdateOwnedMetadata = %v, want success or ErrNotFound", updateErr) } if _, err := repo.FindByID(ctx, "file-1"); !errors.Is(err, model.ErrNotFound) { t.Fatalf("concurrent update revived deleted file: %v", err) } } func TestFileRepository_CreateAndParentDeleteAreSerialized(t *testing.T) { repo := setupConcurrentFileRepo(t) ctx := context.Background() dir, err := repo.CreateDirectory(ctx, DirectoryParams{ID: "dir-1", UserID: "user-1", Name: "dir"}) if err != nil { t.Fatalf("CreateDirectory = %v", err) } start := make(chan struct{}) var wg sync.WaitGroup wg.Add(2) var createErr, deleteErr error go func() { defer wg.Done() <-start _, createErr = repo.CreateUploadedFile(ctx, UploadedFileParams{ ID: "child-1", UserID: "user-1", ParentID: &dir.ID, Name: "child.txt", StoragePath: "data/user-1/child-1", }) }() go func() { defer wg.Done() <-start deleteErr = repo.SoftDeleteOwned(ctx, "user-1", dir.ID) }() close(start) wg.Wait() switch { case createErr == nil: if !errors.Is(deleteErr, model.ErrDirectoryNotEmpty) { t.Fatalf("create succeeded but delete error = %v, want ErrDirectoryNotEmpty", deleteErr) } if _, err := repo.FindByID(ctx, dir.ID); err != nil { t.Fatalf("parent missing after child creation: %v", err) } case deleteErr == nil: if !errors.Is(createErr, model.ErrParentNotFound) { t.Fatalf("delete succeeded but create error = %v, want ErrParentNotFound", createErr) } if _, err := repo.FindByID(ctx, "child-1"); !errors.Is(err, model.ErrNotFound) { t.Fatalf("active orphan child exists: %v", err) } default: t.Fatalf("neither operation succeeded: create=%v delete=%v", createErr, deleteErr) } } func TestFileRepository_MoveAndTargetDeleteAreSerialized(t *testing.T) { repo := setupConcurrentFileRepo(t) ctx := context.Background() target, err := repo.CreateDirectory(ctx, DirectoryParams{ID: "target-1", UserID: "user-1", Name: "target"}) if err != nil { t.Fatalf("CreateDirectory = %v", err) } if _, err := repo.CreateUploadedFile(ctx, UploadedFileParams{ID: "file-1", UserID: "user-1", Name: "file.txt", StoragePath: "data/user-1/file-1"}); err != nil { t.Fatalf("CreateUploadedFile = %v", err) } start := make(chan struct{}) var wg sync.WaitGroup wg.Add(2) var moveErr, deleteErr error go func() { defer wg.Done() <-start _, moveErr = repo.UpdateOwnedMetadata(ctx, FileMetadataUpdate{FileID: "file-1", UserID: "user-1", NewParentID: &target.ID}) }() go func() { defer wg.Done() <-start deleteErr = repo.SoftDeleteOwned(ctx, "user-1", target.ID) }() close(start) wg.Wait() switch { case moveErr == nil: if !errors.Is(deleteErr, model.ErrDirectoryNotEmpty) { t.Fatalf("move succeeded but delete error = %v, want ErrDirectoryNotEmpty", deleteErr) } case deleteErr == nil: if !errors.Is(moveErr, model.ErrParentNotFound) { t.Fatalf("delete succeeded but move error = %v, want ErrParentNotFound", moveErr) } file, err := repo.FindByID(ctx, "file-1") if err != nil { t.Fatalf("find source file: %v", err) } if file.ParentID != nil { t.Fatalf("failed move changed source parent to %v", *file.ParentID) } default: t.Fatalf("neither operation succeeded: move=%v delete=%v", moveErr, deleteErr) } }