package api import ( "io" "os" "path/filepath" "testing" "time" "github.com/openaccounting/oa-server/core/model" "github.com/openaccounting/oa-server/core/model/types" "github.com/openaccounting/oa-server/core/util" "github.com/openaccounting/oa-server/core/util/id" "github.com/openaccounting/oa-server/database" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupTestDatabase(t *testing.T) (*gorm.DB, func()) { // Create temporary database file tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) if err != nil { t.Fatalf("Failed to open database: %v", err) } // Set global DB for database package database.DB = db // Run migrations err = database.AutoMigrate() if err != nil { t.Fatalf("Failed to run auto migrations: %v", err) } err = database.Migrate() if err != nil { t.Fatalf("Failed to run custom migrations: %v", err) } // Cleanup function cleanup := func() { sqlDB, _ := db.DB() if sqlDB != nil { sqlDB.Close() } os.RemoveAll(tmpDir) } return db, cleanup } func setupTestData(t *testing.T, db *gorm.DB) (orgID, userID, transactionID string) { // Use hardcoded UUIDs without dashes for hex format orgID = "550e8400e29b41d4a716446655440000" userID = "550e8400e29b41d4a716446655440001" transactionID = "550e8400e29b41d4a716446655440002" accountID := "550e8400e29b41d4a716446655440003" // Insert test data using raw SQL for reliability now := time.Now() // Insert org err := db.Exec("INSERT INTO orgs (id, inserted, updated, name, currency, `precision`, timezone) VALUES (UNHEX(?), ?, ?, ?, ?, ?, ?)", orgID, now.UnixNano()/int64(time.Millisecond), now.UnixNano()/int64(time.Millisecond), "Test Org", "USD", 2, "UTC").Error if err != nil { t.Fatalf("Failed to insert org: %v", err) } // Insert user err = db.Exec("INSERT INTO users (id, inserted, updated, firstName, lastName, email, passwordHash, agreeToTerms, passwordReset, emailVerified, emailVerifyCode, signupSource) VALUES (UNHEX(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", userID, now.UnixNano()/int64(time.Millisecond), now.UnixNano()/int64(time.Millisecond), "Test", "User", "test@example.com", "hashedpassword", true, "", true, "", "test").Error if err != nil { t.Fatalf("Failed to insert user: %v", err) } // Insert user-org relationship err = db.Exec("INSERT INTO user_orgs (userId, orgId, admin) VALUES (UNHEX(?), UNHEX(?), ?)", userID, orgID, false).Error if err != nil { t.Fatalf("Failed to insert user-org: %v", err) } // Insert account err = db.Exec("INSERT INTO accounts (id, orgId, inserted, updated, name, parent, currency, `precision`, debitBalance) VALUES (UNHEX(?), UNHEX(?), ?, ?, ?, ?, ?, ?, ?)", accountID, orgID, now.UnixNano()/int64(time.Millisecond), now.UnixNano()/int64(time.Millisecond), "Test Account", []byte{}, "USD", 2, true).Error if err != nil { t.Fatalf("Failed to insert account: %v", err) } // Insert transaction err = db.Exec("INSERT INTO transactions (id, orgId, userId, inserted, updated, date, description, data, deleted) VALUES (UNHEX(?), UNHEX(?), UNHEX(?), ?, ?, ?, ?, ?, ?)", transactionID, orgID, userID, now.UnixNano()/int64(time.Millisecond), now.UnixNano()/int64(time.Millisecond), now.UnixNano()/int64(time.Millisecond), "Test Transaction", "", false).Error if err != nil { t.Fatalf("Failed to insert transaction: %v", err) } // Insert split err = db.Exec("INSERT INTO splits (transactionId, accountId, date, inserted, updated, amount, nativeAmount, deleted) VALUES (UNHEX(?), UNHEX(?), ?, ?, ?, ?, ?, ?)", transactionID, accountID, now.UnixNano()/int64(time.Millisecond), now.UnixNano()/int64(time.Millisecond), now.UnixNano()/int64(time.Millisecond), 100, 100, false).Error if err != nil { t.Fatalf("Failed to insert split: %v", err) } return orgID, userID, transactionID } func createTestFile(t *testing.T) (string, []byte) { content := []byte("This is a test file content for attachment testing") tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "test.txt") err := os.WriteFile(filePath, content, 0644) if err != nil { t.Fatalf("Failed to create test file: %v", err) } return filePath, content } func TestAttachmentIntegration(t *testing.T) { db, cleanup := setupTestDatabase(t) defer cleanup() orgID, userID, transactionID := setupTestData(t, db) // Set up the model instance for the API handlers bc := &util.StandardBcrypt{} // Use the existing datastore model which has the attachment implementation // We need to create it with the database connection datastoreModel := model.NewModel(nil, bc, types.Config{}) model.Instance = datastoreModel t.Run("Database Integration Test", func(t *testing.T) { // Test direct database operations first filePath, originalContent := createTestFile(t) defer os.Remove(filePath) // Create attachment record directly attachmentID := id.String(id.New()) uploadTime := time.Now() attachment := types.Attachment{ Id: attachmentID, TransactionId: transactionID, OrgId: orgID, UserId: userID, FileName: "stored_test.txt", OriginalName: "test.txt", ContentType: "text/plain", FileSize: int64(len(originalContent)), FilePath: "uploads/test/" + attachmentID + ".txt", Description: "Test attachment description", Uploaded: uploadTime, Deleted: false, } // Insert using the existing model createdAttachment, err := model.Instance.CreateAttachment(&attachment) assert.NoError(t, err) assert.NotNil(t, createdAttachment) assert.Equal(t, attachmentID, createdAttachment.Id) // Verify database persistence var dbAttachment types.Attachment err = db.Raw("SELECT HEX(id) as id, HEX(transactionId) as transactionId, HEX(orgId) as orgId, HEX(userId) as userId, fileName, originalName, contentType, fileSize, filePath, description, uploaded, deleted FROM attachment WHERE HEX(id) = ?", attachmentID).Scan(&dbAttachment).Error assert.NoError(t, err) assert.Equal(t, attachmentID, dbAttachment.Id) assert.Equal(t, transactionID, dbAttachment.TransactionId) assert.Equal(t, "Test attachment description", dbAttachment.Description) // Test retrieval retrievedAttachment, err := model.Instance.GetAttachment(attachmentID, transactionID, orgID, userID) assert.NoError(t, err) assert.NotNil(t, retrievedAttachment) assert.Equal(t, attachmentID, retrievedAttachment.Id) // Test listing by transaction attachments, err := model.Instance.GetAttachmentsByTransaction(transactionID, orgID, userID) assert.NoError(t, err) assert.Len(t, attachments, 1) assert.Equal(t, attachmentID, attachments[0].Id) // Test soft deletion err = model.Instance.DeleteAttachment(attachmentID, transactionID, orgID, userID) assert.NoError(t, err) // Verify soft deletion in database var deletedAttachment types.Attachment err = db.Raw("SELECT deleted FROM attachment WHERE HEX(id) = ?", attachmentID).Scan(&deletedAttachment).Error assert.NoError(t, err) assert.True(t, deletedAttachment.Deleted) // Verify attachment is no longer accessible retrievedAttachment, err = model.Instance.GetAttachment(attachmentID, transactionID, orgID, userID) assert.Error(t, err) assert.Nil(t, retrievedAttachment) }) t.Run("File Upload Integration Test", func(t *testing.T) { // Test file upload functionality filePath, originalContent := createTestFile(t) defer os.Remove(filePath) // Create upload directory uploadDir := "uploads/test" os.MkdirAll(uploadDir, 0755) defer os.RemoveAll("uploads") // Simulate file upload process attachmentID := id.String(id.New()) storedFilePath := filepath.Join(uploadDir, attachmentID+".txt") // Copy file to upload location err := copyFile(filePath, storedFilePath) assert.NoError(t, err) // Create attachment record attachment := types.Attachment{ Id: attachmentID, TransactionId: transactionID, OrgId: orgID, UserId: userID, FileName: filepath.Base(storedFilePath), OriginalName: "test.txt", ContentType: "text/plain", FileSize: int64(len(originalContent)), FilePath: storedFilePath, Description: "Uploaded test file", Uploaded: time.Now(), Deleted: false, } createdAttachment, err := model.Instance.CreateAttachment(&attachment) assert.NoError(t, err) assert.NotNil(t, createdAttachment) // Verify file exists _, err = os.Stat(storedFilePath) assert.NoError(t, err) // Verify database record retrievedAttachment, err := model.Instance.GetAttachment(attachmentID, transactionID, orgID, userID) assert.NoError(t, err) assert.Equal(t, storedFilePath, retrievedAttachment.FilePath) assert.Equal(t, int64(len(originalContent)), retrievedAttachment.FileSize) }) } // Helper function to copy files func copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() destFile, err := os.Create(dst) if err != nil { return err } defer destFile.Close() _, err = io.Copy(destFile, sourceFile) return err } func TestAttachmentValidation(t *testing.T) { db, cleanup := setupTestDatabase(t) defer cleanup() orgID, userID, transactionID := setupTestData(t, db) // Set up the model instance bc := &util.StandardBcrypt{} gormModel := model.NewGormModel(db, bc, types.Config{}) model.Instance = gormModel t.Run("Invalid attachment data", func(t *testing.T) { // Test with missing required fields attachment := types.Attachment{ // Missing ID TransactionId: transactionID, OrgId: orgID, UserId: userID, } createdAttachment, err := model.Instance.CreateAttachment(&attachment) assert.Error(t, err) assert.Nil(t, createdAttachment) }) t.Run("Non-existent attachment retrieval", func(t *testing.T) { nonExistentID := id.String(id.New()) attachment, err := model.Instance.GetAttachment(nonExistentID, transactionID, orgID, userID) assert.Error(t, err) assert.Nil(t, attachment) }) }