From f95c232b2d69f643c8487b816ea85efd09e18be4 Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Mon, 23 Jun 2025 13:18:05 -0500 Subject: [PATCH 01/12] add uid to claims --- .gitignore | 3 ++- backend/main.go | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 0633bed..e05c5d6 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,8 @@ app.*.map.json /android/app/profile /android/app/release - +#ignore data dir +data/ # ignore build outputs bin/ diff --git a/backend/main.go b/backend/main.go index e6d06b5..842c471 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "log" "net/http" @@ -65,7 +66,19 @@ func main() { CookieDuration: time.Hour * 24, // cookie expires in 1 day and will enforce re-login Issuer: "ferna", URL: baseUrl, - AvatarStore: avatar.NewLocalFS("/tmp"), + DisableXSRF: true, + ClaimsUpd: token.ClaimsUpdFunc(func(cl token.Claims) token.Claims { + if cl.User.Name == "" { + return cl + } + u, err := database.GetUserByEmail(context.TODO(), cl.User.Name) + if err != nil || u == nil { + return cl + } + cl.User.SetStrAttr("uid", fmt.Sprint(u.ID)) + return cl + }), + AvatarStore: avatar.NewLocalFS("/tmp"), } // create auth service with providers From c1bd477a53adf2720049f1a422f31ef22fc58369 Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Tue, 24 Jun 2025 15:45:09 -0500 Subject: [PATCH 02/12] update sql tables --- .../sqlite3/0001_create_users_table.down.sql | 10 ++++++ .../sqlite3/0001_create_users_table.up.sql | 34 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) 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); From 9715e1f3a9b109a8b75db4d121afc66c9ef32792 Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Tue, 24 Jun 2025 15:45:35 -0500 Subject: [PATCH 03/12] add species db funcs --- backend/db/interface.go | 10 ++++-- backend/db/sqlite3/sqlite3.go | 58 +++++++++++++++++++++++++++++++++++ backend/model/models.go | 20 ++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 backend/model/models.go 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/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"` +} From b22bd56889e1ad3e4bd1ab054f3af46be922d384 Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Tue, 24 Jun 2025 15:46:14 -0500 Subject: [PATCH 04/12] seed species table --- backend/main.go | 8 +++++++ backend/seed/species.csv | 11 ++++++++++ backend/seed/species.go | 47 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 backend/seed/species.csv create mode 100644 backend/seed/species.go diff --git a/backend/main.go b/backend/main.go index 842c471..be9a982 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 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 +} From 96650b7317e7f8a7829112e720882db30c16047d Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Tue, 24 Jun 2025 15:46:29 -0500 Subject: [PATCH 05/12] add http handler for species queries --- backend/handlers/species.go | 40 +++++++++++++++++++++++++++++++++++++ backend/main.go | 3 +++ backend/model/user.go | 11 ---------- 3 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 backend/handlers/species.go delete mode 100644 backend/model/user.go 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 be9a982..e80cef9 100644 --- a/backend/main.go +++ b/backend/main.go @@ -107,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/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 -} From 721f38a1a2d9d1a27fc515ec96bf9a491429c7bd Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Tue, 24 Jun 2025 15:48:15 -0500 Subject: [PATCH 06/12] update tests --- backend/auth/handlers_test.go | 15 ++-- backend/db/sqlite3/sqlite3_test.go | 140 +++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 5 deletions(-) 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/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") +} From 1595d94bb24e843a1f5d4a466e592eac07bc6154 Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Thu, 26 Jun 2025 12:55:11 -0500 Subject: [PATCH 07/12] update schema --- .../sqlite3/0001_create_users_table.down.sql | 4 ++- .../sqlite3/0001_create_users_table.up.sql | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/backend/migrations/sqlite3/0001_create_users_table.down.sql b/backend/migrations/sqlite3/0001_create_users_table.down.sql index 2154f1d..75ce8fe 100644 --- a/backend/migrations/sqlite3/0001_create_users_table.down.sql +++ b/backend/migrations/sqlite3/0001_create_users_table.down.sql @@ -1,6 +1,7 @@ -- Drop triggers DROP TRIGGER IF EXISTS update_species_updated_at; DROP TRIGGER IF EXISTS update_users_updated_at; +DROP TRIGGER IF EXISTS update_plants_updated_at; -- Drop indexes DROP INDEX IF EXISTS idx_species_common_name; @@ -8,4 +9,5 @@ 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 +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS plants; \ 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 5c64536..48ca742 100644 --- a/backend/migrations/sqlite3/0001_create_users_table.up.sql +++ b/backend/migrations/sqlite3/0001_create_users_table.up.sql @@ -15,6 +15,23 @@ CREATE TABLE IF NOT EXISTS species ( updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS plants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL + REFERENCES users(id) + ON DELETE CASCADE, + species_id INTEGER NOT NULL + REFERENCES species(id) + ON DELETE RESTRICT, + nickname TEXT, + image_url TEXT, + watering_frequency_days INTEGER NOT NULL, + last_watered_at DATETIME, + note TEXT, + 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 @@ -34,6 +51,15 @@ BEGIN WHERE id = OLD.id; END; +CREATE TRIGGER IF NOT EXISTS update_plants_updated_at +AFTER UPDATE ON plants +FOR EACH ROW +BEGIN + UPDATE plants + 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); From db64fdbce5b8a3f658e19bdfe0e4edd944f4f05b Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Thu, 26 Jun 2025 12:55:39 -0500 Subject: [PATCH 08/12] update database adapter --- backend/db/interface.go | 24 ++++++-- backend/db/sqlite3/sqlite3.go | 108 ++++++++++++++++++++++++++++++++++ backend/model/models.go | 14 +++++ 3 files changed, 141 insertions(+), 5 deletions(-) diff --git a/backend/db/interface.go b/backend/db/interface.go index c52f7ea..190ee37 100644 --- a/backend/db/interface.go +++ b/backend/db/interface.go @@ -8,25 +8,39 @@ import ( ) type Database interface { - // user auth methods + // ---- auth stuff ---- // CheckIfEmailExists returns true if a user record with the given email exists. // It returns (false, nil) if not found, or (false, err) on error. CheckIfEmailExists(ctx context.Context, email string) (bool, error) - CreateUser(ctx context.Context, email, passHash string) (int64, error) //returns userid + // inserts a new user with the given email and password hash and returns the new user ID. + CreateUser(ctx context.Context, email, passHash string) (int64, error) + // fetches a user by email. GetUserByEmail(ctx context.Context, email string) (*model.User, error) - // Plant functions - + // ---- plant stuff ---- + + // CreatePlant inserts a new plant, returning its new ID. + CreatePlant(ctx context.Context, p *model.Plant) (int64, error) + // GetPlantByID fetches a single plant by user+plant ID. + GetPlantByID(ctx context.Context, userID, plantID int64) (*model.Plant, error) + // ListPlants returns a paginated list of a user’s plants. + ListPlants(ctx context.Context, userID int64, limit, offset int) ([]*model.Plant, error) + // UpdatePlant updates all mutable fields on the given plant. + UpdatePlant(ctx context.Context, p *model.Plant) error + // DeletePlant removes a plant by userplant ID. + DeletePlant(ctx context.Context, userID, plantID int64) error // SearchSpecies searches for species by common or scientific name. SearchSpecies(ctx context.Context, query string, limit, offset int) ([]*model.Species, error) + // ---- other stuff ----- + // Database operations ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) - // establishes a connection to the database. Connect(dsn string) error // Close closes the database connection. Close() error + // Migrate runs database migrations using go-migrate and embedded sql files to ensure the schema is up-to-date. Migrate() error } diff --git a/backend/db/sqlite3/sqlite3.go b/backend/db/sqlite3/sqlite3.go index ee657eb..be065dc 100644 --- a/backend/db/sqlite3/sqlite3.go +++ b/backend/db/sqlite3/sqlite3.go @@ -181,3 +181,111 @@ func (s *SQLiteDB) ExecContext(ctx context.Context, query string, args ...interf } return result, nil } + +// CreatePlant inserts a new plant and returns its ID. +func (s *SQLiteDB) CreatePlant(ctx context.Context, p *model.Plant) (int64, error) { + res, err := s.db.ExecContext(ctx, ` + INSERT INTO plants ( + user_id, species_id, nickname, image_url, + watering_frequency_days, last_watered_at, note + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + p.UserID, p.SpeciesID, p.Nickname, p.ImageURL, + p.WateringFrequencyDays, p.LastWateredAt, p.Note, + ) + if err != nil { + return 0, fmt.Errorf("CreatePlant: %w", err) + } + return res.LastInsertId() +} + +// GetPlantByID fetches one plant, ensuring it belongs to the user. +func (s *SQLiteDB) GetPlantByID(ctx context.Context, userID, plantID int64) (*model.Plant, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, user_id, species_id, nickname, image_url, + watering_frequency_days, last_watered_at, note, + created_at, updated_at + FROM plants + WHERE id = ? AND user_id = ?`, plantID, userID) + + var p model.Plant + var lw, created, updated sql.NullString + if err := row.Scan( + &p.ID, &p.UserID, &p.SpeciesID, &p.Nickname, &p.ImageURL, + &p.WateringFrequencyDays, &lw, &p.Note, + &created, &updated, + ); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("GetPlantByID: %w", err) + } + if lw.Valid { + t, err := time.Parse(time.RFC3339, lw.String) + if err == nil { + p.LastWateredAt = &t + } + } + p.CreatedAt, _ = time.Parse(time.RFC3339, created.String) + p.UpdatedAt, _ = time.Parse(time.RFC3339, updated.String) + return &p, nil +} + +// ListPlants returns a page of plants for a user. +func (s *SQLiteDB) ListPlants(ctx context.Context, userID int64, limit, offset int) ([]*model.Plant, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, user_id, species_id, nickname, image_url, + watering_frequency_days, last_watered_at, note, + created_at, updated_at + FROM plants + WHERE user_id = ? + ORDER BY id + LIMIT ? OFFSET ?`, userID, limit, offset) + if err != nil { + return nil, fmt.Errorf("ListPlants: %w", err) + } + defer rows.Close() + + var list []*model.Plant + for rows.Next() { + var p model.Plant + var lw, created, updated sql.NullString + if err := rows.Scan( + &p.ID, &p.UserID, &p.SpeciesID, &p.Nickname, &p.ImageURL, + &p.WateringFrequencyDays, &lw, &p.Note, + &created, &updated, + ); err != nil { + return nil, fmt.Errorf("ListPlants scan: %w", err) + } + if lw.Valid { + t, err := time.Parse(time.RFC3339, lw.String) + if err == nil { + p.LastWateredAt = &t + } + } + p.CreatedAt, _ = time.Parse(time.RFC3339, created.String) + p.UpdatedAt, _ = time.Parse(time.RFC3339, updated.String) + list = append(list, &p) + } + return list, rows.Err() +} + +// UpdatePlant updates an existing plant’s mutable fields. +func (s *SQLiteDB) UpdatePlant(ctx context.Context, p *model.Plant) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE plants + SET species_id = ?, nickname = ?, image_url = ?, + watering_frequency_days = ?, last_watered_at = ?, note = ? + WHERE id = ? AND user_id = ?`, + p.SpeciesID, p.Nickname, p.ImageURL, + p.WateringFrequencyDays, p.LastWateredAt, p.Note, + p.ID, p.UserID, + ) + return fmt.Errorf("UpdatePlant: %w", err) +} + +// DeletePlant removes a plant by its ID and owner. +func (s *SQLiteDB) DeletePlant(ctx context.Context, userID, plantID int64) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM plants WHERE id = ? AND user_id = ?`, plantID, userID) + return fmt.Errorf("DeletePlant: %w", err) +} diff --git a/backend/model/models.go b/backend/model/models.go index cde9eda..6848aa1 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -18,3 +18,17 @@ type Species struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +// Plant represents a user’s individual plant. +type Plant struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + SpeciesID int64 `json:"species_id"` + Nickname *string `json:"nickname"` + ImageURL *string `json:"image_url"` + WateringFrequencyDays int `json:"watering_frequency_days"` + LastWateredAt *time.Time `json:"last_watered_at"` + Note *string `json:"note"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} From d7b7151c05fc9950f18d41f641fdc7c938bda52a Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Mon, 30 Jun 2025 19:22:17 -0500 Subject: [PATCH 09/12] add getSpecies by Id --- backend/db/sqlite3/sqlite3.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/db/sqlite3/sqlite3.go b/backend/db/sqlite3/sqlite3.go index be065dc..4d367eb 100644 --- a/backend/db/sqlite3/sqlite3.go +++ b/backend/db/sqlite3/sqlite3.go @@ -173,6 +173,29 @@ func (s *SQLiteDB) SearchSpecies(ctx context.Context, query string, limit, offse return res, nil } +func (s *SQLiteDB) GetSpeciesByID(ctx context.Context, speciesID int64) (*model.Species, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, common_name, scientific_name, default_watering_frequency_days, created_at, updated_at + FROM species + WHERE id = ?`, speciesID) + + var sp model.Species + var created, updated sql.NullString + if err := row.Scan( + &sp.ID, &sp.CommonName, &sp.ScientificName, &sp.DefaultWateringFrequency, + &created, &updated, + ); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("GetSpeciesByID: %w", err) + } + + sp.CreatedAt, _ = time.Parse(time.RFC3339, created.String) + sp.UpdatedAt, _ = time.Parse(time.RFC3339, updated.String) + return &sp, 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...) From c560ee6d80852f14ad3000526c21f3162ea5acde Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Mon, 30 Jun 2025 19:22:34 -0500 Subject: [PATCH 10/12] fix sqlite3 adapter --- backend/db/sqlite3/sqlite3.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/db/sqlite3/sqlite3.go b/backend/db/sqlite3/sqlite3.go index 4d367eb..b301d8b 100644 --- a/backend/db/sqlite3/sqlite3.go +++ b/backend/db/sqlite3/sqlite3.go @@ -303,12 +303,18 @@ func (s *SQLiteDB) UpdatePlant(ctx context.Context, p *model.Plant) error { p.WateringFrequencyDays, p.LastWateredAt, p.Note, p.ID, p.UserID, ) - return fmt.Errorf("UpdatePlant: %w", err) + if err != nil { + return fmt.Errorf("UpdatePlant: %w", err) + } + return nil } // DeletePlant removes a plant by its ID and owner. func (s *SQLiteDB) DeletePlant(ctx context.Context, userID, plantID int64) error { _, err := s.db.ExecContext(ctx, `DELETE FROM plants WHERE id = ? AND user_id = ?`, plantID, userID) - return fmt.Errorf("DeletePlant: %w", err) + if err != nil { + return fmt.Errorf("DeletePlant: %w", err) + } + return nil } From 21aa1fcd48d92697302b28c30b9f0b99fb1c3b18 Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Mon, 30 Jun 2025 19:23:06 -0500 Subject: [PATCH 11/12] add http handlers for plant crud --- backend/db/interface.go | 2 + backend/handlers/helpers.go | 33 ++++++ backend/handlers/plants.go | 220 ++++++++++++++++++++++++++++++++++++ backend/main.go | 20 +++- 4 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 backend/handlers/helpers.go create mode 100644 backend/handlers/plants.go diff --git a/backend/db/interface.go b/backend/db/interface.go index 190ee37..831b755 100644 --- a/backend/db/interface.go +++ b/backend/db/interface.go @@ -32,6 +32,8 @@ type Database interface { DeletePlant(ctx context.Context, userID, plantID int64) error // SearchSpecies searches for species by common or scientific name. SearchSpecies(ctx context.Context, query string, limit, offset int) ([]*model.Species, error) + // get species with a ID + GetSpeciesByID(ctx context.Context, speciesID int64) (*model.Species, error) // ---- other stuff ----- diff --git a/backend/handlers/helpers.go b/backend/handlers/helpers.go new file mode 100644 index 0000000..a1a6400 --- /dev/null +++ b/backend/handlers/helpers.go @@ -0,0 +1,33 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + + "github.com/go-pkgz/auth/v2/token" +) + +// extracts the user ID from the JWT claims in the request context. +func GetUserIDFromRequest(r *http.Request) (int64, error) { + // First check if this is a test context with mocked user + // TODO: There must be a better way to test.... + if userCtx := r.Context().Value("user"); userCtx != nil { + if userMap, ok := userCtx.(map[string]interface{}); ok { + if uid, ok := userMap["uid"].(string); ok { + return strconv.ParseInt(uid, 10, 64) + } + } + } + + // Production code path using JWT token + user, err := token.GetUserInfo(r) + if err != nil { + return 0, errors.New("Failed to get user info") + } + userID, err := strconv.ParseInt(user.StrAttr("uid"), 10, 64) + if err != nil { + return 0, errors.New("Invalid user ID in token") + } + return userID, nil +} diff --git a/backend/handlers/plants.go b/backend/handlers/plants.go new file mode 100644 index 0000000..0edaed2 --- /dev/null +++ b/backend/handlers/plants.go @@ -0,0 +1,220 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/anish-chanda/ferna/db" + "github.com/anish-chanda/ferna/model" + "github.com/gorilla/mux" +) + +// Creates a plant for the user. Requires userID from JWT token in request context +func CreatePlant(database db.Database) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var p model.Plant + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get user ID + userID, err := GetUserIDFromRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + p.UserID = userID + + // validate required fielda + if p.SpeciesID == 0 { + http.Error(w, "species_id is required", http.StatusBadRequest) + return + } + + // get species + species, err := database.GetSpeciesByID(r.Context(), p.SpeciesID) + if err != nil { + http.Error(w, "Failed to get species: "+err.Error(), http.StatusInternalServerError) + return + } + if species == nil { + http.Error(w, "Species not found", http.StatusBadRequest) + return + } + + // set nickname and wattering frequency if not provided + if p.Nickname == nil || *p.Nickname == "" { + p.Nickname = &species.CommonName + } + if p.WateringFrequencyDays <= 0 { + p.WateringFrequencyDays = species.DefaultWateringFrequency + } + + id, err := database.CreatePlant(r.Context(), &p) + if err != nil { + http.Error(w, "Failed to create plant", http.StatusInternalServerError) + return + } + + p.ID = id + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(p) + } +} + +func GetPlant(database db.Database) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + plantID, err := strconv.ParseInt(mux.Vars(r)["plantID"], 10, 64) + if err != nil { + http.Error(w, "Invalid plant ID", http.StatusBadRequest) + return + } + + userID, err := GetUserIDFromRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + plant, err := database.GetPlantByID(r.Context(), userID, plantID) + if err != nil { + http.Error(w, "Failed to get plant: "+err.Error(), http.StatusInternalServerError) + return + } + if plant == nil { + http.Error(w, "Plant not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(plant) + } +} + +func UpdatePlant(database db.Database) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + plantID, err := strconv.ParseInt(mux.Vars(r)["plantID"], 10, 64) + if err != nil { + http.Error(w, "Invalid plant ID", http.StatusBadRequest) + return + } + + // Parse the partial update request first + var updateData model.Plant + if err := json.NewDecoder(r.Body).Decode(&updateData); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + userID, err := GetUserIDFromRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + // First, fetch the existing plant + existingPlant, err := database.GetPlantByID(r.Context(), userID, plantID) + if err != nil { + http.Error(w, "Failed to get plant: "+err.Error(), http.StatusInternalServerError) + return + } + if existingPlant == nil { + http.Error(w, "Plant not found", http.StatusNotFound) + return + } + + // Merge changes into existing plant (only update non-zero fields) + if updateData.SpeciesID != 0 { + existingPlant.SpeciesID = updateData.SpeciesID + } + if updateData.Nickname != nil { + existingPlant.Nickname = updateData.Nickname + } + if updateData.ImageURL != nil { + existingPlant.ImageURL = updateData.ImageURL + } + if updateData.WateringFrequencyDays != 0 { + existingPlant.WateringFrequencyDays = updateData.WateringFrequencyDays + } + if updateData.LastWateredAt != nil { + existingPlant.LastWateredAt = updateData.LastWateredAt + } + if updateData.Note != nil { + existingPlant.Note = updateData.Note + } + + // Update the plant + if err := database.UpdatePlant(r.Context(), existingPlant); err != nil { + http.Error(w, "Failed to update plant: "+err.Error(), http.StatusInternalServerError) + return + } + + // Return the updated plant + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(existingPlant) + } +} + +func DeletePlant(database db.Database) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + plantID, err := strconv.ParseInt(mux.Vars(r)["plantID"], 10, 64) + if err != nil { + http.Error(w, "Invalid plant ID", http.StatusBadRequest) + return + } + + userID, err := GetUserIDFromRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + if err := database.DeletePlant(r.Context(), userID, plantID); err != nil { + http.Error(w, "Failed to delete plant: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} + +func ListPlants(database db.Database) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := GetUserIDFromRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if v, err := strconv.Atoi(l); err == nil && v > 0 { + limit = v + } + } + + offset := 0 + if o := r.URL.Query().Get("offset"); o != "" { + if v, err := strconv.Atoi(o); err == nil && v >= 0 { + offset = v + } + } + + plants, err := database.ListPlants(r.Context(), userID, limit, offset) + if err != nil { + http.Error(w, "Failed to list plants: "+err.Error(), http.StatusInternalServerError) + return + } + + // return empty array if user doesnt have any plants + if plants == nil { + plants = []*model.Plant{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(plants) + } +} diff --git a/backend/main.go b/backend/main.go index e80cef9..79a09da 100644 --- a/backend/main.go +++ b/backend/main.go @@ -90,8 +90,8 @@ func main() { } // create auth service with providers - service := auth.NewService(authOptions) - service.AddDirectProvider("local", provider.CredCheckerFunc(func(user, password string) (ok bool, err error) { + authService := auth.NewService(authOptions) + authService.AddDirectProvider("local", provider.CredCheckerFunc(func(user, password string) (ok bool, err error) { return myAuth.HandleLogin(database, user, password) })) @@ -103,12 +103,22 @@ func main() { }).Methods("POST") // setup auth routes - authRoutes, avaRoutes := service.Handlers() + authRoutes, avaRoutes := authService.Handlers() r.PathPrefix("/auth").Handler(authRoutes) r.PathPrefix("/avatar").Handler(avaRoutes) - // register species endpoint - r.HandleFunc("/api/plants/species", handlers.SearchSpecies(database)).Methods("GET") + // create middleware and mount api endpoints + authMiddleware := authService.Middleware() + apiRouter := r.PathPrefix("/api").Subrouter() + apiRouter.Use(authMiddleware.Auth) + apiRouter.HandleFunc("/plants/species", handlers.SearchSpecies(database)).Methods("GET") + + // plant routes + apiRouter.HandleFunc("/plants", handlers.CreatePlant(database)).Methods("POST") + apiRouter.HandleFunc("/plants", handlers.ListPlants(database)).Methods("GET") + apiRouter.HandleFunc("/plants/{plantID}", handlers.GetPlant(database)).Methods("GET") + apiRouter.HandleFunc("/plants/{plantID}", handlers.UpdatePlant(database)).Methods("PATCH") + apiRouter.HandleFunc("/plants/{plantID}", handlers.DeletePlant(database)).Methods("DELETE") fmt.Println("Server is running on port 8080...") http.ListenAndServe(":8080", r) From c91a97656b79aaf9cd16589178c1406c0fb36202 Mon Sep 17 00:00:00 2001 From: Anish Chanda Date: Mon, 30 Jun 2025 19:23:18 -0500 Subject: [PATCH 12/12] add tests --- backend/auth/handlers_test.go | 12 + backend/go.mod | 1 + backend/go.sum | 2 + backend/handlers/plants_test.go | 780 ++++++++++++++++++++++++++++++++ 4 files changed, 795 insertions(+) create mode 100644 backend/handlers/plants_test.go diff --git a/backend/auth/handlers_test.go b/backend/auth/handlers_test.go index d4ef4a6..836909b 100644 --- a/backend/auth/handlers_test.go +++ b/backend/auth/handlers_test.go @@ -41,9 +41,21 @@ 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) GetSpeciesByID(ctx context.Context, speciesID int64) (*model.Species, error) { + return nil, nil +} func (f *fakeDB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { return nil, nil } +func (f *fakeDB) CreatePlant(ctx context.Context, p *model.Plant) (int64, error) { return 0, nil } +func (f *fakeDB) GetPlantByID(ctx context.Context, userID, plantID int64) (*model.Plant, error) { + return nil, nil +} +func (f *fakeDB) ListPlants(ctx context.Context, userID int64, limit, offset int) ([]*model.Plant, error) { + return nil, nil +} +func (f *fakeDB) UpdatePlant(ctx context.Context, p *model.Plant) error { return nil } +func (f *fakeDB) DeletePlant(ctx context.Context, userID, plantID int64) error { return nil } //----------------------- // Tests for HandleLogin diff --git a/backend/go.mod b/backend/go.mod index b2447ec..b511d9f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -21,6 +21,7 @@ require ( github.com/montanaflynn/stats v0.7.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/backend/go.sum b/backend/go.sum index e861cbe..30292e8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -120,6 +120,8 @@ github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYl github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/backend/handlers/plants_test.go b/backend/handlers/plants_test.go new file mode 100644 index 0000000..c652de6 --- /dev/null +++ b/backend/handlers/plants_test.go @@ -0,0 +1,780 @@ +package handlers + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/anish-chanda/ferna/model" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// Helper function to create string pointers +func stringPtr(s string) *string { + return &s +} + +type MockDatabase struct { + mock.Mock +} + +func (m *MockDatabase) CreatePlant(ctx context.Context, p *model.Plant) (int64, error) { + args := m.Called(ctx, p) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockDatabase) GetPlantByID(ctx context.Context, userID, plantID int64) (*model.Plant, error) { + args := m.Called(ctx, userID, plantID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Plant), args.Error(1) +} + +func (m *MockDatabase) ListPlants(ctx context.Context, userID int64, limit, offset int) ([]*model.Plant, error) { + args := m.Called(ctx, userID, limit, offset) + return args.Get(0).([]*model.Plant), args.Error(1) +} + +func (m *MockDatabase) UpdatePlant(ctx context.Context, p *model.Plant) error { + args := m.Called(ctx, p) + return args.Error(0) +} + +func (m *MockDatabase) DeletePlant(ctx context.Context, userID, plantID int64) error { + args := m.Called(ctx, userID, plantID) + return args.Error(0) +} + +// Implement other required interface methods as stubs +func (m *MockDatabase) CheckIfEmailExists(ctx context.Context, email string) (bool, error) { + return false, nil +} +func (m *MockDatabase) CreateUser(ctx context.Context, email, passHash string) (int64, error) { + return 0, nil +} +func (m *MockDatabase) GetUserByEmail(ctx context.Context, email string) (*model.User, error) { + return nil, nil +} +func (m *MockDatabase) SearchSpecies(ctx context.Context, query string, limit, offset int) ([]*model.Species, error) { + return nil, nil +} +func (m *MockDatabase) GetSpeciesByID(ctx context.Context, speciesID int64) (*model.Species, error) { + args := m.Called(ctx, speciesID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Species), args.Error(1) +} +func (m *MockDatabase) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { + return nil, nil +} +func (m *MockDatabase) Connect(dsn string) error { return nil } +func (m *MockDatabase) Close() error { return nil } +func (m *MockDatabase) Migrate() error { return nil } + +func createTestRequest(method, url string, body interface{}) *http.Request { + var buf bytes.Buffer + if body != nil { + json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, url, &buf) + req.Header.Set("Content-Type", "application/json") + return req +} + +func TestCreatePlant(t *testing.T) { + t.Run("successful creation", func(t *testing.T) { + mockDB := new(MockDatabase) + + // Mock the species lookup + species := &model.Species{ + ID: 123, + CommonName: "Test Plant", + DefaultWateringFrequency: 7, + } + mockDB.On("GetSpeciesByID", mock.Anything, int64(123)).Return(species, nil) + mockDB.On("CreatePlant", mock.Anything, mock.AnythingOfType("*model.Plant")).Return(int64(123), nil) + + body := map[string]interface{}{ + "species_id": 123, + "nickname": "My Plant", + "watering_frequency_days": 7, + } + req := createTestRequest("POST", "/plants", body) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := CreatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var response model.Plant + err := json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, int64(123), response.ID) + assert.Equal(t, int64(1), response.UserID) + + mockDB.AssertExpectations(t) + }) + + t.Run("invalid request body", func(t *testing.T) { + mockDB := new(MockDatabase) + + req := httptest.NewRequest("POST", "/plants", bytes.NewReader([]byte("invalid json"))) + w := httptest.NewRecorder() + + handler := CreatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid request body") + }) + + t.Run("unauthorized - no user context", func(t *testing.T) { + mockDB := new(MockDatabase) + + body := map[string]interface{}{"species_id": 123} + req := createTestRequest("POST", "/plants", body) + + w := httptest.NewRecorder() + handler := CreatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("database error", func(t *testing.T) { + mockDB := new(MockDatabase) + + // Mock the species lookup to succeed + species := &model.Species{ + ID: 123, + CommonName: "Test Plant", + DefaultWateringFrequency: 7, + } + mockDB.On("GetSpeciesByID", mock.Anything, int64(123)).Return(species, nil) + mockDB.On("CreatePlant", mock.Anything, mock.AnythingOfType("*model.Plant")).Return(int64(0), errors.New("db error")) + + body := map[string]interface{}{"species_id": 123} + req := createTestRequest("POST", "/plants", body) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := CreatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Failed to create plant") + mockDB.AssertExpectations(t) + }) + + t.Run("empty request body", func(t *testing.T) { + mockDB := new(MockDatabase) + + req := createTestRequest("POST", "/plants", map[string]interface{}{}) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := CreatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "species_id is required") + }) + + t.Run("missing species_id", func(t *testing.T) { + mockDB := new(MockDatabase) + + body := map[string]interface{}{ + "nickname": "My Plant", + } + req := createTestRequest("POST", "/plants", body) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := CreatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "species_id is required") + }) + + t.Run("species not found", func(t *testing.T) { + mockDB := new(MockDatabase) + mockDB.On("GetSpeciesByID", mock.Anything, int64(999)).Return(nil, nil) + + body := map[string]interface{}{ + "species_id": 999, + } + req := createTestRequest("POST", "/plants", body) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := CreatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Species not found") + mockDB.AssertExpectations(t) + }) + + t.Run("species lookup error", func(t *testing.T) { + mockDB := new(MockDatabase) + mockDB.On("GetSpeciesByID", mock.Anything, int64(123)).Return(nil, errors.New("database error")) + + body := map[string]interface{}{ + "species_id": 123, + } + req := createTestRequest("POST", "/plants", body) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := CreatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Failed to get species") + mockDB.AssertExpectations(t) + }) + + t.Run("defaults applied when not provided", func(t *testing.T) { + mockDB := new(MockDatabase) + + // Mock the species lookup + species := &model.Species{ + ID: 123, + CommonName: "Rose", + DefaultWateringFrequency: 3, + } + mockDB.On("GetSpeciesByID", mock.Anything, int64(123)).Return(species, nil) + + // Capture the plant that gets created to verify defaults + var capturedPlant *model.Plant + mockDB.On("CreatePlant", mock.Anything, mock.AnythingOfType("*model.Plant")).Run(func(args mock.Arguments) { + capturedPlant = args.Get(1).(*model.Plant) + }).Return(int64(456), nil) + + body := map[string]interface{}{ + "species_id": 123, + // No nickname or watering_frequency_days provided + } + req := createTestRequest("POST", "/plants", body) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := CreatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + // Verify defaults were applied + assert.Equal(t, "Rose", *capturedPlant.Nickname) + assert.Equal(t, 3, capturedPlant.WateringFrequencyDays) + + mockDB.AssertExpectations(t) + }) +} + +func TestGetPlant(t *testing.T) { + t.Run("successful get", func(t *testing.T) { + mockDB := new(MockDatabase) + plant := &model.Plant{ID: 123, UserID: 1, SpeciesID: 456} + + mockDB.On("GetPlantByID", mock.Anything, int64(1), int64(123)).Return(plant, nil) + + req := httptest.NewRequest("GET", "/plants/123", nil) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := GetPlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var response model.Plant + err := json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, int64(123), response.ID) + assert.Equal(t, int64(1), response.UserID) + + mockDB.AssertExpectations(t) + }) + + t.Run("invalid plant ID", func(t *testing.T) { + mockDB := new(MockDatabase) + + req := httptest.NewRequest("GET", "/plants/invalid", nil) + req = mux.SetURLVars(req, map[string]string{"plantID": "invalid"}) + + w := httptest.NewRecorder() + handler := GetPlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid plant ID") + }) + + t.Run("unauthorized - no user context", func(t *testing.T) { + mockDB := new(MockDatabase) + + req := httptest.NewRequest("GET", "/plants/123", nil) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := GetPlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("database error", func(t *testing.T) { + mockDB := new(MockDatabase) + mockDB.On("GetPlantByID", mock.Anything, int64(1), int64(123)).Return(&model.Plant{}, errors.New("db error")) + + req := httptest.NewRequest("GET", "/plants/123", nil) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := GetPlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Failed to get plant") + mockDB.AssertExpectations(t) + }) + + t.Run("negative plant ID", func(t *testing.T) { + mockDB := new(MockDatabase) + + req := httptest.NewRequest("GET", "/plants/-1", nil) + req = mux.SetURLVars(req, map[string]string{"plantID": "-1"}) + + w := httptest.NewRecorder() + handler := GetPlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("plant not found", func(t *testing.T) { + mockDB := new(MockDatabase) + mockDB.On("GetPlantByID", mock.Anything, int64(1), int64(999)).Return(nil, nil) + + body := map[string]interface{}{ + "nickname": "Updated Plant", + } + req := createTestRequest("PATCH", "/plants/999", body) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + req = mux.SetURLVars(req, map[string]string{"plantID": "999"}) + + w := httptest.NewRecorder() + handler := UpdatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), "Plant not found") + mockDB.AssertExpectations(t) + }) + + t.Run("get plant error", func(t *testing.T) { + mockDB := new(MockDatabase) + mockDB.On("GetPlantByID", mock.Anything, int64(1), int64(123)).Return(nil, errors.New("db error")) + + body := map[string]interface{}{ + "nickname": "Updated Plant", + } + req := createTestRequest("PATCH", "/plants/123", body) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := UpdatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Failed to get plant") + mockDB.AssertExpectations(t) + }) +} + +func TestUpdatePlant(t *testing.T) { + t.Run("successful update", func(t *testing.T) { + mockDB := new(MockDatabase) + + // Mock GetPlantByID to return existing plant + existingPlant := &model.Plant{ + ID: 123, + UserID: 1, + SpeciesID: 1, + Nickname: stringPtr("Original Plant"), + WateringFrequencyDays: 7, + } + mockDB.On("GetPlantByID", mock.Anything, int64(1), int64(123)).Return(existingPlant, nil) + mockDB.On("UpdatePlant", mock.Anything, mock.AnythingOfType("*model.Plant")).Return(nil) + + body := map[string]interface{}{ + "nickname": "Updated Plant", + "watering_frequency_days": 14, + } + req := createTestRequest("PATCH", "/plants/123", body) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := UpdatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + // Verify response contains updated plant + var response model.Plant + err := json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "Updated Plant", *response.Nickname) + assert.Equal(t, 14, response.WateringFrequencyDays) + + mockDB.AssertExpectations(t) + }) + + t.Run("invalid plant ID", func(t *testing.T) { + mockDB := new(MockDatabase) + + body := map[string]string{"nickname": "Updated Plant"} + req := createTestRequest("PUT", "/plants/invalid", body) + req = mux.SetURLVars(req, map[string]string{"plantID": "invalid"}) + + w := httptest.NewRecorder() + handler := UpdatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid plant ID") + }) + + t.Run("invalid request body", func(t *testing.T) { + mockDB := new(MockDatabase) + + req := httptest.NewRequest("PATCH", "/plants/123", bytes.NewReader([]byte("invalid json"))) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := UpdatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid request body") + }) + + t.Run("unauthorized - no user context", func(t *testing.T) { + mockDB := new(MockDatabase) + + body := map[string]string{"nickname": "Updated Plant"} + req := createTestRequest("PATCH", "/plants/123", body) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := UpdatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("database error", func(t *testing.T) { + mockDB := new(MockDatabase) + + // Mock GetPlantByID to return existing plant + existingPlant := &model.Plant{ + ID: 123, + UserID: 1, + SpeciesID: 1, + Nickname: stringPtr("Original Plant"), + WateringFrequencyDays: 7, + } + mockDB.On("GetPlantByID", mock.Anything, int64(1), int64(123)).Return(existingPlant, nil) + mockDB.On("UpdatePlant", mock.Anything, mock.AnythingOfType("*model.Plant")).Return(errors.New("db error")) + + body := map[string]string{"nickname": "Updated Plant"} + req := createTestRequest("PATCH", "/plants/123", body) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := UpdatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Failed to update plant") + mockDB.AssertExpectations(t) + }) + + t.Run("update with partial data", func(t *testing.T) { + mockDB := new(MockDatabase) + + // Mock GetPlantByID to return existing plant + existingPlant := &model.Plant{ + ID: 123, + UserID: 1, + SpeciesID: 1, + Nickname: stringPtr("Original Plant"), + WateringFrequencyDays: 7, + } + mockDB.On("GetPlantByID", mock.Anything, int64(1), int64(123)).Return(existingPlant, nil) + mockDB.On("UpdatePlant", mock.Anything, mock.AnythingOfType("*model.Plant")).Return(nil) + + body := map[string]interface{}{ + "nickname": "Only nickname updated", + } + req := createTestRequest("PATCH", "/plants/123", body) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := UpdatePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + // Verify response contains updated plant with only nickname changed + var response model.Plant + err := json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "Only nickname updated", *response.Nickname) + assert.Equal(t, 7, response.WateringFrequencyDays) // Should remain unchanged + assert.Equal(t, int64(1), response.SpeciesID) // Should remain unchanged + + mockDB.AssertExpectations(t) + }) +} + +func TestDeletePlant(t *testing.T) { + t.Run("successful delete", func(t *testing.T) { + mockDB := new(MockDatabase) + mockDB.On("DeletePlant", mock.Anything, int64(1), int64(123)).Return(nil) + + req := httptest.NewRequest("DELETE", "/plants/123", nil) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := DeletePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Empty(t, w.Body.String()) + mockDB.AssertExpectations(t) + }) + + t.Run("invalid plant ID", func(t *testing.T) { + mockDB := new(MockDatabase) + + req := httptest.NewRequest("DELETE", "/plants/invalid", nil) + req = mux.SetURLVars(req, map[string]string{"plantID": "invalid"}) + + w := httptest.NewRecorder() + handler := DeletePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid plant ID") + }) + + t.Run("unauthorized - no user context", func(t *testing.T) { + mockDB := new(MockDatabase) + + req := httptest.NewRequest("DELETE", "/plants/123", nil) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := DeletePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("database error", func(t *testing.T) { + mockDB := new(MockDatabase) + mockDB.On("DeletePlant", mock.Anything, int64(1), int64(123)).Return(errors.New("db error")) + + req := httptest.NewRequest("DELETE", "/plants/123", nil) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + req = mux.SetURLVars(req, map[string]string{"plantID": "123"}) + + w := httptest.NewRecorder() + handler := DeletePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Failed to delete plant") + mockDB.AssertExpectations(t) + }) + + t.Run("zero plant ID", func(t *testing.T) { + mockDB := new(MockDatabase) + + req := httptest.NewRequest("DELETE", "/plants/0", nil) + req = mux.SetURLVars(req, map[string]string{"plantID": "0"}) + + w := httptest.NewRecorder() + handler := DeletePlant(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) +} + +func TestListPlants(t *testing.T) { + t.Run("successful list with default pagination", func(t *testing.T) { + mockDB := new(MockDatabase) + plants := []*model.Plant{ + {ID: 1, UserID: 1, SpeciesID: 123}, + {ID: 2, UserID: 1, SpeciesID: 456}, + } + mockDB.On("ListPlants", mock.Anything, int64(1), 20, 0).Return(plants, nil) + + req := httptest.NewRequest("GET", "/plants", nil) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := ListPlants(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var response []*model.Plant + err := json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + assert.Len(t, response, 2) + + mockDB.AssertExpectations(t) + }) + + t.Run("successful list with custom pagination", func(t *testing.T) { + mockDB := new(MockDatabase) + plants := []*model.Plant{} + mockDB.On("ListPlants", mock.Anything, int64(1), 10, 5).Return(plants, nil) + + req := httptest.NewRequest("GET", "/plants?limit=10&offset=5", nil) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := ListPlants(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + mockDB.AssertExpectations(t) + }) + + t.Run("invalid pagination parameters ignored", func(t *testing.T) { + mockDB := new(MockDatabase) + plants := []*model.Plant{} + mockDB.On("ListPlants", mock.Anything, int64(1), 20, 0).Return(plants, nil) + + req := httptest.NewRequest("GET", "/plants?limit=invalid&offset=invalid", nil) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := ListPlants(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + mockDB.AssertExpectations(t) + }) + + t.Run("unauthorized - no user context", func(t *testing.T) { + mockDB := new(MockDatabase) + + req := httptest.NewRequest("GET", "/plants", nil) + + w := httptest.NewRecorder() + handler := ListPlants(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("database error", func(t *testing.T) { + mockDB := new(MockDatabase) + mockDB.On("ListPlants", mock.Anything, int64(1), 20, 0).Return([]*model.Plant{}, errors.New("db error")) + + req := httptest.NewRequest("GET", "/plants", nil) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := ListPlants(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Failed to list plants") + mockDB.AssertExpectations(t) + }) + + t.Run("large limit parameter", func(t *testing.T) { + mockDB := new(MockDatabase) + plants := []*model.Plant{} + mockDB.On("ListPlants", mock.Anything, int64(1), 1000, 0).Return(plants, nil) + + req := httptest.NewRequest("GET", "/plants?limit=1000", nil) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := ListPlants(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + mockDB.AssertExpectations(t) + }) + + t.Run("negative pagination parameters", func(t *testing.T) { + mockDB := new(MockDatabase) + plants := []*model.Plant{} + mockDB.On("ListPlants", mock.Anything, int64(1), 20, 0).Return(plants, nil) + + req := httptest.NewRequest("GET", "/plants?limit=-5&offset=-10", nil) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := ListPlants(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + mockDB.AssertExpectations(t) + }) + + t.Run("empty plant list", func(t *testing.T) { + mockDB := new(MockDatabase) + plants := []*model.Plant{} + mockDB.On("ListPlants", mock.Anything, int64(1), 20, 0).Return(plants, nil) + + req := httptest.NewRequest("GET", "/plants", nil) + req = req.WithContext(context.WithValue(req.Context(), "user", map[string]interface{}{"uid": "1"})) + + w := httptest.NewRecorder() + handler := ListPlants(mockDB) + handler(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response []*model.Plant + err := json.NewDecoder(w.Body).Decode(&response) + assert.NoError(t, err) + assert.Len(t, response, 0) + + mockDB.AssertExpectations(t) + }) +}