diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml new file mode 100644 index 0000000..74be6fa --- /dev/null +++ b/.github/workflows/go-test.yml @@ -0,0 +1,21 @@ +name: Go Test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22.x' + + - name: Test + run: go test ./... diff --git a/.gitignore b/.gitignore index f6ddc29..234dd5e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ go.work.sum .vscode/ *.~ +# Database files +api/*.db + diff --git a/api/api.go b/api/api.go index 37102b7..c7692ff 100644 --- a/api/api.go +++ b/api/api.go @@ -1,20 +1,19 @@ package api import ( - "database/sql" "log" "net/http" - _ "modernc.org/sqlite" + "lidsol.org/papeador/store" ) type ApiContext struct{ - DB *sql.DB + Store store.Store } -func API(sqlitedb *sql.DB) { +func API(s store.Store) { apiCtx := ApiContext{ - DB: sqlitedb, + Store: s, } mux := http.NewServeMux() diff --git a/api/api_test.go b/api/api_test.go index 4102274..b6de023 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -8,9 +8,10 @@ import ( "net/http" "net/http/httptest" "os" - "os/exec" "testing" + "lidsol.org/papeador/store" + _ "modernc.org/sqlite" ) @@ -23,13 +24,13 @@ func TestMain(m *testing.M) { log.Fatal(err) } testDB = db - defer os.Remove("test_api.db") // clean up - // run schema - command := exec.Command("bash", "-c", "sqlite3 test_api.db < ../schema.sql") - output, err := command.CombinedOutput() + schemaBytes, err := os.ReadFile("../schema.sql") if err != nil { - log.Fatalf("Failed to run schema: %s, %s", err, string(output)) + log.Fatalf("Failed to read schema.sql: %v", err) + } + if _, err := db.Exec(string(schemaBytes)); err != nil { + log.Fatalf("Failed to execute schema: %v", err) } // Run tests @@ -37,6 +38,10 @@ func TestMain(m *testing.M) { // Teardown: close the database connection testDB.Close() + // remove the temporary DB file + if err := os.Remove("test_api.db"); err != nil { + log.Printf("Warning: failed to remove test DB: %v", err) + } os.Exit(exitCode) } @@ -45,7 +50,9 @@ func TestMain(m *testing.M) { func executeRequest(req *http.Request) *httptest.ResponseRecorder { rr := httptest.NewRecorder() - handler := http.HandlerFunc(createUser) + // create an ApiContext that uses the test sqlite DB + apiCtx := ApiContext{Store: store.NewSQLiteStore(testDB)} + handler := http.HandlerFunc(apiCtx.createUser) handler.ServeHTTP(rr, req) return rr } @@ -58,7 +65,7 @@ func checkStatus(t *testing.T, rr *httptest.ResponseRecorder, expectedStatus int } } -func createUserRequest(user User) (*http.Request, error) { +func createUserRequest(user store.User) (*http.Request, error) { jsonUser, _ := json.Marshal(user) return http.NewRequest("POST", "/users", bytes.NewBuffer(jsonUser)) } @@ -72,10 +79,9 @@ func clearUserTable() { func TestCreateUser(t *testing.T) { // Initialize the API with the test database - db = testDB + // apiCtx is constructed inside executeRequest per-call using testDB clearUserTable() - - newUser := User{ + newUser := store.User{ Username: "testuser", Passhash: "testpass", Email: "test@example.com", @@ -90,7 +96,7 @@ func TestCreateUser(t *testing.T) { checkStatus(t, rr, http.StatusCreated) // Check the response body - var createdUser User + var createdUser store.User err = json.NewDecoder(rr.Body).Decode(&createdUser) if err != nil { t.Fatal(err) diff --git a/api/contest.go b/api/contest.go index 9f59da3..c7f1a3c 100644 --- a/api/contest.go +++ b/api/contest.go @@ -1,57 +1,32 @@ package api import ( - "database/sql" "encoding/json" - "fmt" "log" "net/http" -) -type Contest struct { - ContestID int `json:"contest_id"` - ContestName string `json:"contest_name"` -} + "lidsol.org/papeador/store" +) func (api *ApiContext) createContest(w http.ResponseWriter, r *http.Request) { - var newContest Contest - if err := json.NewDecoder(r.Body).Decode(&newContest); err != nil { + var in store.Contest + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - // Verificar si el nombre del concurso ya existe - var contestName string - query := "SELECT contest_name FROM contest WHERE contest_name=?" - err := api.DB.QueryRow(query, newContest.ContestName).Scan(&contestName) - if err == nil { - // existe - http.Error(w, "El nombre del contest ya esta registrado", http.StatusConflict) - log.Println("El nombre del contest ya esta registrado") - return - } else if err != sql.ErrNoRows { - // error distinto a no rows + if err := api.Store.CreateContest(r.Context(), &in); err != nil { + if err == store.ErrAlreadyExists { + http.Error(w, "El nombre del contest ya esta registrado", http.StatusConflict) + log.Println("El nombre del contest ya esta registrado") + return + } http.Error(w, err.Error(), http.StatusInternalServerError) log.Println(err) return } - res, err := api.DB.Exec( - "INSERT INTO contest (contest_name) VALUES (?)", - newContest.ContestName, - ) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - log.Println(err) - return - } - - // Intentar obtener el id generado - if id, ierr := res.LastInsertId(); ierr == nil { - newContest.ContestID = int(id) - } - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(newContest) + json.NewEncoder(w).Encode(in) } diff --git a/api/problem.go b/api/problem.go index 39cf7d7..faa15ca 100644 --- a/api/problem.go +++ b/api/problem.go @@ -1,58 +1,32 @@ package api import ( - "database/sql" "encoding/json" "log" "net/http" -) -type Problem struct { - ProblemID int `json:"problem_id"` - ContestID *int `json:"contest_id"` - CreatorID int `json:"creator_id"` - ProblemName string `json:"problem_name"` - Description string `json:"description"` -} + "lidsol.org/papeador/store" +) func (api *ApiContext) createProblem(w http.ResponseWriter, r *http.Request) { - var newProblem Problem - if err := json.NewDecoder(r.Body).Decode(&newProblem); err != nil { + var in store.Problem + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if newProblem.ContestID != nil { - var contestID int - query := "SELECT contest_id FROM CONTEST WHERE contest_id=?" - err := api.DB.QueryRow(query, *newProblem.ContestID).Scan(&contestID) - if err == sql.ErrNoRows { + if err := api.Store.CreateProblem(r.Context(), &in); err != nil { + if err == store.ErrNotFound { http.Error(w, "El concurso no existe", http.StatusConflict) log.Println("El concurso no existe") return - } else if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - log.Println(err) - return } - } - - res, err := api.DB.Exec( - "INSERT INTO PROBLEM (contest_id, creator_id, problem_name, description) VALUES (?, ?, ?, ?)", - newProblem.ContestID, newProblem.CreatorID, newProblem.ProblemName, newProblem.Description, - ) - if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) log.Println(err) return } - // Obtener el ID del problema recién insertado - if id, err := res.LastInsertId(); err == nil { - newProblem.ProblemID = int(id) - } - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(newProblem) + json.NewEncoder(w).Encode(in) } diff --git a/api/user.go b/api/user.go index aae7d8e..22a0bd3 100644 --- a/api/user.go +++ b/api/user.go @@ -1,55 +1,32 @@ package api import ( - "database/sql" "encoding/json" "log" "net/http" -) -type User struct { - UserID int `json:"user_id"` - Username string `json:"username"` - Passhash string `json:"passhash"` - Email string `json:"email"` -} + "lidsol.org/papeador/store" +) func (api *ApiContext) createUser(w http.ResponseWriter, r *http.Request) { - var new_user User + var in store.User - err := json.NewDecoder(r.Body).Decode(&new_user) - if err != nil { + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - // Verify if the user already exists (unique username and email) - var username string - query := "SELECT username FROM user WHERE username=? OR email=?" - err = api.DB.QueryRow(query, new_user.Username, new_user.Email).Scan(&username) - if err == nil { - http.Error(w, "El usuario ya está registrado", http.StatusConflict) - log.Println("El usuario ya está registrado") - return - } else if err != sql.ErrNoRows { + if err := api.Store.CreateUser(r.Context(), &in); err != nil { + if err == store.ErrAlreadyExists { + http.Error(w, "El usuario ya está registrado", http.StatusConflict) + log.Println("El usuario ya está registrado") + return + } http.Error(w, err.Error(), http.StatusInternalServerError) log.Println(err) return } - - res, err := api.DB.Exec("INSERT INTO user (username,passhash,email) VALUES (?, ?, ?)", new_user.Username, new_user.Passhash, new_user.Email) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - log.Println(err) - return - } - - // Obtener el ID del usuario recién insertado - if id, err := res.LastInsertId(); err == nil { - new_user.User_ID = int(id) - } - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(new_user) + json.NewEncoder(w).Encode(in) } \ No newline at end of file diff --git a/papeador.go b/papeador.go index d86347a..42360e7 100644 --- a/papeador.go +++ b/papeador.go @@ -7,6 +7,7 @@ import ( "os/exec" "lidsol.org/papeador/api" + "lidsol.org/papeador/store" _ "modernc.org/sqlite" ) @@ -23,5 +24,6 @@ func main() { fmt.Println(string(output)) log.Fatal(err) } - api.API(db) + s := store.NewSQLiteStore(db) + api.API(s) } diff --git a/store/sqlite.go b/store/sqlite.go new file mode 100644 index 0000000..0414336 --- /dev/null +++ b/store/sqlite.go @@ -0,0 +1,74 @@ +package store + +import ( + "context" + "database/sql" +) + +type SQLiteStore struct{ + DB *sql.DB +} + +func NewSQLiteStore(db *sql.DB) Store { + return &SQLiteStore{DB: db} +} + +func (s *SQLiteStore) CreateUser(ctx context.Context, u *User) error { + // Verify unique username/email + var username string + err := s.DB.QueryRowContext(ctx, "SELECT username FROM user WHERE username=? OR email=?", u.Username, u.Email).Scan(&username) + if err == nil { + return ErrAlreadyExists + } else if err != sql.ErrNoRows { + return err + } + + res, err := s.DB.ExecContext(ctx, "INSERT INTO user (username,passhash,email) VALUES (?, ?, ?)", u.Username, u.Passhash, u.Email) + if err != nil { + return err + } + if id, ierr := res.LastInsertId(); ierr == nil { + u.UserID = id + } + return nil +} + +func (s *SQLiteStore) CreateContest(ctx context.Context, c *Contest) error { + var name string + err := s.DB.QueryRowContext(ctx, "SELECT contest_name FROM contest WHERE contest_name=?", c.ContestName).Scan(&name) + if err == nil { + return ErrAlreadyExists + } else if err != sql.ErrNoRows { + return err + } + + res, err := s.DB.ExecContext(ctx, "INSERT INTO contest (contest_name) VALUES (?)", c.ContestName) + if err != nil { + return err + } + if id, ierr := res.LastInsertId(); ierr == nil { + c.ContestID = id + } + return nil +} + +func (s *SQLiteStore) CreateProblem(ctx context.Context, p *Problem) error { + if p.ContestID != nil { + var cid int64 + err := s.DB.QueryRowContext(ctx, "SELECT contest_id FROM contest WHERE contest_id=?", *p.ContestID).Scan(&cid) + if err == sql.ErrNoRows { + return ErrNotFound + } else if err != nil { + return err + } + } + + res, err := s.DB.ExecContext(ctx, "INSERT INTO problem (contest_id, creator_id, problem_name, description) VALUES (?, ?, ?, ?)", p.ContestID, p.CreatorID, p.ProblemName, p.Description) + if err != nil { + return err + } + if id, ierr := res.LastInsertId(); ierr == nil { + p.ProblemID = id + } + return nil +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..85808ac --- /dev/null +++ b/store/store.go @@ -0,0 +1,38 @@ +package store + +import ( + "context" + "errors" +) + +var ( + ErrAlreadyExists = errors.New("already exists") + ErrNotFound = errors.New("not found") +) + +type User struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + Passhash string `json:"passhash"` + Email string `json:"email"` +} + +type Contest struct { + ContestID int64 `json:"contest_id"` + ContestName string `json:"contest_name"` +} + +type Problem struct { + ProblemID int64 `json:"problem_id"` + ContestID *int64 `json:"contest_id"` + CreatorID int64 `json:"creator_id"` + ProblemName string `json:"problem_name"` + Description string `json:"description"` +} + +// Store defines persistence operations used by the HTTP layer. +type Store interface { + CreateUser(ctx context.Context, u *User) error + CreateContest(ctx context.Context, c *Contest) error + CreateProblem(ctx context.Context, p *Problem) error +}