Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions backend/auth/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package auth
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions backend/db/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package db

import (
"context"
"database/sql"

"github.com/anish-chanda/ferna/model"
)
Expand All @@ -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
Expand Down
58 changes: 58 additions & 0 deletions backend/db/sqlite3/sqlite3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
140 changes: 140 additions & 0 deletions backend/db/sqlite3/sqlite3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
40 changes: 40 additions & 0 deletions backend/handlers/species.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 11 additions & 0 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
10 changes: 10 additions & 0 deletions backend/migrations/sqlite3/0001_create_users_table.down.sql
Original file line number Diff line number Diff line change
@@ -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;
34 changes: 33 additions & 1 deletion backend/migrations/sqlite3/0001_create_users_table.up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
);

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);
Loading
Loading