diff --git a/backend/auth/handlers_test.go b/backend/auth/handlers_test.go index f6b3be9..d4ef4a6 100644 --- a/backend/auth/handlers_test.go +++ b/backend/auth/handlers_test.go @@ -3,6 +3,7 @@ package auth import ( "bytes" "context" + "database/sql" "encoding/json" "errors" "net/http" @@ -34,11 +35,15 @@ func (f *fakeDB) CreateUser(ctx context.Context, email, passHash string) (int64, } // Unused methods to satisfy interface: -func (f *fakeDB) Connect(dsn string) error { return nil } -func (f *fakeDB) Close() error { return nil } -func (f *fakeDB) Migrate() error { return nil } -func (f *fakeDB) SomeUnusedMethod1() { /* no-op */ } -func (f *fakeDB) SomeUnusedMethod2(arg interface{}) (interface{}, error) { return nil, nil } +func (f *fakeDB) Connect(dsn string) error { return nil } +func (f *fakeDB) Close() error { return nil } +func (f *fakeDB) Migrate() error { return nil } +func (f *fakeDB) SearchSpecies(ctx context.Context, query string, limit, offset int) ([]*model.Species, error) { + return nil, nil +} +func (f *fakeDB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { + return nil, nil +} //----------------------- // Tests for HandleLogin diff --git a/backend/db/interface.go b/backend/db/interface.go index 8d561ea..c52f7ea 100644 --- a/backend/db/interface.go +++ b/backend/db/interface.go @@ -2,6 +2,7 @@ package db import ( "context" + "database/sql" "github.com/anish-chanda/ferna/model" ) @@ -14,9 +15,14 @@ type Database interface { CheckIfEmailExists(ctx context.Context, email string) (bool, error) CreateUser(ctx context.Context, email, passHash string) (int64, error) //returns userid GetUserByEmail(ctx context.Context, email string) (*model.User, error) - // UpdatePassword(userID int64, passHash string) error - // driver funcs + // Plant functions + + // SearchSpecies searches for species by common or scientific name. + SearchSpecies(ctx context.Context, query string, limit, offset int) ([]*model.Species, error) + + // Database operations + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) // establishes a connection to the database. Connect(dsn string) error diff --git a/backend/db/sqlite3/sqlite3.go b/backend/db/sqlite3/sqlite3.go index 1983a17..ee657eb 100644 --- a/backend/db/sqlite3/sqlite3.go +++ b/backend/db/sqlite3/sqlite3.go @@ -123,3 +123,61 @@ func (s *SQLiteDB) GetUserByEmail(ctx context.Context, email string) (*model.Use return &user, nil } + +func (s *SQLiteDB) SearchSpecies(ctx context.Context, query string, limit, offset int) ([]*model.Species, error) { + pattern := "%" + query + "%" + rows, err := s.db.QueryContext(ctx, ` + SELECT id, common_name, scientific_name, + default_watering_frequency_days, created_at, updated_at + FROM species + WHERE common_name LIKE ? + OR scientific_name LIKE ? + ORDER BY common_name + LIMIT ? OFFSET ?`, + pattern, pattern, limit, offset, + ) + if err != nil { + return nil, fmt.Errorf("error searching species query: %w", err) + } + + defer rows.Close() + + var res []*model.Species + for rows.Next() { + var sp model.Species + var created, updated string + if err := rows.Scan(&sp.ID, + &sp.CommonName, + &sp.ScientificName, + &sp.DefaultWateringFrequency, + &created, + &updated); err != nil { + return nil, fmt.Errorf("scan species: %w", err) + } + + //parse time + sp.CreatedAt, err = time.Parse(time.RFC3339, created) + if err != nil { + return nil, fmt.Errorf("parse created_at: %w", err) + } + sp.UpdatedAt, err = time.Parse(time.RFC3339, updated) + if err != nil { + return nil, fmt.Errorf("parse updated_at: %w", err) + } + res = append(res, &sp) + + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate species rows: %w", err) + } + return res, nil +} + +// ExecContext executes a query with context that doesn't return rows, like insert, delete etc +func (s *SQLiteDB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { + result, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) + } + return result, nil +} diff --git a/backend/db/sqlite3/sqlite3_test.go b/backend/db/sqlite3/sqlite3_test.go index 1fa433f..f84c3a3 100644 --- a/backend/db/sqlite3/sqlite3_test.go +++ b/backend/db/sqlite3/sqlite3_test.go @@ -189,3 +189,143 @@ func TestCloseThenUse(t *testing.T) { require.Error(t, err, "GetUserByEmail after Close() should error") assert.Contains(t, err.Error(), "database is closed", "Error should mention that the DB is closed") } + +// TestExecContext verifies that ExecContext works correctly for INSERT, UPDATE, DELETE operations +func TestExecContext_Insert(t *testing.T) { + sq := setupInMemoryDB(t) + defer sq.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Test INSERT using ExecContext + result, err := sq.ExecContext(ctx, + `INSERT INTO species(common_name, scientific_name, default_watering_frequency_days) VALUES (?, ?, ?)`, + "Test Plant", "Testus plantus", 7) + require.NoError(t, err) + + // Check that the insert was successful + rowsAffected, err := result.RowsAffected() + require.NoError(t, err) + assert.Equal(t, int64(1), rowsAffected) + + // Verify the record was inserted by searching for it + species, err := sq.SearchSpecies(ctx, "Test Plant", 10, 0) + require.NoError(t, err) + require.Len(t, species, 1) + assert.Equal(t, "Test Plant", species[0].CommonName) + assert.Equal(t, "Testus plantus", species[0].ScientificName) + assert.Equal(t, 7, species[0].DefaultWateringFrequency) +} + +func TestExecContext_InsertOrIgnore(t *testing.T) { + sq := setupInMemoryDB(t) + defer sq.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // First insert + result1, err := sq.ExecContext(ctx, + `INSERT OR IGNORE INTO species(common_name, scientific_name, default_watering_frequency_days) VALUES (?, ?, ?)`, + "Rose", "Rosa rubiginosa", 3) + require.NoError(t, err) + + rowsAffected, err := result1.RowsAffected() + require.NoError(t, err) + assert.Equal(t, int64(1), rowsAffected) + + // Second insert with same scientific_name should be ignored due to UNIQUE constraint + result2, err := sq.ExecContext(ctx, + `INSERT OR IGNORE INTO species(common_name, scientific_name, default_watering_frequency_days) VALUES (?, ?, ?)`, + "Different Rose", "Rosa rubiginosa", 5) + require.NoError(t, err) + + rowsAffected, err = result2.RowsAffected() + require.NoError(t, err) + assert.Equal(t, int64(0), rowsAffected) // Should be 0 because it was ignored + + // Verify only one record exists + species, err := sq.SearchSpecies(ctx, "Rosa rubiginosa", 10, 0) + require.NoError(t, err) + require.Len(t, species, 1) + assert.Equal(t, "Rose", species[0].CommonName) // Should still be the original +} + +func TestExecContext_Update(t *testing.T) { + sq := setupInMemoryDB(t) + defer sq.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // First insert a record + _, err := sq.ExecContext(ctx, + `INSERT INTO species(common_name, scientific_name, default_watering_frequency_days) VALUES (?, ?, ?)`, + "Cactus", "Cactaceae genericus", 14) + require.NoError(t, err) + + // Update the watering frequency + result, err := sq.ExecContext(ctx, + `UPDATE species SET default_watering_frequency_days = ? WHERE scientific_name = ?`, + 21, "Cactaceae genericus") + require.NoError(t, err) + + rowsAffected, err := result.RowsAffected() + require.NoError(t, err) + assert.Equal(t, int64(1), rowsAffected) + + // Verify the update + species, err := sq.SearchSpecies(ctx, "Cactus", 10, 0) + require.NoError(t, err) + require.Len(t, species, 1) + assert.Equal(t, 21, species[0].DefaultWateringFrequency) +} + +func TestExecContext_Delete(t *testing.T) { + sq := setupInMemoryDB(t) + defer sq.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // First insert a record + _, err := sq.ExecContext(ctx, + `INSERT INTO species(common_name, scientific_name, default_watering_frequency_days) VALUES (?, ?, ?)`, + "Temporary Plant", "Temporarius plantus", 1) + require.NoError(t, err) + + // Verify it exists + species, err := sq.SearchSpecies(ctx, "Temporary Plant", 10, 0) + require.NoError(t, err) + require.Len(t, species, 1) + + // Delete the record + result, err := sq.ExecContext(ctx, + `DELETE FROM species WHERE scientific_name = ?`, + "Temporarius plantus") + require.NoError(t, err) + + rowsAffected, err := result.RowsAffected() + require.NoError(t, err) + assert.Equal(t, int64(1), rowsAffected) + + // Verify it's gone + species, err = sq.SearchSpecies(ctx, "Temporary Plant", 10, 0) + require.NoError(t, err) + assert.Len(t, species, 0) +} + +func TestExecContext_CanceledContext(t *testing.T) { + sq := setupInMemoryDB(t) + defer sq.Close() + + // Create a context and cancel it immediately + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) + cancel() + time.Sleep(5 * time.Millisecond) // allow cancellation to propagate + + // ExecContext with canceled context should error + _, err := sq.ExecContext(ctx, + `INSERT INTO species(common_name, scientific_name, default_watering_frequency_days) VALUES (?, ?, ?)`, + "Test", "Test", 1) + require.Error(t, err, "ExecContext should error on canceled context") + assert.True(t, errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), + "Error should be context.Canceled or DeadlineExceeded") +} diff --git a/backend/handlers/species.go b/backend/handlers/species.go new file mode 100644 index 0000000..e1548de --- /dev/null +++ b/backend/handlers/species.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/anish-chanda/ferna/db" +) + +func SearchSpecies(database db.Database) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("query") + + // parse limit (default 20) + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if v, err := strconv.Atoi(l); err == nil { + limit = v + } + } + + // parse offset (default 0) + offset := 0 + if o := r.URL.Query().Get("offset"); o != "" { + if v, err := strconv.Atoi(o); err == nil { + offset = v + } + } + + species, err := database.SearchSpecies(r.Context(), q, limit, offset) + if err != nil { + http.Error(w, "failed to search species: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(species) + } +} diff --git a/backend/main.go b/backend/main.go index 842c471..e80cef9 100644 --- a/backend/main.go +++ b/backend/main.go @@ -12,6 +12,8 @@ import ( myAuth "github.com/anish-chanda/ferna/auth" "github.com/anish-chanda/ferna/db" "github.com/anish-chanda/ferna/db/sqlite3" + "github.com/anish-chanda/ferna/handlers" + "github.com/anish-chanda/ferna/seed" "github.com/go-pkgz/auth/v2" "github.com/go-pkgz/auth/v2/avatar" "github.com/go-pkgz/auth/v2/provider" @@ -55,6 +57,12 @@ func main() { log.Fatalf("run migrations: %v", err) } + // Seed the species table + fmt.Println("Seeding species data...") + if err := seed.SeedSpecies(context.Background(), database); err != nil { + log.Fatalf("seed species: %v", err) + } + fmt.Println("Database connected and migrated successfully!") // setup auth options @@ -99,6 +107,9 @@ func main() { r.PathPrefix("/auth").Handler(authRoutes) r.PathPrefix("/avatar").Handler(avaRoutes) + // register species endpoint + r.HandleFunc("/api/plants/species", handlers.SearchSpecies(database)).Methods("GET") + fmt.Println("Server is running on port 8080...") http.ListenAndServe(":8080", r) } diff --git a/backend/migrations/sqlite3/0001_create_users_table.down.sql b/backend/migrations/sqlite3/0001_create_users_table.down.sql index 365a210..2154f1d 100644 --- a/backend/migrations/sqlite3/0001_create_users_table.down.sql +++ b/backend/migrations/sqlite3/0001_create_users_table.down.sql @@ -1 +1,11 @@ +-- Drop triggers +DROP TRIGGER IF EXISTS update_species_updated_at; +DROP TRIGGER IF EXISTS update_users_updated_at; + +-- Drop indexes +DROP INDEX IF EXISTS idx_species_common_name; +DROP INDEX IF EXISTS idx_species_scientific_name; + +-- Drop tables +DROP TABLE IF EXISTS species; DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/backend/migrations/sqlite3/0001_create_users_table.up.sql b/backend/migrations/sqlite3/0001_create_users_table.up.sql index 8a35792..5c64536 100644 --- a/backend/migrations/sqlite3/0001_create_users_table.up.sql +++ b/backend/migrations/sqlite3/0001_create_users_table.up.sql @@ -4,4 +4,36 @@ CREATE TABLE IF NOT EXISTS users ( pass_hash TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP -) \ No newline at end of file +); + +CREATE TABLE IF NOT EXISTS species ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + common_name TEXT NOT NULL, + scientific_name TEXT UNIQUE, + default_watering_frequency_days INTEGER NOT NULL DEFAULT 7, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- triggers, updates updated_at for users and species tables +CREATE TRIGGER IF NOT EXISTS update_species_updated_at +AFTER UPDATE ON species +FOR EACH ROW +BEGIN + UPDATE species + SET updated_at = CURRENT_TIMESTAMP + WHERE id = OLD.id; +END; + +CREATE TRIGGER IF NOT EXISTS update_users_updated_at +AFTER UPDATE ON users +FOR EACH ROW +BEGIN + UPDATE users + SET updated_at = CURRENT_TIMESTAMP + WHERE id = OLD.id; +END; + +-- indexes +CREATE INDEX IF NOT EXISTS idx_species_common_name ON species(common_name); +CREATE INDEX IF NOT EXISTS idx_species_scientific_name ON species(scientific_name); diff --git a/backend/model/models.go b/backend/model/models.go new file mode 100644 index 0000000..cde9eda --- /dev/null +++ b/backend/model/models.go @@ -0,0 +1,20 @@ +package model + +import "time" + +type User struct { + ID int64 + Email string + PassHash string + CreatedAt time.Time + UpdatedAt time.Time +} + +type Species struct { + ID int64 `json:"id"` + CommonName string `json:"common_name"` + ScientificName string `json:"scientific_name,omitempty"` + DefaultWateringFrequency int `json:"default_watering_frequency_days"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/model/user.go b/backend/model/user.go deleted file mode 100644 index d252bca..0000000 --- a/backend/model/user.go +++ /dev/null @@ -1,11 +0,0 @@ -package model - -import "time" - -type User struct { - ID int64 - Email string - PassHash string - CreatedAt time.Time - UpdatedAt time.Time -} diff --git a/backend/seed/species.csv b/backend/seed/species.csv new file mode 100644 index 0000000..e9b1ac7 --- /dev/null +++ b/backend/seed/species.csv @@ -0,0 +1,11 @@ +common_name,scientific_name,default_watering_frequency_days +Monstera,Monstera deliciosa,7 +Snake Plant,Dracaena trifasciata,21 +Pothos,Epipremnum aureum,10 +ZZ Plant,Zamioculcas zamiifolia,21 +Fiddle Leaf Fig,Ficus lyrata,10 +Spider Plant,Chlorophytum comosum,7 +Peace Lily,Spathiphyllum,7 +Chinese Money Plant,Pilea peperomioides,10 +Philodendron,Philodendron hederaceum,10 +Areca Palm,Dypsis lutescens,10 \ No newline at end of file diff --git a/backend/seed/species.go b/backend/seed/species.go new file mode 100644 index 0000000..dc067e6 --- /dev/null +++ b/backend/seed/species.go @@ -0,0 +1,47 @@ +package seed + +import ( + "context" + "embed" + "encoding/csv" + "fmt" + "io" + "strconv" + + "github.com/anish-chanda/ferna/db" +) + +//go:embed species.csv +var speciesCSV embed.FS + +func SeedSpecies(ctx context.Context, database db.Database) error { + f, err := speciesCSV.Open("species.csv") + if err != nil { + return fmt.Errorf("open embedded CSV: %w", err) + } + defer f.Close() + rdr := csv.NewReader(f) + // skip header + if _, err := rdr.Read(); err != nil { + return fmt.Errorf("read header: %w", err) + } + + for { + record, err := rdr.Read() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("csv parse: %w", err) + } + freq, _ := strconv.Atoi(record[2]) + + // Use INSERT OR IGNORE to handle duplicates as scientific name is unique + if _, err := database.ExecContext(ctx, + `INSERT OR IGNORE INTO species(common_name, scientific_name, default_watering_frequency_days) VALUES (?, ?, ?)`, + record[0], record[1], freq); err != nil { + return fmt.Errorf("insert species: %w", err) + } + } + return nil +}