Compare commits

..

7 commits

Author SHA1 Message Date
6b15ecadcb Merge pull request 'Added max amount of tasks on contest' (#7) from feature/max_tasks_on_contest into develop
Reviewed-on: #7
Reviewed-by: Vyacheslav Birin <vyacheslav1557@noreply.localhost>
2025-04-14 11:42:44 +00:00
a2e0894728 Merge pull request 'read the best solution for each task in contest' (#8) from get-contest-extension into develop
Reviewed-on: #8
Reviewed-by: Vyacheslav Birin <vyacheslav1557@noreply.localhost>
2025-04-14 11:38:34 +00:00
adcc1bc8d8 read the best solution for each task in contest 2025-04-13 17:01:57 +05:00
94cb1f5e66 Added max amount of tasks on contest 2025-04-13 16:55:34 +05:00
Vyacheslav1557
a28d2506e5 docs: add readme 2025-04-12 23:49:40 +05:00
c3eb127b25 Merge pull request 'feature/GAT-103: rename submodule' (#5) from feature/GAT-103 into develop
Reviewed-on: #5
2025-04-12 16:32:44 +00:00
Vyacheslav1557
fed73f184e refactor: rename submodule
refactor: rename submodule

refactor: rename submodule
2025-04-12 21:27:29 +05:00
17 changed files with 410 additions and 1514 deletions

2
.gitmodules vendored
View file

@ -1,3 +1,3 @@
[submodule "proto"]
path = proto
path = contracts
url = https://git.sch9.ru/new_gate/contracts

View file

@ -1,7 +1,7 @@
tag = latest
gen:
@oapi-codegen --config=config.yaml ./proto/tester/v1/openapi.yaml
@oapi-codegen --config=config.yaml ./contracts/tester/v1/openapi.yaml
dev: gen
@go run main.go
build: gen

108
README.md Normal file
View file

@ -0,0 +1,108 @@
# ms-tester
`ms-tester` is a microservice designed for managing programming competitions. It provides backend functionality for handling problems, contests, participants, and their submissions. The service is developed in Go. PostgreSQL serves as the relational database. Pandoc is used to convert problem statements from LaTeX to HTML.
For understanding the architecture, see the [documentation](https://git.sch9.ru/new_gate/docs).
### Prerequisites
Before you begin, ensure you have the following dependencies installed:
* **Docker** and **Docker Compose**: To run PostgreSQL, Pandoc.
* **Goose**: For applying database migrations (`go install github.com/pressly/goose/v3/cmd/goose@latest`).
* **oapi-codegen**: For generating OpenAPI code (`go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@latest`).
### 1. Running Dependencies
You can run PostgreSQL and Pandoc using Docker Compose, for example:
```yaml
version: '3.8'
services:
pandoc:
image: pandoc/latex
ports:
- "4000:3030" # Exposes Pandoc server on port 4000 locally
command: "server" # Runs Pandoc in server mode
postgres:
image: postgres:14.1-alpine # Uses PostgreSQL 14.1 Alpine image
restart: always # Ensures the container restarts if it stops
environment:
POSTGRES_USER: postgres # Default user
POSTGRES_PASSWORD: supersecretpassword # Default password (change for production!)
POSTGRES_DB: postgres # Default database name
ports:
- '5432:5432' # Exposes PostgreSQL on the standard port 5432
volumes:
- ./postgres-data:/var/lib/postgresql/data # Persists database data locally
healthcheck:
test: pg_isready -U postgres -d postgres # Command to check if PostgreSQL is ready
interval: 10s # Check every 10 seconds
timeout: 3s # Wait 3 seconds for the check to respond
retries: 5 # Try 5 times before marking as unhealthy
volumes:
postgres-data: # Defines the named volume for data persistence
```
Start the services in detached mode:
```bash
docker-compose up -d
```
### 2. Configuration
The application uses environment variables for configuration. Create a `.env` file in the project root. The minimum required variables are:
```dotenv
# Environment type (development or production)
ENV=dev # or prod
# Address of the running Pandoc service
PANDOC=http://localhost:4000
# Address and port where the ms-tester service will listen
ADDRESS=localhost:8080
# PostgreSQL connection string (Data Source Name)
# Format: postgres://user:password@host:port/database?sslmode=disable
POSTGRES_DSN=postgres://username:supersecretpassword@localhost:5432/db_name?sslmode=disable
# Secret key for signing and verifying JWT tokens
JWT_SECRET=your_super_secret_jwt_key
```
**Important:** Replace `supersecretpassword` and `your_super_secret_jwt_key` with secure, unique values, especially for a production environment.
### 3. Database Migrations
The project uses `goose` to manage the database schema.
1. Ensure `goose` is installed:
```bash
go install github.com/pressly/goose/v3/cmd/goose@latest
```
2. Apply the migrations to the running PostgreSQL database. Make sure the connection string in the command matches the `POSTGRES_DSN` from your `.env` file:
```bash
goose -dir ./migrations postgres "postgres://postgres:supersecretpassword@localhost:5432/postgres?sslmode=disable" up
```
### 4. OpenAPI Code Generation
The project uses OpenAPI to define its API. Go code for handlers and models is generated based on this specification using `oapi-codegen`.
Run the generation command:
```bash
make gen
```
### 5. Running the Application
Start the `ms-tester` service:
```bash
go run ./main.go
```
After starting, the service will be available at the address specified in the `ADDRESS` variable in your `.env` file (e.g., `http://localhost:8080`).

View file

@ -2,4 +2,4 @@ package: testerv1
generate:
fiber-server: true
models: true
output: ./proto/tester/v1/tester.go
output: ./contracts/tester/v1/tester.go

View file

5
go.mod
View file

@ -4,13 +4,11 @@ go 1.23.6
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/Masterminds/squirrel v1.5.4
github.com/gofiber/fiber/v2 v2.52.6
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/google/uuid v1.6.0
github.com/ilyakaznacheev/cleanenv v1.5.0
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438
github.com/microcosm-cc/bluemonday v1.0.27
github.com/oapi-codegen/runtime v1.1.1
github.com/open-policy-agent/opa v1.2.0
github.com/rabbitmq/amqp091-go v1.10.0
@ -37,11 +35,10 @@ require (
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.21.0 // indirect

7
go.sum
View file

@ -5,8 +5,6 @@ github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
@ -99,10 +97,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@ -152,7 +146,6 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=

View file

@ -1,7 +1,7 @@
package tester
import (
testerv1 "git.sch9.ru/new_gate/ms-tester/proto/tester/v1"
testerv1 "git.sch9.ru/new_gate/ms-tester/contracts/tester/v1"
"github.com/gofiber/fiber/v2"
)

View file

@ -1,10 +1,10 @@
package rest
import (
testerv1 "git.sch9.ru/new_gate/ms-tester/contracts/tester/v1"
"git.sch9.ru/new_gate/ms-tester/internal/models"
"git.sch9.ru/new_gate/ms-tester/internal/tester"
"git.sch9.ru/new_gate/ms-tester/pkg"
testerv1 "git.sch9.ru/new_gate/ms-tester/proto/tester/v1"
"github.com/gofiber/fiber/v2"
"io"
)
@ -99,6 +99,15 @@ func (h *TesterHandlers) GetContest(c *fiber.Ctx, id int32) error {
return c.SendStatus(pkg.ToREST(err))
}
var participantId int32 = 2
solutions, err := h.contestsUC.ReadBestSolutions(c.Context(), id, participantId)
m := make(map[int32]*models.Solution)
for i := 0; i < len(solutions); i++ {
m[solutions[i].TaskPosition] = solutions[i]
}
resp := testerv1.GetContestResponse{
Contest: C2C(*contest),
Tasks: make([]struct {
@ -108,11 +117,15 @@ func (h *TesterHandlers) GetContest(c *fiber.Ctx, id int32) error {
}
for i, task := range tasks {
solution := testerv1.Solution{}
if sol, ok := m[task.Position]; ok {
solution = S2S(*sol)
}
resp.Tasks[i] = struct {
Solution testerv1.Solution `json:"solution"`
Task testerv1.TasksListItem `json:"task"`
}{
Solution: testerv1.Solution{},
Solution: solution,
Task: TLI2TLI(*task),
}
}

View file

@ -49,4 +49,5 @@ type ContestRepository interface {
ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error)
ReadTask(ctx context.Context, id int32) (*models.Task, error)
ReadMonitor(ctx context.Context, id int32) (*models.Monitor, error)
ReadBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.Solution, error)
}

View file

@ -2,9 +2,10 @@ package repository
import (
"context"
"fmt"
"git.sch9.ru/new_gate/ms-tester/internal/models"
sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
"strings"
)
type ContestRepository struct {
@ -171,7 +172,7 @@ const (
func (r *ContestRepository) ListContests(ctx context.Context, filter models.ContestsFilter) (*models.ContestsList, error) {
const op = "ContestRepository.ReadTasks"
contests := make([]*models.ContestsListItem, 0)
var contests []*models.ContestsListItem
query := r.db.Rebind(readContestsListQuery)
err := r.db.SelectContext(ctx, &contests, query, filter.PageSize, filter.Offset())
if err != nil {
@ -206,7 +207,7 @@ func (r *ContestRepository) ListParticipants(ctx context.Context, filter models.
filter.PageSize = 1
}
participants := make([]*models.ParticipantsListItem, 0)
var participants []*models.ParticipantsListItem
query := r.db.Rebind(readParticipantsListQuery)
err := r.db.SelectContext(ctx, &participants, query, filter.ContestId, filter.PageSize, filter.Offset())
if err != nil {
@ -312,94 +313,93 @@ func (r *ContestRepository) CreateSolution(ctx context.Context, creation *models
return id, nil
}
// buildListSolutionsQueries builds two SQL queries: one for selecting solutions
// and another for counting them. The first query selects all columns that are
// needed for the solutions list, including the task and contest titles, and
// the participant name. The second query counts the number of solutions that
// match the filter.
//
// The caller is responsible for executing the queries and processing the
// results.
func buildListSolutionsQueries(filter models.SolutionsFilter) (sq.SelectBuilder, sq.SelectBuilder) {
columns := []string{
"s.id",
"s.participant_id",
"p2.name AS participant_name",
"s.state",
"s.score",
"s.penalty",
"s.time_stat",
"s.memory_stat",
"s.language",
"s.task_id",
"t.position AS task_position",
"p.title AS task_title",
"t.contest_id",
"c.title",
"s.updated_at",
"s.created_at",
}
qb := sq.Select(columns...).
From("solutions s").
LeftJoin("tasks t ON s.task_id = t.id").
LeftJoin("problems p ON t.problem_id = p.id").
LeftJoin("contests c ON t.contest_id = c.id").
LeftJoin("participants p2 ON s.participant_id = p2.id")
if filter.ContestId != nil {
qb = qb.Where("s.contest_id = ?", *filter.ContestId)
}
if filter.ParticipantId != nil {
qb = qb.Where("s.participant_id = ?", *filter.ParticipantId)
}
if filter.TaskId != nil {
qb = qb.Where("s.task_id = ?", *filter.TaskId)
}
if filter.Language != nil {
qb = qb.Where("s.language = ?", *filter.Language)
}
if filter.State != nil {
qb = qb.Where("s.state = ?", *filter.State)
}
countQb := sq.Select("COUNT(*)").FromSelect(qb, "sub")
if filter.Order != nil && *filter.Order < 0 {
qb = qb.OrderBy("s.id DESC")
}
qb = qb.Limit(uint64(filter.PageSize)).Offset(uint64(filter.Offset()))
return qb, countQb
}
func (r *ContestRepository) ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error) {
const op = "ContestRepository.ListSolutions"
baseQb, countQb := buildListSolutionsQueries(filter)
baseQuery := `
SELECT s.id,
query, args, err := countQb.ToSql()
if err != nil {
return nil, handlePgErr(err, op)
s.participant_id,
p2.name as participant_name,
s.state,
s.score,
s.penalty,
s.time_stat,
s.memory_stat,
s.language,
s.task_id,
t.position as task_position,
p.title as task_title,
t.contest_id,
c.title,
s.updated_at,
s.created_at
FROM solutions s
LEFT JOIN tasks t ON s.task_id = t.id
LEFT JOIN problems p ON t.problem_id = p.id
LEFT JOIN contests c ON t.contest_id = c.id
LEFT JOIN participants p2 on s.participant_id = p2.id
WHERE 1=1
`
var conditions []string
var args []interface{}
if filter.ContestId != nil {
conditions = append(conditions, "s.contest_id = ?")
args = append(args, *filter.ContestId)
}
if filter.ParticipantId != nil {
conditions = append(conditions, "s.participant_id = ?")
args = append(args, *filter.ParticipantId)
}
if filter.TaskId != nil {
conditions = append(conditions, "s.task_id = ?")
args = append(args, *filter.TaskId)
}
if filter.Language != nil {
conditions = append(conditions, "s.language = ?")
args = append(args, *filter.Language)
}
if filter.State != nil {
conditions = append(conditions, "s.state = ?")
args = append(args, *filter.State)
}
if len(conditions) > 0 {
baseQuery += " AND " + strings.Join(conditions, " AND ")
}
if filter.Order != nil {
orderDirection := "ASC"
if *filter.Order < 0 {
orderDirection = "DESC"
}
baseQuery += fmt.Sprintf(" ORDER BY s.id %s", orderDirection)
}
countQuery := "SELECT COUNT(*) FROM (" + baseQuery + ") as count_table"
var totalCount int32
err = r.db.QueryRowxContext(ctx, r.db.Rebind(query), args...).Scan(&totalCount)
err := r.db.QueryRowxContext(ctx, r.db.Rebind(countQuery), args...).Scan(&totalCount)
if err != nil {
return nil, handlePgErr(err, op)
}
query, args, err = baseQb.ToSql()
if err != nil {
return nil, handlePgErr(err, op)
}
rows, err := r.db.QueryxContext(ctx, r.db.Rebind(query), args...)
offset := (filter.Page - 1) * filter.PageSize
baseQuery += " LIMIT ? OFFSET ?"
args = append(args, filter.PageSize, offset)
rows, err := r.db.QueryxContext(ctx, r.db.Rebind(baseQuery), args...)
if err != nil {
return nil, handlePgErr(err, op)
}
defer rows.Close()
solutions := make([]*models.SolutionsListItem, 0)
var solutions []*models.SolutionsListItem
for rows.Next() {
var solution models.SolutionsListItem
err = rows.StructScan(&solution)
@ -551,7 +551,7 @@ WITH Attempts AS (
MIN(CASE WHEN s.state = 5 THEN s.penalty END) as success_penalty
FROM solutions s
JOIN tasks t ON t.id = s.task_id
WHERE t.contest_id = ?
WHERE t.contest_id = :contest_id
GROUP BY s.participant_id, s.task_id
)
SELECT
@ -559,11 +559,11 @@ SELECT
p.name,
COUNT(DISTINCT CASE WHEN a.success_penalty IS NOT NULL THEN a.task_id END) as solved_in_total,
COALESCE(SUM(CASE WHEN a.success_penalty IS NOT NULL
THEN a.failed_attempts * ? + a.success_penalty
THEN a.failed_attempts * :penalty + a.success_penalty
ELSE 0 END), 0) as penalty_in_total
FROM participants p
LEFT JOIN Attempts a ON a.participant_id = p.id
WHERE p.contest_id = ?
WHERE p.contest_id = :contest_id
GROUP BY p.id, p.name
`
)
@ -596,7 +596,10 @@ func (r *ContestRepository) ReadMonitor(ctx context.Context, contestId int32) (*
penalty := int32(20) // FIXME
namedQuery := r.db.Rebind(participantsQuery)
rows3, err := r.db.QueryxContext(ctx, namedQuery, contestId, penalty, contestId)
rows3, err := r.db.NamedQueryContext(ctx, namedQuery, map[string]interface{}{
"contest_id": contestId,
"penalty": penalty,
})
if err != nil {
return nil, handlePgErr(err, op)
}
@ -623,3 +626,65 @@ func (r *ContestRepository) ReadMonitor(ctx context.Context, contestId int32) (*
return &monitor, nil
}
const (
// state=5 - AC
readBestSolutions = `
WITH contest_tasks AS (
SELECT t.id AS task_id,
t.position AS task_position,
t.contest_id,
t.problem_id,
t.created_at,
t.updated_at,
p.title AS task_title,
c.title AS contest_title
FROM tasks t
LEFT JOIN problems p ON p.id = t.problem_id
LEFT JOIN contests c ON c.id = t.contest_id
WHERE t.contest_id = ?
),
best_solutions AS (
SELECT DISTINCT ON (s.task_id)
*
FROM solutions s
WHERE s.participant_id = ?
ORDER BY s.task_id, s.score DESC, s.created_at DESC
)
SELECT
s.id,
s.participant_id,
p.name AS participant_name,
s.solution,
s.state,
s.score,
s.penalty,
s.time_stat,
s.memory_stat,
s.language,
ct.task_id,
ct.task_position,
ct.task_title,
ct.contest_id,
ct.contest_title,
s.updated_at,
s.created_at
FROM contest_tasks ct
LEFT JOIN best_solutions s ON s.task_id = ct.task_id
LEFT JOIN participants p ON p.id = s.participant_id WHERE s.id IS NOT NULL
ORDER BY ct.task_position
`
)
func (r *ContestRepository) ReadBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.Solution, error) {
const op = "ContestRepository.ReadBestSolutions"
var solutions []*models.Solution
query := r.db.Rebind(readBestSolutions)
err := r.db.SelectContext(ctx, &solutions, query, contestId, participantId)
if err != nil {
return nil, handlePgErr(err, op)
}
return solutions, nil
}

File diff suppressed because it is too large Load diff

View file

@ -2,153 +2,57 @@ package repository
import (
"context"
"database/sql"
"git.sch9.ru/new_gate/ms-tester/internal/models"
"github.com/DATA-DOG/go-sqlmock"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"testing"
)
type problemTestFixture struct {
db *sql.DB
sqlxDB *sqlx.DB
mock sqlmock.Sqlmock
problemRepo *ProblemRepository
}
func TestProblemRepository_CreateProblem(t *testing.T) {
t.Parallel()
func newProblemTestFixture(t *testing.T) *problemTestFixture {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
require.NoError(t, err)
defer db.Close()
sqlxDB := sqlx.NewDb(db, "sqlmock")
repo := NewProblemRepository(sqlxDB)
defer sqlxDB.Close()
return &problemTestFixture{
db: db,
sqlxDB: sqlxDB,
mock: mock,
problemRepo: repo,
}
}
// cleanup closes database connections
func (tf *problemTestFixture) cleanup() {
tf.db.Close()
tf.sqlxDB.Close()
}
func TestProblemRepository_CreateProblem(t *testing.T) {
tf := newProblemTestFixture(t)
defer tf.cleanup()
problemRepo := NewProblemRepository(sqlxDB, zap.NewNop())
t.Run("valid problem creation", func(t *testing.T) {
title := "Test Problem"
title := "Problem title"
rows := sqlmock.NewRows([]string{"id"}).AddRow(1)
tf.mock.ExpectQuery(tf.sqlxDB.Rebind(createProblemQuery)).
WithArgs(title).
WillReturnRows(rows)
mock.ExpectQuery(sqlxDB.Rebind(createProblemQuery)).WithArgs(title).WillReturnRows(rows)
id, err := tf.problemRepo.CreateProblem(context.Background(), tf.problemRepo.DB(), title)
id, err := problemRepo.CreateProblem(context.Background(), title)
require.NoError(t, err)
require.Equal(t, int32(1), id)
})
}
func TestProblemRepository_ReadProblemById(t *testing.T) {
tf := newProblemTestFixture(t)
defer tf.cleanup()
t.Run("valid problem read", func(t *testing.T) {
id := int32(1)
rows := sqlmock.NewRows([]string{"id", "title"}).
AddRow(1, "Test Problem")
tf.mock.ExpectQuery(tf.sqlxDB.Rebind(readProblemQuery)).
WithArgs(id).
WillReturnRows(rows)
problem, err := tf.problemRepo.ReadProblemById(context.Background(), tf.problemRepo.DB(), id)
require.NoError(t, err)
require.NotNil(t, problem)
require.Equal(t, int32(1), problem.Id)
require.Equal(t, "Test Problem", problem.Title)
})
}
func TestProblemRepository_DeleteProblem(t *testing.T) {
tf := newProblemTestFixture(t)
defer tf.cleanup()
t.Parallel()
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
require.NoError(t, err)
defer db.Close()
sqlxDB := sqlx.NewDb(db, "sqlmock")
defer sqlxDB.Close()
problemRepo := NewProblemRepository(sqlxDB, zap.NewNop())
t.Run("valid problem deletion", func(t *testing.T) {
id := int32(1)
rows := sqlmock.NewResult(1, 1)
tf.mock.ExpectExec(tf.sqlxDB.Rebind(deleteProblemQuery)).
WithArgs(id).
WillReturnResult(rows)
mock.ExpectExec(sqlxDB.Rebind(deleteProblemQuery)).WithArgs(id).WillReturnResult(rows)
err := tf.problemRepo.DeleteProblem(context.Background(), tf.problemRepo.DB(), id)
require.NoError(t, err)
})
}
func TestProblemRepository_ListProblems(t *testing.T) {
tf := newProblemTestFixture(t)
defer tf.cleanup()
t.Run("valid problems list", func(t *testing.T) {
filter := models.ProblemsFilter{
Page: 1,
PageSize: 10,
}
listRows := sqlmock.NewRows([]string{"id", "title", "solved_count"}).
AddRow(1, "Problem 1", 5).
AddRow(2, "Problem 2", 3)
countRows := sqlmock.NewRows([]string{"count"}).AddRow(2)
tf.mock.ExpectQuery(tf.sqlxDB.Rebind(ListProblemsQuery)).
WithArgs(filter.PageSize, filter.Offset()).
WillReturnRows(listRows)
tf.mock.ExpectQuery(tf.sqlxDB.Rebind(CountProblemsQuery)).
WillReturnRows(countRows)
result, err := tf.problemRepo.ListProblems(context.Background(), tf.problemRepo.DB(), filter)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result.Problems, 2)
require.Equal(t, int32(1), result.Pagination.Page)
require.Equal(t, int32(1), result.Pagination.Total)
})
}
func TestProblemRepository_UpdateProblem(t *testing.T) {
tf := newProblemTestFixture(t)
defer tf.cleanup()
t.Run("valid problem update", func(t *testing.T) {
id := int32(1)
problemUpdate := models.ProblemUpdate{
Title: sp("Updated Title"),
TimeLimit: i32p(1000),
MemoryLimit: i32p(256),
}
rows := sqlmock.NewResult(1, 1)
tf.mock.ExpectExec(tf.sqlxDB.Rebind(UpdateProblemQuery)).
WithArgs(
problemUpdate.Title,
problemUpdate.TimeLimit,
problemUpdate.MemoryLimit,
nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil,
id,
).
WillReturnResult(rows)
err := tf.problemRepo.UpdateProblem(context.Background(), tf.problemRepo.DB(), id, problemUpdate)
err = problemRepo.DeleteProblem(context.Background(), id)
require.NoError(t, err)
})
}

View file

@ -31,4 +31,5 @@ type ContestUseCase interface {
ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error)
ReadTask(ctx context.Context, id int32) (*models.Task, error)
ReadMonitor(ctx context.Context, id int32) (*models.Monitor, error)
ReadBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.Solution, error)
}

View file

@ -85,3 +85,7 @@ func (uc *ContestUseCase) ReadTask(ctx context.Context, id int32) (*models.Task,
func (uc *ContestUseCase) ReadMonitor(ctx context.Context, contestId int32) (*models.Monitor, error) {
return uc.contestRepo.ReadMonitor(ctx, contestId)
}
func (uc *ContestUseCase) ReadBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.Solution, error) {
return uc.contestRepo.ReadBestSolutions(ctx, contestId, participantId)
}

View file

@ -3,11 +3,11 @@ package main
import (
"fmt"
"git.sch9.ru/new_gate/ms-tester/config"
testerv1 "git.sch9.ru/new_gate/ms-tester/contracts/tester/v1"
"git.sch9.ru/new_gate/ms-tester/internal/tester/delivery/rest"
problemsRepository "git.sch9.ru/new_gate/ms-tester/internal/tester/repository"
testerUseCase "git.sch9.ru/new_gate/ms-tester/internal/tester/usecase"
"git.sch9.ru/new_gate/ms-tester/pkg"
testerv1 "git.sch9.ru/new_gate/ms-tester/proto/tester/v1"
"github.com/gofiber/fiber/v2"
fiberlogger "github.com/gofiber/fiber/v2/middleware/logger"
"github.com/ilyakaznacheev/cleanenv"

View file

@ -9,6 +9,24 @@ BEGIN
END;
$$;
CREATE FUNCTION check_max_tasks() RETURNS TRIGGER
LANGUAGE plpgsql AS
$$
DECLARE
max_on_contest_tasks_amount integer := 50;
BEGIN
IF (
SELECT count(*) FROM tasks
WHERE contest_id = NEW.contest_id
) >= (
max_on_contest_tasks_amount
) THEN
RAISE EXCEPTION 'Exceeded max tasks for this contest';
END IF;
RETURN NEW;
END;
$$;
CREATE TABLE IF NOT EXISTS problems
(
id serial NOT NULL,
@ -73,6 +91,11 @@ CREATE TABLE IF NOT EXISTS tasks
CHECK (position >= 0)
);
CREATE TRIGGER max_tasks_on_contest_check
BEFORE INSERT ON tasks
FOR EACH STATEMENT
EXECUTE FUNCTION check_max_tasks();
CREATE TRIGGER on_tasks_update
BEFORE UPDATE
ON tasks
@ -98,6 +121,7 @@ CREATE TRIGGER on_participants_update
FOR EACH ROW
EXECUTE PROCEDURE updated_at_update();
CREATE TABLE IF NOT EXISTS solutions
(
id serial NOT NULL,
@ -125,15 +149,17 @@ EXECUTE PROCEDURE updated_at_update();
-- +goose Down
-- +goose StatementBegin
DROP TRIGGER IF EXISTS on_solutions_update ON solutions;
DROP TABLE IF EXISTS solutions;
DROP TRIGGER IF EXISTS on_participants_update ON participants;
DROP TABLE IF EXISTS participants;
DROP TRIGGER IF EXISTS on_tasks_update ON tasks;
DROP TRIGGER IF EXISTS max_tasks_on_contest_check ON tasks;
DROP TABLE IF EXISTS tasks;
DROP TRIGGER IF EXISTS on_problems_update ON problems;
DROP TABLE IF EXISTS problems;
DROP TRIGGER IF EXISTS on_contests_update ON contests;
DROP TABLE IF EXISTS contests;
DROP TRIGGER IF EXISTS on_tasks_update ON tasks;
DROP TABLE IF EXISTS tasks;
DROP TRIGGER IF EXISTS on_participants_update ON participants;
DROP TABLE IF EXISTS participants;
DROP TRIGGER IF EXISTS on_solutions_update ON solutions;
DROP TABLE IF EXISTS solutions;
DROP FUNCTION updated_at_update();
-- +goose StatementEnd
DROP FUNCTION IF EXISTS updated_at_update();
DROP FUNCTION IF EXISTS check_max_tasks();
-- +goose StatementEnd