From 441af4c6a2110e3d1e17fae1268d2923af926904 Mon Sep 17 00:00:00 2001 From: Vyacheslav1557 Date: Tue, 22 Apr 2025 20:44:52 +0500 Subject: [PATCH] feat: merge auth&tester --- .gitmodules | 1 + config/config.go | 13 +- contracts | 2 +- go.mod | 61 +- go.sum | 180 ++--- internal/auth/delivery.go | 13 + internal/auth/delivery/rest/handlers.go | 160 ++++ internal/auth/usecase.go | 14 + internal/auth/usecase/usecase.go | 70 ++ internal/{tester => contests}/delivery.go | 14 +- .../delivery/rest/contests_handlers.go | 202 +++++ internal/contests/delivery/rest/dto.go | 202 +++++ .../delivery/rest/monitor_handlers.go | 71 ++ .../delivery/rest/participants_handlers.go | 116 +++ .../delivery/rest/solutions_handlers.go | 148 ++++ .../contests/delivery/rest/tasks_handlers.go | 105 +++ internal/contests/pg_repository.go | 34 + .../repository/contests_pg_repository.go | 145 ++++ .../repository/contests_pg_repository_test.go | 116 +++ .../repository/monitor_pg_repository.go | 161 ++++ .../repository/monitor_pg_repository_test.go | 1 + .../repository/participants_pg_repository.go | 126 ++++ .../participants_pg_repository_test.go | 51 ++ .../repository/solutions_pg_repository.go | 222 ++++++ .../solutions_pg_repository_test.go | 1 + .../repository/tasks_pg_repository.go | 101 +++ .../repository/tasks_pg_repository_test.go | 51 ++ internal/contests/usecase.go | 34 + internal/contests/usecase/contests_usecase.go | 39 + internal/contests/usecase/monitor_usecase.go | 10 + .../contests/usecase/participants_usecase.go | 34 + .../contests/usecase/solutions_usecase.go | 29 + internal/contests/usecase/tasks_usecase.go | 22 + .../middlewares.go => middleware/auth.go} | 33 +- internal/models/contest.go | 72 ++ internal/models/participant.go | 41 -- internal/models/problem.go | 1 - internal/models/session.go | 205 ++---- internal/models/solution.go | 1 + internal/models/task.go | 35 - internal/models/user.go | 71 ++ internal/problems/delivery.go | 15 + internal/problems/delivery/rest/handlers.go | 246 +++++++ internal/problems/pg_repository.go | 32 + internal/problems/repository/pg_repository.go | 175 +++++ .../problems/repository/pg_repository_test.go | 293 ++++++++ internal/problems/usecase.go | 15 + .../usecase/usecase.go} | 34 +- .../sessions/repository/valkey_repository.go | 183 +++++ .../repository/valkey_repository_test.go | 312 ++++++++ internal/sessions/usecase.go | 14 + internal/sessions/usecase/usecase.go | 78 ++ internal/sessions/valkey_repository.go | 14 + internal/tester/delivery/rest/handlers.go | 622 ---------------- internal/tester/pg_repository.go | 53 -- internal/tester/repository/error.go | 27 - .../repository/pg_contests_repository.go | 690 ------------------ .../repository/pg_contests_repository_test.go | 154 ---- .../repository/pg_problems_repository.go | 184 ----- .../repository/pg_problems_repository_test.go | 58 -- internal/tester/usecase.go | 37 - internal/tester/usecase/contests_usecase.go | 91 --- internal/users/delivery.go | 14 + internal/users/delivery/rest/handlers.go | 204 ++++++ internal/users/pg_repository.go | 33 + internal/users/repository/pg_repository.go | 156 ++++ .../users/repository/pg_repository_test.go | 222 ++++++ internal/users/usecase.go | 15 + internal/users/usecase/usecase.go | 164 +++++ main.go | 75 +- migrations/20240727123308_initial.sql | 49 +- pkg/errors.go | 21 + 72 files changed, 4910 insertions(+), 2378 deletions(-) create mode 100644 internal/auth/delivery.go create mode 100644 internal/auth/delivery/rest/handlers.go create mode 100644 internal/auth/usecase.go create mode 100644 internal/auth/usecase/usecase.go rename internal/{tester => contests}/delivery.go (66%) create mode 100644 internal/contests/delivery/rest/contests_handlers.go create mode 100644 internal/contests/delivery/rest/dto.go create mode 100644 internal/contests/delivery/rest/monitor_handlers.go create mode 100644 internal/contests/delivery/rest/participants_handlers.go create mode 100644 internal/contests/delivery/rest/solutions_handlers.go create mode 100644 internal/contests/delivery/rest/tasks_handlers.go create mode 100644 internal/contests/pg_repository.go create mode 100644 internal/contests/repository/contests_pg_repository.go create mode 100644 internal/contests/repository/contests_pg_repository_test.go create mode 100644 internal/contests/repository/monitor_pg_repository.go create mode 100644 internal/contests/repository/monitor_pg_repository_test.go create mode 100644 internal/contests/repository/participants_pg_repository.go create mode 100644 internal/contests/repository/participants_pg_repository_test.go create mode 100644 internal/contests/repository/solutions_pg_repository.go create mode 100644 internal/contests/repository/solutions_pg_repository_test.go create mode 100644 internal/contests/repository/tasks_pg_repository.go create mode 100644 internal/contests/repository/tasks_pg_repository_test.go create mode 100644 internal/contests/usecase.go create mode 100644 internal/contests/usecase/contests_usecase.go create mode 100644 internal/contests/usecase/monitor_usecase.go create mode 100644 internal/contests/usecase/participants_usecase.go create mode 100644 internal/contests/usecase/solutions_usecase.go create mode 100644 internal/contests/usecase/tasks_usecase.go rename internal/{tester/delivery/rest/middlewares.go => middleware/auth.go} (67%) delete mode 100644 internal/models/participant.go delete mode 100644 internal/models/task.go create mode 100644 internal/models/user.go create mode 100644 internal/problems/delivery.go create mode 100644 internal/problems/delivery/rest/handlers.go create mode 100644 internal/problems/pg_repository.go create mode 100644 internal/problems/repository/pg_repository.go create mode 100644 internal/problems/repository/pg_repository_test.go create mode 100644 internal/problems/usecase.go rename internal/{tester/usecase/problems_usecase.go => problems/usecase/usecase.go} (88%) create mode 100644 internal/sessions/repository/valkey_repository.go create mode 100644 internal/sessions/repository/valkey_repository_test.go create mode 100644 internal/sessions/usecase.go create mode 100644 internal/sessions/usecase/usecase.go create mode 100644 internal/sessions/valkey_repository.go delete mode 100644 internal/tester/delivery/rest/handlers.go delete mode 100644 internal/tester/pg_repository.go delete mode 100644 internal/tester/repository/error.go delete mode 100644 internal/tester/repository/pg_contests_repository.go delete mode 100644 internal/tester/repository/pg_contests_repository_test.go delete mode 100644 internal/tester/repository/pg_problems_repository.go delete mode 100644 internal/tester/repository/pg_problems_repository_test.go delete mode 100644 internal/tester/usecase.go delete mode 100644 internal/tester/usecase/contests_usecase.go create mode 100644 internal/users/delivery.go create mode 100644 internal/users/delivery/rest/handlers.go create mode 100644 internal/users/pg_repository.go create mode 100644 internal/users/repository/pg_repository.go create mode 100644 internal/users/repository/pg_repository_test.go create mode 100644 internal/users/usecase.go create mode 100644 internal/users/usecase/usecase.go diff --git a/.gitmodules b/.gitmodules index 6fd0375..f616f3b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "proto"] path = contracts url = https://git.sch9.ru/new_gate/contracts + update = rebase diff --git a/config/config.go b/config/config.go index c061e5a..7c2bfa4 100644 --- a/config/config.go +++ b/config/config.go @@ -1,11 +1,18 @@ package config type Config struct { - Env string `env:"ENV" env-default:"prod"` + Env string `env:"ENV" env-default:"prod"` + + Address string `env:"ADDRESS" required:"true"` + Pandoc string `env:"PANDOC" required:"true"` - Address string `env:"ADDRESS" required:"true"` PostgresDSN string `env:"POSTGRES_DSN" required:"true"` - JWTSecret string `env:"JWT_SECRET" required:"true"` + RedisDSN string `env:"REDIS_DSN" required:"true"` + + JWTSecret string `env:"JWT_SECRET" required:"true"` + + AdminUsername string `env:"ADMIN_USERNAME" env-default:"admin"` + AdminPassword string `env:"ADMIN_PASSWORD" env-default:"admin"` //RabbitDSN string `env:"RABBIT_DSN" required:"true"` //InstanceName string `env:"INSTANCE_NAME" required:"true"` diff --git a/contracts b/contracts index 89b4b19..a0e07aa 160000 --- a/contracts +++ b/contracts @@ -1 +1 @@ -Subproject commit 89b4b19ae383c17665f0c3176e3d4122e90e46ec +Subproject commit a0e07aab6642f7594065af8a3b28702156e2aa7d diff --git a/go.mod b/go.mod index 42f6dcd..b705472 100644 --- a/go.mod +++ b/go.mod @@ -4,75 +4,56 @@ 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/golang-jwt/jwt/v4 v4.5.2 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 github.com/stretchr/testify v1.10.0 - github.com/valkey-io/valkey-go v1.0.47 + github.com/valkey-io/valkey-go v1.0.57 + github.com/valkey-io/valkey-go/mock v1.0.57 + go.uber.org/mock v0.5.1 go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.36.0 ) require ( - github.com/agnivade/levenshtein v1.2.1 // indirect - github.com/andybalholm/brotli v1.1.0 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-ini/ini v1.67.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/gobwas/glob v0.2.3 // indirect + github.com/go-sql-driver/mysql v1.9.1 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/gorilla/mux v1.8.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - 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/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/kr/pretty v0.3.1 // 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 - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.62.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/tchap/go-patricia/v2 v2.3.2 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/yashtewari/glob-intersection v0.2.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - go.opentelemetry.io/otel/sdk v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect - google.golang.org/protobuf v1.36.3 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect ) require ( github.com/BurntSushi/toml v1.3.2 // indirect - github.com/jackc/pgx/v5 v5.6.0 + github.com/jackc/pgx/v5 v5.7.4 github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 16d2140..a18b9bc 100644 --- a/go.sum +++ b/go.sum @@ -5,98 +5,62 @@ 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= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= -github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA= -github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgraph-io/badger/v4 v4.5.1 h1:7DCIXrQjo1LKmM96YD+hLVJ2EEsyyoWxJfpdd56HLps= -github.com/dgraph-io/badger/v4 v4.5.1/go.mod h1:qn3Be0j3TfV4kPbVoK0arXCD1/nr1ftth6sbL5jxdoA= -github.com/dgraph-io/ristretto/v2 v2.1.0 h1:59LjpOJLNDULHh8MC4UaegN52lC4JnO2dITsie/Pa8I= -github.com/dgraph-io/ristretto/v2 v2.1.0/go.mod h1:uejeqfYXpUomfse0+lO+13ATz4TypQYLJZzBSAemuB4= -github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= -github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= -github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= -github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= -github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= -github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= -github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= -github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= +github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/google/flatbuffers v24.12.23+incompatible h1:ubBKR94NR4pXUCY/MUsRVzd9umNW7ht7EG9hHfS9FX8= -github.com/google/flatbuffers v24.12.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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= @@ -110,115 +74,61 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= -github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/open-policy-agent/opa v1.2.0 h1:88NDVCM0of1eO6Z4AFeL3utTEtMuwloFmWWU7dRV1z0= -github.com/open-policy-agent/opa v1.2.0/go.mod h1:30euUmOvuBoebRCcJ7DMF42bRBOPznvt0ACUMYDUGVY= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA= -github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= -github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 h1:MkV+77GLUNo5oJ0jf870itWm3D0Sjh7+Za9gazKc5LQ= -github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -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= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tchap/go-patricia/v2 v2.3.2 h1:xTHFutuitO2zqKAQ5rCROYgUb7Or/+IC3fts9/Yc7nM= -github.com/tchap/go-patricia/v2 v2.3.2/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= -github.com/valkey-io/valkey-go v1.0.47 h1:fW5+m2BaLAbxB1EWEEWmj+i2n+YcYFBDG/jKs6qu5j8= -github.com/valkey-io/valkey-go v1.0.47/go.mod h1:BXlVAPIL9rFQinSFM+N32JfWzfCaUAqBpZkc4vPY6fM= +github.com/valkey-io/valkey-go v1.0.57 h1:rMpREZ7kvWwv9vHkB1WTpI9rX4dQHsvPHimSWenScvI= +github.com/valkey-io/valkey-go v1.0.57/go.mod h1:sxpCChk8i3oTG+A/lUi9Lj8C/7WI+yhnQCvDJlPVKNM= +github.com/valkey-io/valkey-go/mock v1.0.57 h1:ft06MuqCCKlob/R5dzUv4zNnNu+GaqElalApOFS5Fc4= +github.com/valkey-io/valkey-go/mock v1.0.57/go.mod h1:VDiXrmHdRCz/UT4xzMkfQEc5iHa7naDpqsZ+lotmJE8= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= -github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs= +go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= -golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= -google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA= -google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= -google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= -google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= -google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= -google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -227,5 +137,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/auth/delivery.go b/internal/auth/delivery.go new file mode 100644 index 0000000..45a5bb7 --- /dev/null +++ b/internal/auth/delivery.go @@ -0,0 +1,13 @@ +package auth + +import ( + "github.com/gofiber/fiber/v2" +) + +type AuthHandlers interface { + ListSessions(c *fiber.Ctx) error + Terminate(c *fiber.Ctx) error + Login(c *fiber.Ctx) error + Logout(c *fiber.Ctx) error + Refresh(c *fiber.Ctx) error +} diff --git a/internal/auth/delivery/rest/handlers.go b/internal/auth/delivery/rest/handlers.go new file mode 100644 index 0000000..7d95003 --- /dev/null +++ b/internal/auth/delivery/rest/handlers.go @@ -0,0 +1,160 @@ +package rest + +import ( + "context" + "encoding/base64" + "git.sch9.ru/new_gate/ms-tester/internal/auth" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/pkg" + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v4" + "strings" + "time" +) + +type Handlers struct { + authUC auth.UseCase + jwtSecret string +} + +func NewHandlers(authUC auth.UseCase, jwtSecret string) *Handlers { + return &Handlers{ + authUC: authUC, + jwtSecret: jwtSecret, + } +} + +const ( + sessionKey = "session" +) + +func sessionFromCtx(ctx context.Context) (*models.Session, error) { + const op = "sessionFromCtx" + + session, ok := ctx.Value(sessionKey).(*models.Session) + if !ok { + return nil, pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "") + } + + return session, nil +} + +func (h *Handlers) ListSessions(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusNotImplemented) +} + +func (h *Handlers) Terminate(c *fiber.Ctx) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + err = h.authUC.Terminate(ctx, session.UserId) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) +} + +func (h *Handlers) Login(c *fiber.Ctx) error { + authHeader := c.Get("Authorization", "") + if authHeader == "" { + return c.SendStatus(fiber.StatusUnauthorized) + } + + username, pwd, err := parseBasicAuth(authHeader) + if err != nil { + return c.SendStatus(fiber.StatusUnauthorized) + } + + credentials := &models.Credentials{ + Username: strings.ToLower(username), + Password: pwd, + } + device := &models.Device{ + Ip: c.IP(), + UseAgent: c.Get("User-Agent", ""), + } + + ctx := c.Context() + + session, err := h.authUC.Login(ctx, credentials, device) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + claims := jwt.NewWithClaims(jwt.SigningMethodHS256, models.JWT{ + SessionId: session.Id, + UserId: session.UserId, + Role: session.Role, + IssuedAt: time.Now().Unix(), + }) + + token, err := claims.SignedString([]byte(h.jwtSecret)) + if err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + + c.Set("Authorization", "Bearer "+token) + + return c.SendStatus(fiber.StatusOK) +} + +func (h *Handlers) Logout(c *fiber.Ctx) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + err = h.authUC.Logout(c.Context(), session.Id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) +} + +func (h *Handlers) Refresh(c *fiber.Ctx) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + err = h.authUC.Refresh(c.Context(), session.Id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) +} + +func parseBasicAuth(header string) (string, string, error) { + const ( + op = "parseBasicAuth" + msg = "invalid auth header" + ) + + authParts := strings.Split(header, " ") + if len(authParts) != 2 || strings.ToLower(authParts[0]) != "basic" { + return "", "", pkg.Wrap(pkg.ErrUnauthenticated, nil, op, msg) + } + + decodedAuth, err := base64.StdEncoding.DecodeString(authParts[1]) + if err != nil { + return "", "", pkg.Wrap(pkg.ErrUnauthenticated, nil, op, msg) + } + + authParts = strings.Split(string(decodedAuth), ":") + if len(authParts) != 2 { + return "", "", pkg.Wrap(pkg.ErrUnauthenticated, nil, op, msg) + } + + return authParts[0], authParts[1], nil +} diff --git a/internal/auth/usecase.go b/internal/auth/usecase.go new file mode 100644 index 0000000..0199add --- /dev/null +++ b/internal/auth/usecase.go @@ -0,0 +1,14 @@ +package auth + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +type UseCase interface { + Login(ctx context.Context, credentials *models.Credentials, device *models.Device) (*models.Session, error) + Refresh(ctx context.Context, sessionId string) error + Logout(ctx context.Context, sessionId string) error + Terminate(ctx context.Context, userId int32) error + ListSessions(ctx context.Context, userId int32) ([]*models.Session, error) +} diff --git a/internal/auth/usecase/usecase.go b/internal/auth/usecase/usecase.go new file mode 100644 index 0000000..1e13dde --- /dev/null +++ b/internal/auth/usecase/usecase.go @@ -0,0 +1,70 @@ +package usecase + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/internal/sessions" + "git.sch9.ru/new_gate/ms-tester/internal/users" + "git.sch9.ru/new_gate/ms-tester/pkg" + "github.com/google/uuid" + "time" +) + +type UseCase struct { + usersUC users.UseCase + sessionsUC sessions.UseCase +} + +func NewUseCase(usersUC users.UseCase, sessionsUC sessions.UseCase) *UseCase { + return &UseCase{ + usersUC: usersUC, + sessionsUC: sessionsUC, + } +} + +func (uc *UseCase) Login(ctx context.Context, credentials *models.Credentials, device *models.Device) (*models.Session, error) { + const op = "UseCase.Login" + + user, err := uc.usersUC.ReadUserByUsername(ctx, credentials.Username) + if err != nil { + return nil, err + } + + if !user.IsSamePwd(credentials.Password) { + return nil, pkg.Wrap(pkg.ErrNotFound, nil, op, "password mismatch") + } + + session := &models.Session{ + Id: uuid.NewString(), + UserId: user.Id, + Role: user.Role, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(40 * time.Minute), + UserAgent: device.UseAgent, + Ip: device.Ip, + } + + err = uc.sessionsUC.CreateSession(ctx, session) + if err != nil { + return nil, err + } + + return session, nil +} + +func (uc *UseCase) Logout(ctx context.Context, sessionId string) error { + return uc.sessionsUC.DeleteSession(ctx, sessionId) +} + +func (uc *UseCase) Refresh(ctx context.Context, sessionId string) error { + return uc.sessionsUC.UpdateSession(ctx, sessionId) +} + +func (uc *UseCase) Terminate(ctx context.Context, userId int32) error { + return uc.sessionsUC.DeleteAllSessions(ctx, userId) +} + +func (uc *UseCase) ListSessions(ctx context.Context, userId int32) ([]*models.Session, error) { + // TODO: implement me + panic("implement me") +} diff --git a/internal/tester/delivery.go b/internal/contests/delivery.go similarity index 66% rename from internal/tester/delivery.go rename to internal/contests/delivery.go index 44493b5..2f7c5f0 100644 --- a/internal/tester/delivery.go +++ b/internal/contests/delivery.go @@ -1,11 +1,11 @@ -package tester +package contests import ( testerv1 "git.sch9.ru/new_gate/ms-tester/contracts/tester/v1" "github.com/gofiber/fiber/v2" ) -type Handlers interface { +type ContestsHandlers interface { ListContests(c *fiber.Ctx, params testerv1.ListContestsParams) error CreateContest(c *fiber.Ctx) error DeleteContest(c *fiber.Ctx, id int32) error @@ -14,18 +14,12 @@ type Handlers interface { DeleteParticipant(c *fiber.Ctx, params testerv1.DeleteParticipantParams) error ListParticipants(c *fiber.Ctx, params testerv1.ListParticipantsParams) error UpdateParticipant(c *fiber.Ctx, params testerv1.UpdateParticipantParams) error - AddParticipant(c *fiber.Ctx, params testerv1.AddParticipantParams) error - ListProblems(c *fiber.Ctx, params testerv1.ListProblemsParams) error - CreateProblem(c *fiber.Ctx) error - DeleteProblem(c *fiber.Ctx, id int32) error - GetProblem(c *fiber.Ctx, id int32) error - UpdateProblem(c *fiber.Ctx, id int32) error - UploadProblem(c *fiber.Ctx, id int32) error + CreateParticipant(c *fiber.Ctx, params testerv1.CreateParticipantParams) error ListSolutions(c *fiber.Ctx, params testerv1.ListSolutionsParams) error CreateSolution(c *fiber.Ctx, params testerv1.CreateSolutionParams) error GetSolution(c *fiber.Ctx, id int32) error DeleteTask(c *fiber.Ctx, id int32) error - AddTask(c *fiber.Ctx, params testerv1.AddTaskParams) error + CreateTask(c *fiber.Ctx, params testerv1.CreateTaskParams) error GetMonitor(c *fiber.Ctx, params testerv1.GetMonitorParams) error GetTask(c *fiber.Ctx, id int32) error } diff --git a/internal/contests/delivery/rest/contests_handlers.go b/internal/contests/delivery/rest/contests_handlers.go new file mode 100644 index 0000000..c6a1972 --- /dev/null +++ b/internal/contests/delivery/rest/contests_handlers.go @@ -0,0 +1,202 @@ +package rest + +import ( + "context" + testerv1 "git.sch9.ru/new_gate/ms-tester/contracts/tester/v1" + "git.sch9.ru/new_gate/ms-tester/internal/contests" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/internal/problems" + "git.sch9.ru/new_gate/ms-tester/pkg" + "github.com/gofiber/fiber/v2" +) + +type Handlers struct { + problemsUC problems.UseCase + contestsUC contests.UseCase + + jwtSecret string +} + +func NewHandlers(problemsUC problems.UseCase, contestsUC contests.UseCase, jwtSecret string) *Handlers { + return &Handlers{ + problemsUC: problemsUC, + contestsUC: contestsUC, + + jwtSecret: jwtSecret, + } +} + +const ( + sessionKey = "session" +) + +func sessionFromCtx(ctx context.Context) (*models.Session, error) { + const op = "sessionFromCtx" + + session, ok := ctx.Value(sessionKey).(*models.Session) + if !ok { + return nil, pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "") + } + + return session, nil +} + +func (h *Handlers) CreateContest(c *fiber.Ctx) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + id, err := h.contestsUC.CreateContest(ctx, "Название контеста") + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(&testerv1.CreateContestResponse{ + Id: id, + }) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) GetContest(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + contest, err := h.contestsUC.GetContest(ctx, id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + tasks, err := h.contestsUC.GetTasks(ctx, id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + solutions := make([]*models.SolutionsListItem, 0) + participantId, err := h.contestsUC.GetParticipantId(ctx, contest.Id, session.UserId) + if err == nil { // Admin or Teacher may not participate in contest + solutions, err = h.contestsUC.GetBestSolutions(ctx, id, participantId) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + } + + return c.JSON(GetContestResponseDTO(contest, tasks, solutions)) + case models.RoleStudent: + contest, err := h.contestsUC.GetContest(ctx, id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + tasks, err := h.contestsUC.GetTasks(ctx, id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + participantId, err := h.contestsUC.GetParticipantId(ctx, contest.Id, session.UserId) + solutions, err := h.contestsUC.GetBestSolutions(c.Context(), id, participantId) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(GetContestResponseDTO(contest, tasks, solutions)) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) UpdateContest(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + var req testerv1.UpdateContestRequest + err := c.BodyParser(&req) + if err != nil { + return err + } + + err = h.contestsUC.UpdateContest(ctx, id, models.ContestUpdate{ + Title: req.Title, + }) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) DeleteContest(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + err := h.contestsUC.DeleteContest(ctx, id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) ListContests(c *fiber.Ctx, params testerv1.ListContestsParams) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + filter := models.ContestsFilter{ + Page: params.Page, + PageSize: params.PageSize, + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + contestsList, err := h.contestsUC.ListContests(ctx, filter) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(ListContestsResponseDTO(contestsList)) + case models.RoleStudent: + filter.UserId = &session.UserId + contestsList, err := h.contestsUC.ListContests(ctx, filter) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(ListContestsResponseDTO(contestsList)) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} diff --git a/internal/contests/delivery/rest/dto.go b/internal/contests/delivery/rest/dto.go new file mode 100644 index 0000000..e2b909b --- /dev/null +++ b/internal/contests/delivery/rest/dto.go @@ -0,0 +1,202 @@ +package rest + +import ( + testerv1 "git.sch9.ru/new_gate/ms-tester/contracts/tester/v1" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +func GetContestResponseDTO(contest *models.Contest, + tasks []*models.TasksListItem, + solutions []*models.SolutionsListItem) *testerv1.GetContestResponse { + + m := make(map[int32]*models.SolutionsListItem) + + for i := 0; i < len(solutions); i++ { + m[solutions[i].TaskPosition] = solutions[i] + } + + resp := testerv1.GetContestResponse{ + Contest: ContestDTO(*contest), + Tasks: make([]struct { + Solution testerv1.SolutionsListItem `json:"solution"` + Task testerv1.TasksListItem `json:"task"` + }, len(tasks)), + } + + for i, task := range tasks { + solution := testerv1.SolutionsListItem{} + if sol, ok := m[task.Position]; ok { + solution = SolutionsListItemDTO(*sol) + } + resp.Tasks[i] = struct { + Solution testerv1.SolutionsListItem `json:"solution"` + Task testerv1.TasksListItem `json:"task"` + }{ + Solution: solution, + Task: TasksListItemDTO(*task), + } + } + + return &resp +} + +func ListContestsResponseDTO(contestsList *models.ContestsList) *testerv1.ListContestsResponse { + resp := testerv1.ListContestsResponse{ + Contests: make([]testerv1.ContestsListItem, len(contestsList.Contests)), + Pagination: PaginationDTO(contestsList.Pagination), + } + + for i, contest := range contestsList.Contests { + resp.Contests[i] = ContestsListItemDTO(*contest) + } + + return &resp +} + +func ListSolutionsResponseDTO(solutionsList *models.SolutionsList) *testerv1.ListSolutionsResponse { + resp := testerv1.ListSolutionsResponse{ + Solutions: make([]testerv1.SolutionsListItem, len(solutionsList.Solutions)), + Pagination: PaginationDTO(solutionsList.Pagination), + } + + for i, solution := range solutionsList.Solutions { + resp.Solutions[i] = SolutionsListItemDTO(*solution) + } + + return &resp +} + +func GetTaskResponseDTO(contest *models.Contest, tasks []*models.TasksListItem, task *models.Task) *testerv1.GetTaskResponse { + resp := testerv1.GetTaskResponse{ + Contest: ContestDTO(*contest), + Tasks: make([]testerv1.TasksListItem, len(tasks)), + Task: *TaskDTO(task), + } + + for i, t := range tasks { + resp.Tasks[i] = TasksListItemDTO(*t) + } + + return &resp +} + +func PaginationDTO(p models.Pagination) testerv1.Pagination { + return testerv1.Pagination{ + Page: p.Page, + Total: p.Total, + } +} + +func ContestDTO(c models.Contest) testerv1.Contest { + return testerv1.Contest{ + Id: c.Id, + Title: c.Title, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +func ContestsListItemDTO(c models.ContestsListItem) testerv1.ContestsListItem { + return testerv1.ContestsListItem{ + Id: c.Id, + Title: c.Title, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +func TasksListItemDTO(t models.TasksListItem) testerv1.TasksListItem { + return testerv1.TasksListItem{ + Id: t.Id, + Position: t.Position, + Title: t.Title, + MemoryLimit: t.MemoryLimit, + ProblemId: t.ProblemId, + TimeLimit: t.TimeLimit, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } +} + +func TaskDTO(t *models.Task) *testerv1.Task { + return &testerv1.Task{ + Id: t.Id, + Title: t.Title, + MemoryLimit: t.MemoryLimit, + TimeLimit: t.TimeLimit, + + InputFormatHtml: t.InputFormatHtml, + LegendHtml: t.LegendHtml, + NotesHtml: t.NotesHtml, + OutputFormatHtml: t.OutputFormatHtml, + Position: t.Position, + ScoringHtml: t.ScoringHtml, + + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } +} + +func ParticipantsListItemDTO(p models.ParticipantsListItem) testerv1.ParticipantsListItem { + return testerv1.ParticipantsListItem{ + Id: p.Id, + UserId: p.UserId, + Name: p.Name, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } +} + +func SolutionsListItemDTO(s models.SolutionsListItem) testerv1.SolutionsListItem { + return testerv1.SolutionsListItem{ + Id: s.Id, + + ParticipantId: s.ParticipantId, + ParticipantName: s.ParticipantName, + + State: s.State, + Score: s.Score, + Penalty: s.Penalty, + TimeStat: s.TimeStat, + MemoryStat: s.MemoryStat, + Language: s.Language, + + TaskId: s.TaskId, + TaskPosition: s.TaskPosition, + TaskTitle: s.TaskTitle, + + ContestId: s.ContestId, + ContestTitle: s.ContestTitle, + + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } +} + +func SolutionDTO(s models.Solution) testerv1.Solution { + return testerv1.Solution{ + Id: s.Id, + + ParticipantId: s.ParticipantId, + ParticipantName: s.ParticipantName, + + Solution: s.Solution, + + State: s.State, + Score: s.Score, + Penalty: s.Penalty, + TimeStat: s.TimeStat, + MemoryStat: s.MemoryStat, + Language: s.Language, + + TaskId: s.TaskId, + TaskPosition: s.TaskPosition, + TaskTitle: s.TaskTitle, + + ContestId: s.ContestId, + ContestTitle: s.ContestTitle, + + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } +} diff --git a/internal/contests/delivery/rest/monitor_handlers.go b/internal/contests/delivery/rest/monitor_handlers.go new file mode 100644 index 0000000..d916421 --- /dev/null +++ b/internal/contests/delivery/rest/monitor_handlers.go @@ -0,0 +1,71 @@ +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/pkg" + "github.com/gofiber/fiber/v2" +) + +func (h *Handlers) GetMonitor(c *fiber.Ctx, params testerv1.GetMonitorParams) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher, models.RoleStudent: + contest, err := h.contestsUC.GetContest(ctx, params.ContestId) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + monitor, err := h.contestsUC.GetMonitor(ctx, params.ContestId) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + tasks, err := h.contestsUC.GetTasks(ctx, params.ContestId) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + resp := testerv1.GetMonitorResponse{ + Contest: ContestDTO(*contest), + Tasks: make([]testerv1.TasksListItem, len(tasks)), + Participants: make([]testerv1.ParticipantsStat, len(monitor.Participants)), + SummaryPerProblem: make([]testerv1.ProblemStatSummary, len(monitor.Summary)), + } + + for i, participant := range monitor.Participants { + resp.Participants[i] = testerv1.ParticipantsStat{ + Id: participant.Id, + Name: participant.Name, + PenaltyInTotal: participant.PenaltyInTotal, + Solutions: make([]testerv1.SolutionsListItem, len(participant.Solutions)), + SolvedInTotal: participant.SolvedInTotal, + } + + for j, solution := range participant.Solutions { + resp.Participants[i].Solutions[j] = SolutionsListItemDTO(*solution) + } + } + + for i, problem := range monitor.Summary { + resp.SummaryPerProblem[i] = testerv1.ProblemStatSummary{ + Id: problem.Id, + Success: problem.Success, + Total: problem.Total, + } + } + + for i, task := range tasks { + resp.Tasks[i] = TasksListItemDTO(*task) + } + return c.JSON(resp) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} diff --git a/internal/contests/delivery/rest/participants_handlers.go b/internal/contests/delivery/rest/participants_handlers.go new file mode 100644 index 0000000..dce23b8 --- /dev/null +++ b/internal/contests/delivery/rest/participants_handlers.go @@ -0,0 +1,116 @@ +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/pkg" + "github.com/gofiber/fiber/v2" +) + +func (h *Handlers) CreateParticipant(c *fiber.Ctx, params testerv1.CreateParticipantParams) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + id, err := h.contestsUC.CreateParticipant(ctx, params.ContestId, params.UserId) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(testerv1.CreateParticipantResponse{ + Id: id, + }) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) UpdateParticipant(c *fiber.Ctx, params testerv1.UpdateParticipantParams) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + var req testerv1.UpdateParticipantRequest + err := c.BodyParser(&req) + if err != nil { + return err + } + + err = h.contestsUC.UpdateParticipant(ctx, params.ParticipantId, models.ParticipantUpdate{ + Name: req.Name, + }) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) DeleteParticipant(c *fiber.Ctx, params testerv1.DeleteParticipantParams) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + err := h.contestsUC.DeleteParticipant(c.Context(), params.ParticipantId) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + + } +} + +func (h *Handlers) ListParticipants(c *fiber.Ctx, params testerv1.ListParticipantsParams) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + participantsList, err := h.contestsUC.ListParticipants(c.Context(), models.ParticipantsFilter{ + Page: params.Page, + PageSize: params.PageSize, + ContestId: params.ContestId, + }) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + resp := testerv1.ListParticipantsResponse{ + Participants: make([]testerv1.ParticipantsListItem, len(participantsList.Participants)), + Pagination: PaginationDTO(participantsList.Pagination), + } + + for i, participant := range participantsList.Participants { + resp.Participants[i] = ParticipantsListItemDTO(*participant) + } + + return c.JSON(resp) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} diff --git a/internal/contests/delivery/rest/solutions_handlers.go b/internal/contests/delivery/rest/solutions_handlers.go new file mode 100644 index 0000000..b2ff3d7 --- /dev/null +++ b/internal/contests/delivery/rest/solutions_handlers.go @@ -0,0 +1,148 @@ +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/pkg" + "github.com/gofiber/fiber/v2" + "io" +) + +const ( + maxSolutionSize int64 = 10 * 1024 * 1024 +) + +func (h *Handlers) CreateSolution(c *fiber.Ctx, params testerv1.CreateSolutionParams) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher, models.RoleStudent: + s, err := c.FormFile("solution") + if err != nil { + return err + } + + if s.Size == 0 || s.Size > maxSolutionSize { + return c.SendStatus(fiber.StatusBadRequest) + } + + f, err := s.Open() + if err != nil { + return err + } + defer f.Close() + + b, err := io.ReadAll(f) + if err != nil { + return err + } + + id, err := h.contestsUC.CreateSolution(ctx, &models.SolutionCreation{ + UserId: session.UserId, + TaskId: params.TaskId, + Language: params.Language, + Solution: string(b), + }) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(testerv1.CreateSolutionResponse{ + Id: id, + }) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) GetSolution(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + solution, err := h.contestsUC.GetSolution(ctx, id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(testerv1.GetSolutionResponse{Solution: SolutionDTO(*solution)}) + case models.RoleStudent: + _, err := h.contestsUC.GetParticipantId3(ctx, id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + solution, err := h.contestsUC.GetSolution(ctx, id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(testerv1.GetSolutionResponse{Solution: SolutionDTO(*solution)}) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) ListSolutions(c *fiber.Ctx, params testerv1.ListSolutionsParams) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + filter := models.SolutionsFilter{ + ContestId: params.ContestId, + Page: params.Page, + PageSize: params.PageSize, + ParticipantId: params.ParticipantId, + TaskId: params.TaskId, + Language: params.Language, + Order: params.Order, + State: params.State, + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + solutionsList, err := h.contestsUC.ListSolutions(ctx, filter) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(ListSolutionsResponseDTO(solutionsList)) + case models.RoleStudent: + if params.ContestId == nil { + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } + + participantId, err := h.contestsUC.GetParticipantId(ctx, *params.ContestId, session.UserId) + if err != nil { + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } + + // Student cannot view other users' solutions + if params.ParticipantId != nil && *params.ParticipantId != participantId { + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } + + filter.ParticipantId = &participantId + solutionsList, err := h.contestsUC.ListSolutions(ctx, filter) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(ListSolutionsResponseDTO(solutionsList)) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} diff --git a/internal/contests/delivery/rest/tasks_handlers.go b/internal/contests/delivery/rest/tasks_handlers.go new file mode 100644 index 0000000..22732fa --- /dev/null +++ b/internal/contests/delivery/rest/tasks_handlers.go @@ -0,0 +1,105 @@ +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/pkg" + "github.com/gofiber/fiber/v2" +) + +func (h *Handlers) CreateTask(c *fiber.Ctx, params testerv1.CreateTaskParams) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + id, err := h.contestsUC.CreateTask(ctx, params.ContestId, params.ProblemId) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(testerv1.CreateTaskResponse{ + Id: id, + }) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) GetTask(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + contest, err := h.contestsUC.GetContest(c.Context(), id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + tasks, err := h.contestsUC.GetTasks(c.Context(), id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + t, err := h.contestsUC.GetTask(c.Context(), id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(GetTaskResponseDTO(contest, tasks, t)) + case models.RoleStudent: + _, err = h.contestsUC.GetParticipantId2(ctx, id, session.UserId) + if err != nil { + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } + + contest, err := h.contestsUC.GetContest(c.Context(), id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + tasks, err := h.contestsUC.GetTasks(c.Context(), id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + t, err := h.contestsUC.GetTask(c.Context(), id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(GetTaskResponseDTO(contest, tasks, t)) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) DeleteTask(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + err := h.contestsUC.DeleteTask(c.Context(), id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} diff --git a/internal/contests/pg_repository.go b/internal/contests/pg_repository.go new file mode 100644 index 0000000..c9f1cc5 --- /dev/null +++ b/internal/contests/pg_repository.go @@ -0,0 +1,34 @@ +package contests + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +type Repository interface { + CreateContest(ctx context.Context, title string) (int32, error) + GetContest(ctx context.Context, id int32) (*models.Contest, error) + DeleteContest(ctx context.Context, id int32) error + UpdateContest(ctx context.Context, id int32, contestUpdate models.ContestUpdate) error + ListContests(ctx context.Context, filter models.ContestsFilter) (*models.ContestsList, error) + + CreateTask(ctx context.Context, contestId int32, taskId int32) (int32, error) + GetTask(ctx context.Context, id int32) (*models.Task, error) + DeleteTask(ctx context.Context, taskId int32) error + GetTasks(ctx context.Context, contestId int32) ([]*models.TasksListItem, error) + + GetParticipantId(ctx context.Context, contestId int32, userId int32) (int32, error) + GetParticipantId2(ctx context.Context, taskId int32, userId int32) (int32, error) + GetParticipantId3(ctx context.Context, solutionId int32) (int32, error) + CreateParticipant(ctx context.Context, contestId int32, userId int32) (int32, error) + DeleteParticipant(ctx context.Context, participantId int32) error + UpdateParticipant(ctx context.Context, id int32, participantUpdate models.ParticipantUpdate) error + ListParticipants(ctx context.Context, filter models.ParticipantsFilter) (*models.ParticipantsList, error) + + GetSolution(ctx context.Context, id int32) (*models.Solution, error) + CreateSolution(ctx context.Context, creation *models.SolutionCreation) (int32, error) + ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error) + GetBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.SolutionsListItem, error) + + GetMonitor(ctx context.Context, id int32, penalty int32) (*models.Monitor, error) +} diff --git a/internal/contests/repository/contests_pg_repository.go b/internal/contests/repository/contests_pg_repository.go new file mode 100644 index 0000000..4a721f3 --- /dev/null +++ b/internal/contests/repository/contests_pg_repository.go @@ -0,0 +1,145 @@ +package repository + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/pkg" + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +type Repository struct { + db *sqlx.DB +} + +func NewRepository(db *sqlx.DB) *Repository { + return &Repository{ + db: db, + } +} + +const CreateContestQuery = "INSERT INTO contests (title) VALUES ($1) RETURNING id" + +func (r *Repository) CreateContest(ctx context.Context, title string) (int32, error) { + const op = "Repository.CreateContest" + + rows, err := r.db.QueryxContext(ctx, CreateContestQuery, title) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + + defer rows.Close() + var id int32 + rows.Next() + err = rows.Scan(&id) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + + return id, nil +} + +const GetContestQuery = "SELECT * from contests WHERE id=$1 LIMIT 1" + +func (r *Repository) GetContest(ctx context.Context, id int32) (*models.Contest, error) { + const op = "Repository.GetContest" + + var contest models.Contest + err := r.db.GetContext(ctx, &contest, GetContestQuery, id) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + return &contest, nil +} + +const ( + UpdateContestQuery = "UPDATE contests SET title = COALESCE($1, title) WHERE id = $2" +) + +func (r *Repository) UpdateContest(ctx context.Context, id int32, contestUpdate models.ContestUpdate) error { + const op = "Repository.UpdateContest" + + _, err := r.db.ExecContext(ctx, UpdateContestQuery, contestUpdate.Title, id) + if err != nil { + return pkg.HandlePgErr(err, op) + } + + return nil +} + +const DeleteContestQuery = "DELETE FROM contests WHERE id=$1" + +func (r *Repository) DeleteContest(ctx context.Context, id int32) error { + const op = "Repository.DeleteContest" + + _, err := r.db.ExecContext(ctx, DeleteContestQuery, id) + if err != nil { + return pkg.HandlePgErr(err, op) + } + + return nil +} + +func buildListContestsQueries(filter models.ContestsFilter) (sq.SelectBuilder, sq.SelectBuilder) { + columns := []string{ + "c.id", + "c.title", + "c.created_at", + "c.updated_at", + } + + qb := sq.StatementBuilder.PlaceholderFormat(sq.Dollar).Select(columns...).From("contests c") + + if filter.UserId != nil { + qb = qb.Join("participants p ON c.id = p.contest_id") + qb = qb.Where(sq.Eq{"p.user_id": *filter.UserId}) + } + + countQb := sq.Select("COUNT(*)").FromSelect(qb, "sub") + + if filter.Order != nil && *filter.Order < 0 { + qb = qb.OrderBy("c.created_at DESC") + } else { + qb = qb.OrderBy("c.created_at ASC") + } + + qb = qb.Limit(uint64(filter.PageSize)).Offset(uint64(filter.Offset())) + + return qb, countQb +} + +func (r *Repository) ListContests(ctx context.Context, filter models.ContestsFilter) (*models.ContestsList, error) { + const op = "Repository.ListContests" + + baseQb, countQb := buildListContestsQueries(filter) + + query, args, err := baseQb.ToSql() + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + var contests []*models.ContestsListItem + err = r.db.SelectContext(ctx, &contests, query, args...) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + query, args, err = countQb.ToSql() + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + var count int32 + err = r.db.GetContext(ctx, &count, query, args...) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + return &models.ContestsList{ + Contests: contests, + Pagination: models.Pagination{ + Total: models.Total(count, filter.PageSize), + Page: filter.Page, + }, + }, nil +} diff --git a/internal/contests/repository/contests_pg_repository_test.go b/internal/contests/repository/contests_pg_repository_test.go new file mode 100644 index 0000000..593994b --- /dev/null +++ b/internal/contests/repository/contests_pg_repository_test.go @@ -0,0 +1,116 @@ +package repository_test + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/contests/repository" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +// setupTestDB creates a mocked sqlx.DB and sqlmock instance for testing. +func setupTestDB(t *testing.T) (*sqlx.DB, sqlmock.Sqlmock) { + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + assert.NoError(t, err) + sqlxDB := sqlx.NewDb(db, "sqlmock") + return sqlxDB, mock +} + +func TestRepository_CreateContest(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + contest := models.Contest{ + Id: 1, + Title: "Test Contest", + } + + mock.ExpectQuery(repository.CreateContestQuery). + WithArgs(contest.Title). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(contest.Id)) + + id, err := repo.CreateContest(ctx, contest.Title) + assert.NoError(t, err) + assert.Equal(t, contest.Id, id) + }) +} + +func TestRepository_GetContest(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + contest := models.Contest{ + Id: 1, + Title: "Test Contest", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + mock.ExpectQuery(repository.GetContestQuery). + WithArgs(contest.Id). + WillReturnRows(sqlmock.NewRows([]string{"id", "title", "created_at", "updated_at"}). + AddRow(contest.Id, contest.Title, contest.CreatedAt, contest.UpdatedAt)) + + result, err := repo.GetContest(ctx, contest.Id) + assert.NoError(t, err) + assert.EqualExportedValues(t, &contest, result) + }) +} + +func TestRepository_UpdateContest(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + var contestId int32 = 1 + update := models.ContestUpdate{ + Title: sp("Updated Contest"), + } + + mock.ExpectExec(repository.UpdateContestQuery). + WithArgs(update.Title, contestId). + WillReturnResult(sqlmock.NewResult(0, 1)) + + err := repo.UpdateContest(ctx, contestId, update) + assert.NoError(t, err) + }) +} + +func TestRepository_DeleteContest(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + mock.ExpectExec(repository.DeleteContestQuery). + WithArgs(1). + WillReturnResult(sqlmock.NewResult(0, 1)) + + err := repo.DeleteContest(ctx, 1) + assert.NoError(t, err) + }) +} + +func sp(s string) *string { + return &s +} diff --git a/internal/contests/repository/monitor_pg_repository.go b/internal/contests/repository/monitor_pg_repository.go new file mode 100644 index 0000000..94487fa --- /dev/null +++ b/internal/contests/repository/monitor_pg_repository.go @@ -0,0 +1,161 @@ +package repository + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/pkg" +) + +const ( + // state=5 - AC + ReadStatisticsQuery = ` +SELECT t.id as task_id, + t.position, + COUNT(*) as total, + COUNT(CASE WHEN s.state = 5 THEN 1 END) as success +FROM tasks t LEFT JOIN solutions s ON t.id = s.task_id +WHERE t.contest_id = $1 +GROUP BY t.id, t.position +ORDER BY t.position; +` + + SolutionsQuery = ` +WITH RankedSolutions AS ( + SELECT + 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 as contest_title, + + s.updated_at, + s.created_at, + ROW_NUMBER() OVER ( + PARTITION BY s.task_id, s.participant_id + ORDER BY s.score DESC, s.created_at + ) as rn + 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 t.contest_id = $1 +) +SELECT + rs.id, + + rs.participant_id, + rs.participant_name, + + rs.state, + rs.score, + rs.penalty, + rs.time_stat, + rs.memory_stat, + rs.language, + + rs.task_id, + rs.task_position, + rs.task_title, + + rs.contest_id, + rs.contest_title, + + rs.updated_at, + rs.created_at +FROM RankedSolutions rs +WHERE rs.rn = 1` + + ParticipantsQuery = ` +WITH Attempts AS ( + SELECT + s.participant_id, + s.task_id, + COUNT(*) FILTER (WHERE s.state != 5 AND s.created_at < ( + SELECT MIN(s2.created_at) + FROM solutions s2 + WHERE s2.participant_id = s.participant_id + AND s2.task_id = s.task_id + AND s2.state = 5 + )) as failed_attempts, + 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 = $1 + GROUP BY s.participant_id, s.task_id +) +SELECT + p.id, + p.name, + COUNT(DISTINCT CASE WHEN a.success_penalty IS NOT NULL THEN a.task_id END) as solved_in_total, + COALESCE(SUM(a.failed_attempts), 0) * $2 + COALESCE(SUM(a.success_penalty), 0) as penalty_in_total +FROM participants p LEFT JOIN Attempts a ON a.participant_id = p.id +WHERE p.contest_id = $1 +GROUP BY p.id, p.name +` +) + +func (r *Repository) GetMonitor(ctx context.Context, contestId int32, penalty int32) (*models.Monitor, error) { + const op = "Repository.GetMonitor" + + rows, err := r.db.QueryxContext(ctx, ReadStatisticsQuery, contestId) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + defer rows.Close() + + var monitor models.Monitor + for rows.Next() { + var stat models.ProblemStatSummary + err = rows.StructScan(&stat) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + monitor.Summary = append(monitor.Summary, &stat) + } + + var solutions []*models.SolutionsListItem + err = r.db.SelectContext(ctx, &solutions, SolutionsQuery, contestId) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + rows3, err := r.db.QueryxContext(ctx, ParticipantsQuery, contestId, penalty) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + defer rows3.Close() + + solutionsMap := make(map[int32][]*models.SolutionsListItem) + for _, solution := range solutions { + solutionsMap[solution.ParticipantId] = append(solutionsMap[solution.ParticipantId], solution) + } + + for rows3.Next() { + var stat models.ParticipantsStat + err = rows3.StructScan(&stat) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + if sols, ok := solutionsMap[stat.Id]; ok { + stat.Solutions = sols + } + + monitor.Participants = append(monitor.Participants, &stat) + } + + return &monitor, nil +} diff --git a/internal/contests/repository/monitor_pg_repository_test.go b/internal/contests/repository/monitor_pg_repository_test.go new file mode 100644 index 0000000..50a4378 --- /dev/null +++ b/internal/contests/repository/monitor_pg_repository_test.go @@ -0,0 +1 @@ +package repository diff --git a/internal/contests/repository/participants_pg_repository.go b/internal/contests/repository/participants_pg_repository.go new file mode 100644 index 0000000..ccd966e --- /dev/null +++ b/internal/contests/repository/participants_pg_repository.go @@ -0,0 +1,126 @@ +package repository + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/pkg" +) + +const GetParticipantIdQuery = "SELECT id FROM participants WHERE user_id=$1 AND contest_id=$2 LIMIT 1" + +func (r *Repository) GetParticipantId(ctx context.Context, contestId int32, userId int32) (int32, error) { + const op = "Repository.GetParticipantId" + + var participantId int32 + err := r.db.GetContext(ctx, &participantId, GetParticipantIdQuery, userId, contestId) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + + return participantId, nil +} + +const GetParticipantId2Query = "SELECT p.id FROM participants p JOIN tasks t ON p.contest_id=t.contest_id WHERE user_id=$1 AND t.id=$2 LIMIT 1" + +func (r *Repository) GetParticipantId2(ctx context.Context, taskId int32, userId int32) (int32, error) { + const op = "Repository.GetParticipantId2" + + var participantId int32 + err := r.db.GetContext(ctx, &participantId, GetParticipantId2Query, userId, taskId) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + + return participantId, nil +} + +const GetParticipantId3Query = "SELECT participant_id FROM solutions WHERE id=$1 LIMIT 1" + +func (r *Repository) GetParticipantId3(ctx context.Context, solutionId int32) (int32, error) { + const op = "Repository.GetParticipantId3" + + var participantId int32 + err := r.db.GetContext(ctx, &participantId, GetParticipantId3Query, solutionId) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + + return participantId, nil +} + +const CreateParticipantQuery = "INSERT INTO participants (user_id, contest_id, name) VALUES ($1, $2, $3) RETURNING id" + +func (r *Repository) CreateParticipant(ctx context.Context, contestId int32, userId int32) (int32, error) { + const op = "Repository.CreateParticipant" + + name := "" + rows, err := r.db.QueryxContext(ctx, CreateParticipantQuery, userId, contestId, name) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + defer rows.Close() + var id int32 + rows.Next() + err = rows.Scan(&id) + if err != nil { + return 0, err + } + return id, nil +} + +const DeleteParticipantQuery = "DELETE FROM participants WHERE id=$1" + +const ( + UpdateParticipantQuery = "UPDATE participants SET name = COALESCE($1, name) WHERE id = $2" +) + +func (r *Repository) UpdateParticipant(ctx context.Context, id int32, participantUpdate models.ParticipantUpdate) error { + const op = "Repository.UpdateParticipant" + + _, err := r.db.ExecContext(ctx, UpdateParticipantQuery, participantUpdate.Name, id) + if err != nil { + return pkg.HandlePgErr(err, op) + } + + return nil +} + +func (r *Repository) DeleteParticipant(ctx context.Context, participantId int32) error { + const op = "Repository.DeleteParticipant" + + _, err := r.db.ExecContext(ctx, DeleteParticipantQuery, participantId) + if err != nil { + return pkg.HandlePgErr(err, op) + } + return nil +} + +const ( + ReadParticipantsListQuery = `SELECT id, user_id, name, created_at, updated_at FROM participants WHERE contest_id = $1 LIMIT $2 OFFSET $3` + CountParticipantsQuery = "SELECT COUNT(*) FROM participants WHERE contest_id = $1" +) + +func (r *Repository) ListParticipants(ctx context.Context, filter models.ParticipantsFilter) (*models.ParticipantsList, error) { + const op = "Repository.ReadParticipants" + + var participants []*models.ParticipantsListItem + err := r.db.SelectContext(ctx, &participants, + ReadParticipantsListQuery, filter.ContestId, filter.PageSize, filter.Offset()) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + var count int32 + err = r.db.GetContext(ctx, &count, CountParticipantsQuery, filter.ContestId) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + return &models.ParticipantsList{ + Participants: participants, + Pagination: models.Pagination{ + Total: models.Total(count, filter.PageSize), + Page: filter.Page, + }, + }, nil +} diff --git a/internal/contests/repository/participants_pg_repository_test.go b/internal/contests/repository/participants_pg_repository_test.go new file mode 100644 index 0000000..4607947 --- /dev/null +++ b/internal/contests/repository/participants_pg_repository_test.go @@ -0,0 +1,51 @@ +package repository_test + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/contests/repository" + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestRepository_CreateParticipant(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + var ( + expectedId int32 = 1 + userId int32 = 2 + contestId int32 = 3 + ) + ctx := context.Background() + + mock.ExpectQuery(repository.CreateParticipantQuery). + WithArgs(userId, contestId). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(expectedId)) + + id, err := repo.CreateParticipant(ctx, contestId, userId) + assert.NoError(t, err) + assert.Equal(t, expectedId, id) + }) +} + +func TestRepository_DeleteParticipant(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + var participantId int32 = 1 + + mock.ExpectExec(repository.DeleteParticipantQuery). + WithArgs(participantId).WillReturnResult(sqlmock.NewResult(0, 1)) + + err := repo.DeleteParticipant(ctx, participantId) + assert.NoError(t, err) + }) +} diff --git a/internal/contests/repository/solutions_pg_repository.go b/internal/contests/repository/solutions_pg_repository.go new file mode 100644 index 0000000..c02a16c --- /dev/null +++ b/internal/contests/repository/solutions_pg_repository.go @@ -0,0 +1,222 @@ +package repository + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/pkg" + sq "github.com/Masterminds/squirrel" +) + +const ( + GetSolutionQuery = "SELECT * FROM solutions WHERE id = $1" +) + +func (r *Repository) GetSolution(ctx context.Context, id int32) (*models.Solution, error) { + const op = "Repository.GetSolution" + + var solution models.Solution + err := r.db.GetContext(ctx, &solution, GetSolutionQuery, id) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + return &solution, nil +} + +const ( + CreateSolutionQuery = `INSERT INTO solutions (task_id, participant_id, language, penalty, solution) +VALUES ($1, $2, $3, $4, $5) +RETURNING id` +) + +func (r *Repository) CreateSolution(ctx context.Context, creation *models.SolutionCreation) (int32, error) { + const op = "Repository.CreateSolution" + + rows, err := r.db.QueryxContext(ctx, + CreateSolutionQuery, + creation.TaskId, + creation.ParticipantId, + creation.Language, + creation.Penalty, + creation.Solution, + ) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + + defer rows.Close() + var id int32 + rows.Next() + err = rows.Scan(&id) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + + return id, nil +} + +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.StatementBuilder.PlaceholderFormat(sq.Dollar).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(sq.Eq{"s.contest_id": *filter.ContestId}) + } + if filter.ParticipantId != nil { + qb = qb.Where(sq.Eq{"s.participant_id": *filter.ParticipantId}) + } + if filter.TaskId != nil { + qb = qb.Where(sq.Eq{"s.task_id": *filter.TaskId}) + } + if filter.Language != nil { + qb = qb.Where(sq.Eq{"s.language": *filter.Language}) + } + if filter.State != nil { + qb = qb.Where(sq.Eq{"s.state": *filter.State}) + } + + countQb := sq.Select("COUNT(*)").FromSelect(qb, "sub") + + if filter.Order != nil && *filter.Order < 0 { + qb = qb.OrderBy("s.id DESC") + } else { + qb = qb.OrderBy("s.id ASC") + } + + qb = qb.Limit(uint64(filter.PageSize)).Offset(uint64(filter.Offset())) + + return qb, countQb +} + +func (r *Repository) ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error) { + const op = "ContestRepository.ListSolutions" + + baseQb, countQb := buildListSolutionsQueries(filter) + + query, args, err := countQb.ToSql() + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + var totalCount int32 + err = r.db.GetContext(ctx, &totalCount, query, args...) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + query, args, err = baseQb.ToSql() + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + rows, err := r.db.QueryxContext(ctx, query, args...) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + defer rows.Close() + + solutions := make([]*models.SolutionsListItem, 0) + for rows.Next() { + var solution models.SolutionsListItem + err = rows.StructScan(&solution) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + solutions = append(solutions, &solution) + } + + if err = rows.Err(); err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + return &models.SolutionsList{ + Solutions: solutions, + Pagination: models.Pagination{ + Total: models.Total(totalCount, filter.PageSize), + Page: filter.Page, + }, + }, nil +} + +const ( + // state=5 - AC + GetBestSolutions = ` + 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.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 *Repository) GetBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.SolutionsListItem, error) { + const op = "Repository.GetBestSolutions" + var solutions []*models.SolutionsListItem + query := r.db.Rebind(GetBestSolutions) + err := r.db.SelectContext(ctx, &solutions, query, contestId, participantId) + + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + return solutions, nil +} diff --git a/internal/contests/repository/solutions_pg_repository_test.go b/internal/contests/repository/solutions_pg_repository_test.go new file mode 100644 index 0000000..50a4378 --- /dev/null +++ b/internal/contests/repository/solutions_pg_repository_test.go @@ -0,0 +1 @@ +package repository diff --git a/internal/contests/repository/tasks_pg_repository.go b/internal/contests/repository/tasks_pg_repository.go new file mode 100644 index 0000000..5a8fa88 --- /dev/null +++ b/internal/contests/repository/tasks_pg_repository.go @@ -0,0 +1,101 @@ +package repository + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/pkg" +) + +const CreateTaskQuery = `INSERT INTO tasks (problem_id, contest_id, position) +VALUES ($1, $2, COALESCE((SELECT MAX(position) FROM tasks WHERE contest_id = $2), 0) + 1) +RETURNING id +` + +func (r *Repository) CreateTask(ctx context.Context, contestId int32, problemId int32) (int32, error) { + const op = "Repository.AddTask" + + rows, err := r.db.QueryxContext(ctx, CreateTaskQuery, problemId, contestId) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + defer rows.Close() + var id int32 + rows.Next() + err = rows.Scan(&id) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + return id, nil +} + +const DeleteTaskQuery = "DELETE FROM tasks WHERE id=$1" + +func (r *Repository) DeleteTask(ctx context.Context, taskId int32) error { + const op = "Repository.DeleteTask" + + _, err := r.db.ExecContext(ctx, DeleteTaskQuery, taskId) + if err != nil { + return pkg.HandlePgErr(err, op) + } + return nil +} + +const GetTasksQuery = `SELECT tasks.id, + problem_id, + contest_id, + position, + title, + memory_limit, + time_limit, + tasks.created_at, + tasks.updated_at +FROM tasks + INNER JOIN problems ON tasks.problem_id = problems.id +WHERE contest_id = $1 ORDER BY position` + +func (r *Repository) GetTasks(ctx context.Context, contestId int32) ([]*models.TasksListItem, error) { + const op = "Repository.ReadTasks" + + var tasks []*models.TasksListItem + err := r.db.SelectContext(ctx, &tasks, GetTasksQuery, contestId) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + return tasks, nil +} + +const ( + GetTaskQuery = ` + SELECT + t.id, + t.position, + p.title, + p.time_limit, + p.memory_limit, + t.problem_id, + t.contest_id, + p.legend_html, + p.input_format_html, + p.output_format_html, + p.notes_html, + p.scoring_html, + t.created_at, + t.updated_at + FROM tasks t + LEFT JOIN problems p ON t.problem_id = p.id + WHERE t.id = ? + ` +) + +func (r *Repository) GetTask(ctx context.Context, id int32) (*models.Task, error) { + const op = "Repository.ReadTask" + + query := r.db.Rebind(GetTaskQuery) + var task models.Task + err := r.db.GetContext(ctx, &task, query, id) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + return &task, nil +} diff --git a/internal/contests/repository/tasks_pg_repository_test.go b/internal/contests/repository/tasks_pg_repository_test.go new file mode 100644 index 0000000..afc0f41 --- /dev/null +++ b/internal/contests/repository/tasks_pg_repository_test.go @@ -0,0 +1,51 @@ +package repository_test + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/contests/repository" + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestRepository_CreateTask(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + var ( + expectedId int32 = 1 + problemId int32 = 2 + contestId int32 = 3 + ) + ctx := context.Background() + + mock.ExpectQuery(repository.CreateTaskQuery). + WithArgs(problemId, contestId). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(expectedId)) + + id, err := repo.CreateTask(ctx, contestId, problemId) + assert.NoError(t, err) + assert.Equal(t, expectedId, id) + }) +} + +func TestRepository_DeleteTask(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + mock.ExpectExec(repository.DeleteTaskQuery). + WithArgs(1). + WillReturnResult(sqlmock.NewResult(0, 1)) + + err := repo.DeleteTask(ctx, 1) + assert.NoError(t, err) + }) +} diff --git a/internal/contests/usecase.go b/internal/contests/usecase.go new file mode 100644 index 0000000..ca49791 --- /dev/null +++ b/internal/contests/usecase.go @@ -0,0 +1,34 @@ +package contests + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +type UseCase interface { + CreateContest(ctx context.Context, title string) (int32, error) + GetContest(ctx context.Context, id int32) (*models.Contest, error) + DeleteContest(ctx context.Context, id int32) error + ListContests(ctx context.Context, filter models.ContestsFilter) (*models.ContestsList, error) + UpdateContest(ctx context.Context, id int32, contestUpdate models.ContestUpdate) error + + CreateTask(ctx context.Context, contestId int32, taskId int32) (int32, error) + DeleteTask(ctx context.Context, taskId int32) error + GetTasks(ctx context.Context, contestId int32) ([]*models.TasksListItem, error) + GetTask(ctx context.Context, id int32) (*models.Task, error) + + CreateParticipant(ctx context.Context, contestId int32, userId int32) (int32, error) + GetParticipantId(ctx context.Context, contestId int32, userId int32) (int32, error) + GetParticipantId2(ctx context.Context, taskId, userId int32) (int32, error) + GetParticipantId3(ctx context.Context, solutionId int32) (int32, error) + UpdateParticipant(ctx context.Context, id int32, participantUpdate models.ParticipantUpdate) error + DeleteParticipant(ctx context.Context, participantId int32) error + ListParticipants(ctx context.Context, filter models.ParticipantsFilter) (*models.ParticipantsList, error) + + GetSolution(ctx context.Context, id int32) (*models.Solution, error) + CreateSolution(ctx context.Context, creation *models.SolutionCreation) (int32, error) + ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error) + GetBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.SolutionsListItem, error) + + GetMonitor(ctx context.Context, id int32) (*models.Monitor, error) +} diff --git a/internal/contests/usecase/contests_usecase.go b/internal/contests/usecase/contests_usecase.go new file mode 100644 index 0000000..e0f9453 --- /dev/null +++ b/internal/contests/usecase/contests_usecase.go @@ -0,0 +1,39 @@ +package usecase + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/contests" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +type ContestUseCase struct { + contestRepo contests.Repository +} + +func NewContestUseCase( + contestRepo contests.Repository, +) *ContestUseCase { + return &ContestUseCase{ + contestRepo: contestRepo, + } +} + +func (uc *ContestUseCase) CreateContest(ctx context.Context, title string) (int32, error) { + return uc.contestRepo.CreateContest(ctx, title) +} + +func (uc *ContestUseCase) GetContest(ctx context.Context, id int32) (*models.Contest, error) { + return uc.contestRepo.GetContest(ctx, id) +} + +func (uc *ContestUseCase) UpdateContest(ctx context.Context, id int32, contestUpdate models.ContestUpdate) error { + return uc.contestRepo.UpdateContest(ctx, id, contestUpdate) +} + +func (uc *ContestUseCase) DeleteContest(ctx context.Context, id int32) error { + return uc.contestRepo.DeleteContest(ctx, id) +} + +func (uc *ContestUseCase) ListContests(ctx context.Context, filter models.ContestsFilter) (*models.ContestsList, error) { + return uc.contestRepo.ListContests(ctx, filter) +} diff --git a/internal/contests/usecase/monitor_usecase.go b/internal/contests/usecase/monitor_usecase.go new file mode 100644 index 0000000..563f361 --- /dev/null +++ b/internal/contests/usecase/monitor_usecase.go @@ -0,0 +1,10 @@ +package usecase + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +func (uc *ContestUseCase) GetMonitor(ctx context.Context, contestId int32) (*models.Monitor, error) { + return uc.contestRepo.GetMonitor(ctx, contestId, 20) +} diff --git a/internal/contests/usecase/participants_usecase.go b/internal/contests/usecase/participants_usecase.go new file mode 100644 index 0000000..30ca4e2 --- /dev/null +++ b/internal/contests/usecase/participants_usecase.go @@ -0,0 +1,34 @@ +package usecase + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +func (uc *ContestUseCase) GetParticipantId(ctx context.Context, contestId int32, userId int32) (int32, error) { + return uc.contestRepo.GetParticipantId(ctx, contestId, userId) +} + +func (uc *ContestUseCase) GetParticipantId2(ctx context.Context, taskId, userId int32) (int32, error) { + return uc.contestRepo.GetParticipantId2(ctx, taskId, userId) +} + +func (uc *ContestUseCase) GetParticipantId3(ctx context.Context, solutionId int32) (int32, error) { + return uc.contestRepo.GetParticipantId3(ctx, solutionId) +} + +func (uc *ContestUseCase) CreateParticipant(ctx context.Context, contestId int32, userId int32) (id int32, err error) { + return uc.contestRepo.CreateParticipant(ctx, contestId, userId) +} + +func (uc *ContestUseCase) DeleteParticipant(ctx context.Context, participantId int32) error { + return uc.contestRepo.DeleteParticipant(ctx, participantId) +} + +func (uc *ContestUseCase) ListParticipants(ctx context.Context, filter models.ParticipantsFilter) (*models.ParticipantsList, error) { + return uc.contestRepo.ListParticipants(ctx, filter) +} + +func (uc *ContestUseCase) UpdateParticipant(ctx context.Context, id int32, participantUpdate models.ParticipantUpdate) error { + return uc.contestRepo.UpdateParticipant(ctx, id, participantUpdate) +} diff --git a/internal/contests/usecase/solutions_usecase.go b/internal/contests/usecase/solutions_usecase.go new file mode 100644 index 0000000..8c79dcc --- /dev/null +++ b/internal/contests/usecase/solutions_usecase.go @@ -0,0 +1,29 @@ +package usecase + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +func (uc *ContestUseCase) GetSolution(ctx context.Context, id int32) (*models.Solution, error) { + return uc.contestRepo.GetSolution(ctx, id) +} + +func (uc *ContestUseCase) CreateSolution(ctx context.Context, creation *models.SolutionCreation) (int32, error) { + participantId, err := uc.contestRepo.GetParticipantId2(ctx, creation.TaskId, creation.UserId) + if err != nil { + return 0, err + } + + creation.ParticipantId = participantId + + return uc.contestRepo.CreateSolution(ctx, creation) +} + +func (uc *ContestUseCase) ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error) { + return uc.contestRepo.ListSolutions(ctx, filter) +} + +func (uc *ContestUseCase) GetBestSolutions(ctx context.Context, contestId int32, participantId int32) ([]*models.SolutionsListItem, error) { + return uc.contestRepo.GetBestSolutions(ctx, contestId, participantId) +} diff --git a/internal/contests/usecase/tasks_usecase.go b/internal/contests/usecase/tasks_usecase.go new file mode 100644 index 0000000..5d2d5a0 --- /dev/null +++ b/internal/contests/usecase/tasks_usecase.go @@ -0,0 +1,22 @@ +package usecase + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +func (uc *ContestUseCase) CreateTask(ctx context.Context, contestId int32, taskId int32) (id int32, err error) { + return uc.contestRepo.CreateTask(ctx, contestId, taskId) +} + +func (uc *ContestUseCase) GetTask(ctx context.Context, id int32) (*models.Task, error) { + return uc.contestRepo.GetTask(ctx, id) +} + +func (uc *ContestUseCase) GetTasks(ctx context.Context, contestId int32) ([]*models.TasksListItem, error) { + return uc.contestRepo.GetTasks(ctx, contestId) +} + +func (uc *ContestUseCase) DeleteTask(ctx context.Context, taskId int32) error { + return uc.contestRepo.DeleteTask(ctx, taskId) +} diff --git a/internal/tester/delivery/rest/middlewares.go b/internal/middleware/auth.go similarity index 67% rename from internal/tester/delivery/rest/middlewares.go rename to internal/middleware/auth.go index b0e984d..cb243ad 100644 --- a/internal/tester/delivery/rest/middlewares.go +++ b/internal/middleware/auth.go @@ -1,8 +1,11 @@ -package rest +package middleware import ( + "errors" "fmt" "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/internal/sessions" + "git.sch9.ru/new_gate/ms-tester/pkg" "github.com/gofiber/fiber/v2" "github.com/golang-jwt/jwt/v4" "strings" @@ -12,19 +15,15 @@ const ( TokenKey = "token" ) -func AuthMiddleware(jwtSecret string) fiber.Handler { +func AuthMiddleware(jwtSecret string, sessionsUC sessions.UseCase) fiber.Handler { return func(c *fiber.Ctx) error { - const op = "AuthMiddleware" - authHeader := c.Get("Authorization", "") if authHeader == "" { - c.Locals(TokenKey, nil) return c.Next() } authParts := strings.Split(authHeader, " ") if len(authParts) != 2 || strings.ToLower(authParts[0]) != "bearer" { - c.Locals(TokenKey, nil) return c.Next() } @@ -36,34 +35,30 @@ func AuthMiddleware(jwtSecret string) fiber.Handler { return []byte(jwtSecret), nil }) if err != nil { - c.Locals(TokenKey, nil) return c.Next() } token, ok := parsedToken.Claims.(*models.JWT) if !ok { - c.Locals(TokenKey, nil) return c.Next() } err = token.Valid() if err != nil { - c.Locals(TokenKey, nil) return c.Next() } - //ctx := c.Context() + ctx := c.Context() // check if session exists - //_, err = userUC.ReadSession(ctx, token.SessionId) - //if err != nil { - // if errors.Is(err, pkg.ErrNotFound) { - // c.Locals(TokenKey, nil) - // return c.Next() - // } - // - // return c.SendStatus(pkg.ToREST(err)) - //} + _, err = sessionsUC.ReadSession(ctx, token.SessionId) + if err != nil { + if errors.Is(err, pkg.ErrNotFound) { + return c.Next() + } + + return c.SendStatus(pkg.ToREST(err)) + } c.Locals(TokenKey, token) return c.Next() diff --git a/internal/models/contest.go b/internal/models/contest.go index 3646d1b..4fd94d7 100644 --- a/internal/models/contest.go +++ b/internal/models/contest.go @@ -24,6 +24,8 @@ type ContestsList struct { type ContestsFilter struct { Page int32 PageSize int32 + UserId *int32 + Order *int32 } func (f ContestsFilter) Offset() int32 { @@ -53,3 +55,73 @@ type ProblemStatSummary struct { Success int32 `db:"success"` Total int32 `db:"total"` } + +type Task struct { + Id int32 `db:"id"` + Position int32 `db:"position"` + Title string `db:"title"` + TimeLimit int32 `db:"time_limit"` + MemoryLimit int32 `db:"memory_limit"` + + ProblemId int32 `db:"problem_id"` + ContestId int32 `db:"contest_id"` + + LegendHtml string `db:"legend_html"` + InputFormatHtml string `db:"input_format_html"` + OutputFormatHtml string `db:"output_format_html"` + NotesHtml string `db:"notes_html"` + ScoringHtml string `db:"scoring_html"` + + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type TasksListItem struct { + Id int32 `db:"id"` + ProblemId int32 `db:"problem_id"` + ContestId int32 `db:"contest_id"` + Position int32 `db:"position"` + Title string `db:"title"` + MemoryLimit int32 `db:"memory_limit"` + TimeLimit int32 `db:"time_limit"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type Participant struct { + Id int32 `db:"id"` + UserId int32 `db:"user_id"` + ContestId int32 `db:"contest_id"` + Name string `db:"name"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type ParticipantsListItem struct { + Id int32 `db:"id"` + UserId int32 `db:"user_id"` + ContestId int32 `db:"contest_id"` + Name string `db:"name"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type ParticipantsList struct { + Participants []*ParticipantsListItem + Pagination Pagination +} + +type ParticipantsFilter struct { + Page int32 + PageSize int32 + + ContestId int32 +} + +func (f ParticipantsFilter) Offset() int32 { + return (f.Page - 1) * f.PageSize +} + +type ParticipantUpdate struct { + Name *string `json:"name"` +} diff --git a/internal/models/participant.go b/internal/models/participant.go deleted file mode 100644 index f277a3d..0000000 --- a/internal/models/participant.go +++ /dev/null @@ -1,41 +0,0 @@ -package models - -import "time" - -type Participant struct { - Id int32 `db:"id"` - UserId int32 `db:"user_id"` - ContestId int32 `db:"contest_id"` - Name string `db:"name"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type ParticipantsListItem struct { - Id int32 `db:"id"` - UserId int32 `db:"user_id"` - ContestId int32 `db:"contest_id"` - Name string `db:"name"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type ParticipantsList struct { - Participants []*ParticipantsListItem - Pagination Pagination -} - -type ParticipantsFilter struct { - Page int32 - PageSize int32 - - ContestId int32 -} - -func (f ParticipantsFilter) Offset() int32 { - return (f.Page - 1) * f.PageSize -} - -type ParticipantUpdate struct { - Name *string `json:"name"` -} diff --git a/internal/models/problem.go b/internal/models/problem.go index aad030d..fc3563d 100644 --- a/internal/models/problem.go +++ b/internal/models/problem.go @@ -13,7 +13,6 @@ type Problem struct { OutputFormat string `db:"output_format"` Notes string `db:"notes"` Scoring string `db:"scoring"` - LatexSummary string `db:"latex_summary"` LegendHtml string `db:"legend_html"` InputFormatHtml string `db:"input_format_html"` diff --git a/internal/models/session.go b/internal/models/session.go index 446475a..1d249d4 100644 --- a/internal/models/session.go +++ b/internal/models/session.go @@ -1,20 +1,74 @@ package models import ( - "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" + "fmt" "github.com/google/uuid" - "github.com/open-policy-agent/opa/v1/rego" + "strconv" + "time" ) +type Session struct { + Id string `json:"id"` + UserId int32 `json:"user_id"` + Role Role `json:"role"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + UserAgent string `json:"user_agent"` + Ip string `json:"ip"` +} + +func (s *Session) Valid() error { + if uuid.Validate(s.Id) != nil { + return errors.New("invalid session id") + } + if s.UserId == 0 { + return errors.New("empty user id") + } + if s.CreatedAt.IsZero() { + return errors.New("empty created at") + } + if s.ExpiresAt.IsZero() { + return errors.New("empty expires at") + } + if s.UserAgent == "" { + return errors.New("empty user agent") + } + if s.Ip == "" { + return errors.New("empty ip") + } + return nil +} + +func (s *Session) JSON() ([]byte, error) { + return json.Marshal(s) +} + +func (s *Session) UserIdHash() string { + return sha256string(strconv.FormatInt(int64(s.UserId), 10)) +} + +func (s *Session) SessionIdHash() string { + return sha256string(s.Id) +} + +func (s *Session) Key() string { + return fmt.Sprintf("userid:%s:sessionid:%s", s.UserIdHash(), s.SessionIdHash()) +} +func sha256string(s string) string { + hasher := sha256.New() + hasher.Write([]byte(s)) + return hex.EncodeToString(hasher.Sum(nil)) +} + type JWT struct { - SessionId string `json:"session_id"` - UserId int32 `json:"user_id"` - Role Role `json:"role"` - ExpiresAt int64 `json:"exp"` - IssuedAt int64 `json:"iat"` - NotBefore int64 `json:"nbf"` - Permissions []grant `json:"permissions"` + SessionId string `json:"session_id"` + UserId int32 `json:"user_id"` + Role Role `json:"role"` + IssuedAt int64 `json:"iat"` } func (j JWT) Valid() error { @@ -24,139 +78,18 @@ func (j JWT) Valid() error { if j.UserId == 0 { return errors.New("empty user id") } - if j.ExpiresAt == 0 { - return errors.New("empty expires at") - } if j.IssuedAt == 0 { return errors.New("empty issued at") } - if j.NotBefore == 0 { - return errors.New("empty not before") - } - if len(j.Permissions) == 0 { - return errors.New("empty permissions") - } return nil } -type Role int32 - -const ( - RoleGuest Role = -1 - RoleStudent Role = 0 - RoleTeacher Role = 1 - RoleAdmin Role = 2 -) - -func (r Role) String() string { - switch r { - case RoleGuest: - return "guest" - case RoleStudent: - return "student" - case RoleTeacher: - return "teacher" - case RoleAdmin: - return "admin" - } - - panic("invalid role") +type Credentials struct { + Username string + Password string } -type Action string - -const ( - Create Action = "create" - Read Action = "read" - Update Action = "update" - Delete Action = "delete" -) - -type Resource string - -const ( - ResourceAnotherUser Resource = "another-user" - ResourceMeUser Resource = "me-user" - ResourceListUser Resource = "list-user" - - ResourceOwnSession Resource = "own-session" -) - -type grant struct { - Action Action `json:"action"` - Resource Resource `json:"resource"` -} - -var Grants = map[string][]grant{ - RoleGuest.String(): {}, - RoleStudent.String(): { - {Read, ResourceAnotherUser}, - {Read, ResourceMeUser}, - {Update, ResourceOwnSession}, - {Delete, ResourceOwnSession}, - }, - RoleTeacher.String(): { - {Create, ResourceAnotherUser}, - {Read, ResourceAnotherUser}, - {Read, ResourceMeUser}, - {Read, ResourceListUser}, - {Update, ResourceOwnSession}, - {Delete, ResourceOwnSession}, - }, - RoleAdmin.String(): { - {Create, ResourceAnotherUser}, - {Read, ResourceAnotherUser}, - {Read, ResourceMeUser}, - {Read, ResourceListUser}, - {Update, ResourceAnotherUser}, - {Update, ResourceOwnSession}, - {Delete, ResourceAnotherUser}, - {Delete, ResourceOwnSession}, - }, -} - -const module = `package app.rbac - -default allow := false - -allow if { - some grant in input.role_grants[input.role] - - input.action == grant.action - input.resource == grant.resource -} -` - -var query rego.PreparedEvalQuery - -func (r Role) HasPermission(action Action, resource Resource) bool { - ctx := context.TODO() - - input := map[string]interface{}{ - "action": action, - "resource": resource, - "role": r.String(), - "role_grants": Grants, - } - - results, err := query.Eval(ctx, rego.EvalInput(input)) - if err != nil { - panic(err) - } - - return results.Allowed() -} - -func init() { - var err error - ctx := context.TODO() - - query, err = rego.New( - rego.Query("data.app.rbac.allow"), - rego.Module("ms-auth.rego", module), - ).PrepareForEval(ctx) - - if err != nil { - panic(err) - } +type Device struct { + Ip string + UseAgent string } diff --git a/internal/models/solution.go b/internal/models/solution.go index 869bed5..da7e8a6 100644 --- a/internal/models/solution.go +++ b/internal/models/solution.go @@ -31,6 +31,7 @@ type Solution struct { type SolutionCreation struct { Solution string TaskId int32 + UserId int32 ParticipantId int32 Language int32 Penalty int32 diff --git a/internal/models/task.go b/internal/models/task.go deleted file mode 100644 index 04e2c55..0000000 --- a/internal/models/task.go +++ /dev/null @@ -1,35 +0,0 @@ -package models - -import "time" - -type Task struct { - Id int32 `db:"id"` - Position int32 `db:"position"` - Title string `db:"title"` - TimeLimit int32 `db:"time_limit"` - MemoryLimit int32 `db:"memory_limit"` - - ProblemId int32 `db:"problem_id"` - ContestId int32 `db:"contest_id"` - - LegendHtml string `db:"legend_html"` - InputFormatHtml string `db:"input_format_html"` - OutputFormatHtml string `db:"output_format_html"` - NotesHtml string `db:"notes_html"` - ScoringHtml string `db:"scoring_html"` - - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type TasksListItem struct { - Id int32 `db:"id"` - ProblemId int32 `db:"problem_id"` - ContestId int32 `db:"contest_id"` - Position int32 `db:"position"` - Title string `db:"title"` - MemoryLimit int32 `db:"memory_limit"` - TimeLimit int32 `db:"time_limit"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..3f4c379 --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,71 @@ +package models + +import ( + "golang.org/x/crypto/bcrypt" + "time" +) + +type Role int32 + +type User struct { + Id int32 `db:"id"` + Username string `db:"username"` + HashedPassword string `db:"hashed_pwd"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + Role Role `db:"role"` +} + +type UserCreation struct { + Username string + Password string + Role Role +} + +func (u *UserCreation) HashPassword() error { + hpwd, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) + if err != nil { + return err + } + u.Password = string(hpwd) + return nil +} + +func (user *User) IsSamePwd(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(password)) + if err != nil { + return false + } + return true +} + +type UsersListFilters struct { + PageSize int32 + Page int32 +} + +func (f UsersListFilters) Offset() int32 { + return (f.Page - 1) * f.PageSize +} + +type UsersList struct { + Users []*User + Pagination Pagination +} + +type UserUpdate struct { + Username *string + Role *Role +} + +const ( + RoleGuest Role = -1 + RoleStudent Role = 0 + RoleTeacher Role = 1 + RoleAdmin Role = 2 +) + +type Grant struct { + Action string + Resource string +} diff --git a/internal/problems/delivery.go b/internal/problems/delivery.go new file mode 100644 index 0000000..7f371e4 --- /dev/null +++ b/internal/problems/delivery.go @@ -0,0 +1,15 @@ +package problems + +import ( + testerv1 "git.sch9.ru/new_gate/ms-tester/contracts/tester/v1" + "github.com/gofiber/fiber/v2" +) + +type ProblemsHandlers interface { + ListProblems(c *fiber.Ctx, params testerv1.ListProblemsParams) error + CreateProblem(c *fiber.Ctx) error + DeleteProblem(c *fiber.Ctx, id int32) error + GetProblem(c *fiber.Ctx, id int32) error + UpdateProblem(c *fiber.Ctx, id int32) error + UploadProblem(c *fiber.Ctx, id int32) error +} diff --git a/internal/problems/delivery/rest/handlers.go b/internal/problems/delivery/rest/handlers.go new file mode 100644 index 0000000..5dc7bd4 --- /dev/null +++ b/internal/problems/delivery/rest/handlers.go @@ -0,0 +1,246 @@ +package rest + +import ( + "context" + 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/problems" + "git.sch9.ru/new_gate/ms-tester/pkg" + "github.com/gofiber/fiber/v2" +) + +type Handlers struct { + problemsUC problems.UseCase + + jwtSecret string +} + +const ( + sessionKey = "session" +) + +func sessionFromCtx(ctx context.Context) (*models.Session, error) { + const op = "sessionFromCtx" + + session, ok := ctx.Value(sessionKey).(*models.Session) + if !ok { + return nil, pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "") + } + + return session, nil +} + +func NewHandlers(problemsUC problems.UseCase) *Handlers { + return &Handlers{ + problemsUC: problemsUC, + } +} + +func (h *Handlers) ListProblems(c *fiber.Ctx, params testerv1.ListProblemsParams) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + problemsList, err := h.problemsUC.ListProblems(c.Context(), models.ProblemsFilter{ + Page: params.Page, + PageSize: params.PageSize, + }) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + resp := testerv1.ListProblemsResponse{ + Problems: make([]testerv1.ProblemsListItem, len(problemsList.Problems)), + Pagination: PaginationDTO(problemsList.Pagination), + } + + for i, problem := range problemsList.Problems { + resp.Problems[i] = ProblemsListItemDTO(*problem) + } + return c.JSON(resp) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) CreateProblem(c *fiber.Ctx) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + id, err := h.problemsUC.CreateProblem(c.Context(), "Название задачи") + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(testerv1.CreateProblemResponse{ + Id: id, + }) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) DeleteProblem(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + err := h.problemsUC.DeleteProblem(c.Context(), id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) GetProblem(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + problem, err := h.problemsUC.GetProblemById(c.Context(), id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON( + testerv1.GetProblemResponse{Problem: *ProblemDTO(problem)}, + ) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) UpdateProblem(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + var req testerv1.UpdateProblemRequest + err := c.BodyParser(&req) + if err != nil { + return err + } + + err = h.problemsUC.UpdateProblem(c.Context(), id, &models.ProblemUpdate{ + Title: req.Title, + MemoryLimit: req.MemoryLimit, + TimeLimit: req.TimeLimit, + + Legend: req.Legend, + InputFormat: req.InputFormat, + OutputFormat: req.OutputFormat, + Notes: req.Notes, + Scoring: req.Scoring, + }) + + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) UploadProblem(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + var req testerv1.UploadProblemRequest + err := c.BodyParser(&req) + if err != nil { + return err + } + + data, err := req.Archive.Bytes() + if err != nil { + return err + } + if err = h.problemsUC.UploadProblem(c.Context(), id, data); err != nil { + return err + } + return nil + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func PaginationDTO(p models.Pagination) testerv1.Pagination { + return testerv1.Pagination{ + Page: p.Page, + Total: p.Total, + } +} + +func ProblemsListItemDTO(p models.ProblemsListItem) testerv1.ProblemsListItem { + return testerv1.ProblemsListItem{ + Id: p.Id, + Title: p.Title, + MemoryLimit: p.MemoryLimit, + TimeLimit: p.TimeLimit, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + SolvedCount: p.SolvedCount, + } +} + +func ProblemDTO(p *models.Problem) *testerv1.Problem { + return &testerv1.Problem{ + Id: p.Id, + Title: p.Title, + TimeLimit: p.TimeLimit, + MemoryLimit: p.MemoryLimit, + + Legend: p.Legend, + InputFormat: p.InputFormat, + OutputFormat: p.OutputFormat, + Notes: p.Notes, + Scoring: p.Scoring, + + LegendHtml: p.LegendHtml, + InputFormatHtml: p.InputFormatHtml, + OutputFormatHtml: p.OutputFormatHtml, + NotesHtml: p.NotesHtml, + ScoringHtml: p.ScoringHtml, + + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } +} diff --git a/internal/problems/pg_repository.go b/internal/problems/pg_repository.go new file mode 100644 index 0000000..678274a --- /dev/null +++ b/internal/problems/pg_repository.go @@ -0,0 +1,32 @@ +package problems + +import ( + "context" + "database/sql" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "github.com/jmoiron/sqlx" +) + +type Querier interface { + Rebind(query string) string + QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) + GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error +} + +type Tx interface { + Querier + Commit() error + Rollback() error +} + +type Repository interface { + BeginTx(ctx context.Context) (Tx, error) + DB() Querier + CreateProblem(ctx context.Context, q Querier, title string) (int32, error) + GetProblemById(ctx context.Context, q Querier, id int32) (*models.Problem, error) + DeleteProblem(ctx context.Context, q Querier, id int32) error + ListProblems(ctx context.Context, q Querier, filter models.ProblemsFilter) (*models.ProblemsList, error) + UpdateProblem(ctx context.Context, q Querier, id int32, heading *models.ProblemUpdate) error +} diff --git a/internal/problems/repository/pg_repository.go b/internal/problems/repository/pg_repository.go new file mode 100644 index 0000000..5de265f --- /dev/null +++ b/internal/problems/repository/pg_repository.go @@ -0,0 +1,175 @@ +package repository + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/problems" + "git.sch9.ru/new_gate/ms-tester/pkg" + + "git.sch9.ru/new_gate/ms-tester/internal/models" + "github.com/jmoiron/sqlx" +) + +type Repository struct { + _db *sqlx.DB +} + +func NewRepository(db *sqlx.DB) *Repository { + return &Repository{ + _db: db, + } +} + +func (r *Repository) BeginTx(ctx context.Context) (problems.Tx, error) { + tx, err := r._db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + + return tx, nil +} + +func (r *Repository) DB() problems.Querier { + return r._db +} + +const CreateProblemQuery = "INSERT INTO problems (title) VALUES ($1) RETURNING id" + +func (r *Repository) CreateProblem(ctx context.Context, q problems.Querier, title string) (int32, error) { + const op = "Repository.CreateProblem" + + rows, err := q.QueryxContext(ctx, CreateProblemQuery, title) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + + defer rows.Close() + var id int32 + rows.Next() + err = rows.Scan(&id) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + + return id, nil +} + +const GetProblemByIdQuery = "SELECT * from problems WHERE id=$1 LIMIT 1" + +func (r *Repository) GetProblemById(ctx context.Context, q problems.Querier, id int32) (*models.Problem, error) { + const op = "Repository.ReadProblemById" + + var problem models.Problem + err := q.GetContext(ctx, &problem, GetProblemByIdQuery, id) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + return &problem, nil +} + +const DeleteProblemQuery = "DELETE FROM problems WHERE id=$1" + +func (r *Repository) DeleteProblem(ctx context.Context, q problems.Querier, id int32) error { + const op = "Repository.DeleteProblem" + + _, err := q.ExecContext(ctx, DeleteProblemQuery, id) + if err != nil { + return pkg.HandlePgErr(err, op) + } + + return nil +} + +const ( + ListProblemsQuery = `SELECT p.id, + p.title, + p.memory_limit, + p.time_limit, + p.created_at, + p.updated_at, + COALESCE(solved_count, 0) AS solved_count +FROM problems p + LEFT JOIN (SELECT t.problem_id, + COUNT(DISTINCT s.participant_id) AS solved_count + FROM solutions s + JOIN tasks t ON s.task_id = t.id + WHERE s.state = 5 + GROUP BY t.problem_id) sol ON p.id = sol.problem_id +LIMIT $1 OFFSET $2` + CountProblemsQuery = "SELECT COUNT(*) FROM problems" +) + +func (r *Repository) ListProblems(ctx context.Context, q problems.Querier, filter models.ProblemsFilter) (*models.ProblemsList, error) { + const op = "ContestRepository.ListProblems" + + var list []*models.ProblemsListItem + err := q.SelectContext(ctx, &list, ListProblemsQuery, filter.PageSize, filter.Offset()) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + var count int32 + err = q.GetContext(ctx, &count, CountProblemsQuery) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + return &models.ProblemsList{ + Problems: list, + Pagination: models.Pagination{ + Total: models.Total(count, filter.PageSize), + Page: filter.Page, + }, + }, nil +} + +const ( + UpdateProblemQuery = `UPDATE problems +SET title = COALESCE($2, title), + time_limit = COALESCE($3, time_limit), + memory_limit = COALESCE($4, memory_limit), + + legend = COALESCE($5, legend), + input_format = COALESCE($6, input_format), + output_format = COALESCE($7, output_format), + notes = COALESCE($8, notes), + scoring = COALESCE($9, scoring), + + legend_html = COALESCE($10, legend_html), + input_format_html = COALESCE($11, input_format_html), + output_format_html = COALESCE($12, output_format_html), + notes_html = COALESCE($13, notes_html), + scoring_html = COALESCE($14, scoring_html) + +WHERE id=$1` +) + +func (r *Repository) UpdateProblem(ctx context.Context, q problems.Querier, id int32, problem *models.ProblemUpdate) error { + const op = "Repository.UpdateProblem" + + query := q.Rebind(UpdateProblemQuery) + _, err := q.ExecContext(ctx, query, + id, + + problem.Title, + problem.TimeLimit, + problem.MemoryLimit, + + problem.Legend, + problem.InputFormat, + problem.OutputFormat, + problem.Notes, + problem.Scoring, + + problem.LegendHtml, + problem.InputFormatHtml, + problem.OutputFormatHtml, + problem.NotesHtml, + problem.ScoringHtml, + ) + if err != nil { + return pkg.HandlePgErr(err, op) + } + + return nil +} diff --git a/internal/problems/repository/pg_repository_test.go b/internal/problems/repository/pg_repository_test.go new file mode 100644 index 0000000..f2bc2f2 --- /dev/null +++ b/internal/problems/repository/pg_repository_test.go @@ -0,0 +1,293 @@ +package repository_test + +import ( + "context" + "database/sql" + "fmt" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/internal/problems/repository" + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +// setupTestDB creates a mocked sqlx.DB and sqlmock instance for testing. +func setupTestDB(t *testing.T) (*sqlx.DB, sqlmock.Sqlmock) { + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + assert.NoError(t, err) + sqlxDB := sqlx.NewDb(db, "sqlmock") + return sqlxDB, mock +} + +func TestRepository_CreateProblem(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + problem := models.Problem{ + Id: 1, + Title: "Test Problem", + } + + mock.ExpectQuery(repository.CreateProblemQuery). + WithArgs(problem.Title). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(problem.Id)) + + id, err := repo.CreateProblem(ctx, db, problem.Title) + assert.NoError(t, err) + assert.Equal(t, problem.Id, id) + }) +} + +func TestRepository_GetProblemById(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + expected := &models.Problem{ + Id: 1, + Title: "Test Problem", + TimeLimit: 1000, + MemoryLimit: 1024, + Legend: "Test Legend", + InputFormat: "Test Input Format", + OutputFormat: "Test Output Format", + Notes: "Test Notes", + Scoring: "Test Scoring", + LegendHtml: "Test Legend HTML", + InputFormatHtml: "Test Input Format HTML", + OutputFormatHtml: "Test Output Format HTML", + NotesHtml: "Test Notes HTML", + ScoringHtml: "Test Scoring HTML", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + columns := []string{ + "id", + "title", + "time_limit", + "memory_limit", + + "legend", + "input_format", + "output_format", + "notes", + "scoring", + + "legend_html", + "input_format_html", + "output_format_html", + "notes_html", + "scoring_html", + + "created_at", + "updated_at", + } + + rows := sqlmock.NewRows(columns). + AddRow( + expected.Id, + expected.Title, + expected.TimeLimit, + expected.MemoryLimit, + + expected.Legend, + expected.InputFormat, + expected.OutputFormat, + expected.Notes, + expected.Scoring, + + expected.LegendHtml, + expected.InputFormatHtml, + expected.OutputFormatHtml, + expected.NotesHtml, + expected.ScoringHtml, + + expected.CreatedAt, + expected.UpdatedAt) + + mock.ExpectQuery(repository.GetProblemByIdQuery).WithArgs(expected.Id).WillReturnRows(rows) + + problem, err := repo.GetProblemById(ctx, db, expected.Id) + assert.NoError(t, err) + assert.EqualExportedValues(t, expected, problem) + }) + + t.Run("not found", func(t *testing.T) { + ctx := context.Background() + + id := int32(1) + + mock.ExpectQuery(repository.GetProblemByIdQuery).WithArgs(id).WillReturnError(sql.ErrNoRows) + + _, err := repo.GetProblemById(ctx, db, id) + assert.Error(t, err) + }) +} + +func TestRepository_DeleteProblem(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + id := int32(1) + + mock.ExpectExec(repository.DeleteProblemQuery). + WithArgs(id).WillReturnResult(sqlmock.NewResult(0, 1)) + + err := repo.DeleteProblem(ctx, db, id) + assert.NoError(t, err) + }) + + t.Run("not found", func(t *testing.T) { + ctx := context.Background() + id := int32(1) + + mock.ExpectExec(repository.DeleteProblemQuery).WithArgs(id).WillReturnError(sql.ErrNoRows) + + err := repo.DeleteProblem(ctx, db, id) + assert.Error(t, err) + }) +} + +func TestRepository_ListProblems(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + expected := make([]*models.ProblemsListItem, 0) + for i := 0; i < 10; i++ { + problem := &models.ProblemsListItem{ + Id: int32(i + 1), + Title: fmt.Sprintf("Test Problem %d", i+1), + TimeLimit: 1000, + MemoryLimit: 1024, + SolvedCount: int32(123 * i), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + expected = append(expected, problem) + } + + filter := models.ProblemsFilter{ + Page: 1, + PageSize: 10, + } + + var totalCount int32 = 10 + + columns := []string{ + "id", + "title", + "time_limit", + "memory_limit", + "solved_count", + "created_at", + "updated_at", + } + + rows := sqlmock.NewRows(columns) + for _, problem := range expected { + rows = rows.AddRow( + problem.Id, + problem.Title, + problem.TimeLimit, + problem.MemoryLimit, + problem.SolvedCount, + problem.CreatedAt, + problem.UpdatedAt, + ) + } + + mock.ExpectQuery(repository.ListProblemsQuery).WillReturnRows(rows) + mock.ExpectQuery(repository.CountProblemsQuery). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(totalCount)) + + problems, err := repo.ListProblems(ctx, db, filter) + assert.NoError(t, err) + assert.Equal(t, expected, problems.Problems) + assert.Equal(t, models.Pagination{ + Page: 1, + Total: 1, + }, problems.Pagination) + }) +} + +func TestRepository_UpdateProblem(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + var id int32 = 1 + + update := &models.ProblemUpdate{ + Title: sp("Test Problem"), + TimeLimit: ip(1000), + MemoryLimit: ip(1024), + Legend: sp("Test Legend"), + InputFormat: sp("Test Input Format"), + OutputFormat: sp("Test Output Format"), + Notes: sp("Test Notes"), + Scoring: sp("Test Scoring"), + LegendHtml: sp("Test Legend HTML"), + InputFormatHtml: sp("Test Input Format HTML"), + OutputFormatHtml: sp("Test Output Format HTML"), + NotesHtml: sp("Test Notes HTML"), + ScoringHtml: sp("Test Scoring HTML"), + } + + mock.ExpectExec(repository.UpdateProblemQuery).WithArgs( + id, + + update.Title, + update.TimeLimit, + update.MemoryLimit, + + update.Legend, + update.InputFormat, + update.OutputFormat, + update.Notes, + update.Scoring, + + update.LegendHtml, + update.InputFormatHtml, + update.OutputFormatHtml, + update.NotesHtml, + update.ScoringHtml, + ).WillReturnResult(sqlmock.NewResult(1, 1)) + + err := repo.UpdateProblem(ctx, db, id, update) + assert.NoError(t, err) + }) +} + +func sp(s string) *string { + return &s +} + +func ip(s int32) *int32 { + return &s +} diff --git a/internal/problems/usecase.go b/internal/problems/usecase.go new file mode 100644 index 0000000..bbe1c71 --- /dev/null +++ b/internal/problems/usecase.go @@ -0,0 +1,15 @@ +package problems + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +type UseCase interface { + CreateProblem(ctx context.Context, title string) (int32, error) + GetProblemById(ctx context.Context, id int32) (*models.Problem, error) + DeleteProblem(ctx context.Context, id int32) error + ListProblems(ctx context.Context, filter models.ProblemsFilter) (*models.ProblemsList, error) + UpdateProblem(ctx context.Context, id int32, problem *models.ProblemUpdate) error + UploadProblem(ctx context.Context, id int32, archive []byte) error +} diff --git a/internal/tester/usecase/problems_usecase.go b/internal/problems/usecase/usecase.go similarity index 88% rename from internal/tester/usecase/problems_usecase.go rename to internal/problems/usecase/usecase.go index 27a9c37..b73557b 100644 --- a/internal/tester/usecase/problems_usecase.go +++ b/internal/problems/usecase/usecase.go @@ -7,48 +7,48 @@ import ( "encoding/json" "errors" "fmt" + "git.sch9.ru/new_gate/ms-tester/internal/problems" "io" "strings" "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" "github.com/microcosm-cc/bluemonday" ) -type ProblemUseCase struct { - problemRepo tester.ProblemPostgresRepository +type UseCase struct { + problemRepo problems.Repository pandocClient pkg.PandocClient } -func NewProblemUseCase( - problemRepo tester.ProblemPostgresRepository, +func NewUseCase( + problemRepo problems.Repository, pandocClient pkg.PandocClient, -) *ProblemUseCase { - return &ProblemUseCase{ +) *UseCase { + return &UseCase{ problemRepo: problemRepo, pandocClient: pandocClient, } } -func (u *ProblemUseCase) CreateProblem(ctx context.Context, title string) (int32, error) { +func (u *UseCase) CreateProblem(ctx context.Context, title string) (int32, error) { return u.problemRepo.CreateProblem(ctx, u.problemRepo.DB(), title) } -func (u *ProblemUseCase) ReadProblemById(ctx context.Context, id int32) (*models.Problem, error) { - return u.problemRepo.ReadProblemById(ctx, u.problemRepo.DB(), id) +func (u *UseCase) GetProblemById(ctx context.Context, id int32) (*models.Problem, error) { + return u.problemRepo.GetProblemById(ctx, u.problemRepo.DB(), id) } -func (u *ProblemUseCase) DeleteProblem(ctx context.Context, id int32) error { +func (u *UseCase) DeleteProblem(ctx context.Context, id int32) error { return u.problemRepo.DeleteProblem(ctx, u.problemRepo.DB(), id) } -func (u *ProblemUseCase) ListProblems(ctx context.Context, filter models.ProblemsFilter) (*models.ProblemsList, error) { +func (u *UseCase) ListProblems(ctx context.Context, filter models.ProblemsFilter) (*models.ProblemsList, error) { return u.problemRepo.ListProblems(ctx, u.problemRepo.DB(), filter) } -func (u *ProblemUseCase) UpdateProblem(ctx context.Context, id int32, problemUpdate models.ProblemUpdate) error { - if isEmpty(problemUpdate) { +func (u *UseCase) UpdateProblem(ctx context.Context, id int32, problemUpdate *models.ProblemUpdate) error { + if isEmpty(*problemUpdate) { return pkg.Wrap(pkg.ErrBadInput, nil, "UpdateProblem", "empty problem update") } @@ -57,7 +57,7 @@ func (u *ProblemUseCase) UpdateProblem(ctx context.Context, id int32, problemUpd return err } - problem, err := u.problemRepo.ReadProblemById(ctx, tx, id) + problem, err := u.problemRepo.GetProblemById(ctx, tx, id) if err != nil { return errors.Join(err, tx.Rollback()) } @@ -126,7 +126,7 @@ type ProblemProperties struct { MemoryLimit int32 `json:"memoryLimit"` } -func (u *ProblemUseCase) UploadProblem(ctx context.Context, id int32, data []byte) error { +func (u *UseCase) UploadProblem(ctx context.Context, id int32, data []byte) error { locale := "russian" defaultLocale := "english" @@ -185,7 +185,7 @@ func (u *ProblemUseCase) UploadProblem(ctx context.Context, id int32, data []byt localeProperties.MemoryLimit /= 1024 * 1024 defaultProperties.MemoryLimit /= 1024 * 1024 - var problemUpdate models.ProblemUpdate + problemUpdate := &models.ProblemUpdate{} if localeProblem != "" { problemUpdate.Legend = &localeProblem problemUpdate.Title = &localeProperties.Title diff --git a/internal/sessions/repository/valkey_repository.go b/internal/sessions/repository/valkey_repository.go new file mode 100644 index 0000000..c26372d --- /dev/null +++ b/internal/sessions/repository/valkey_repository.go @@ -0,0 +1,183 @@ +package repository + +import ( + "context" + "fmt" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/pkg" + "github.com/valkey-io/valkey-go" + "strconv" + "time" +) + +type ValkeyRepository struct { + db valkey.Client +} + +func NewValkeyRepository(db valkey.Client) *ValkeyRepository { + return &ValkeyRepository{ + db: db, + } +} + +const SessionLifetime = time.Minute * 40 + +func (r *ValkeyRepository) CreateSession(ctx context.Context, session *models.Session) error { + const op = "ValkeyRepository.CreateSession" + + data, err := session.JSON() + if err != nil { + return pkg.Wrap(pkg.ErrInternal, err, op, "cannot marshal session") + } + + resp := r.db.Do(ctx, r.db. + B().Set(). + Key(session.Key()). + Value(string(data)). + Exat(session.ExpiresAt). + Build(), + ) + + err = resp.Error() + if err != nil { + if valkey.IsValkeyNil(err) { + return pkg.Wrap(pkg.ErrInternal, err, op, "nil response") + } + return pkg.Wrap(pkg.ErrUnhandled, err, op, "unhandled valkey error") + } + + return nil +} + +const ( + readSessionScript = `local result = redis.call('SCAN', 0, 'MATCH', ARGV[1]) +if #result[2] == 0 then + return nil +else + return redis.call('GET', result[2][1]) +end` +) + +func (r *ValkeyRepository) ReadSession(ctx context.Context, sessionId string) (*models.Session, error) { + const op = "ValkeyRepository.ReadSession" + + sessionIdHash := (&models.Session{Id: sessionId}).SessionIdHash() + + resp := valkey.NewLuaScript(readSessionScript).Exec( + ctx, + r.db, + nil, + []string{fmt.Sprintf("userid:*:sessionid:%s", sessionIdHash)}, + ) + + if err := resp.Error(); err != nil { + if valkey.IsValkeyNil(err) { + return nil, pkg.Wrap(pkg.ErrNotFound, err, op, "reading session") + } + return nil, pkg.Wrap(pkg.ErrUnhandled, err, op, "unhandled valkey error") + } + + session := &models.Session{} + + err := resp.DecodeJSON(session) + if err != nil { + return nil, pkg.Wrap(pkg.ErrInternal, err, op, "session storage corrupted") + } + + return session, nil +} + +const ( + updateSessionScript = `local result = redis.call('SCAN', 0, 'MATCH', ARGV[1]) +return #result[2] > 0 and redis.call('EXPIRE', result[2][1], ARGV[2]) == 1` +) + +var ( + sessionLifetimeString = strconv.Itoa(int(SessionLifetime.Seconds())) +) + +func (r *ValkeyRepository) UpdateSession(ctx context.Context, sessionId string) error { + const op = "ValkeyRepository.UpdateSession" + + sessionIdHash := (&models.Session{Id: sessionId}).SessionIdHash() + + resp := valkey.NewLuaScript(updateSessionScript).Exec( + ctx, + r.db, + nil, + []string{fmt.Sprintf("userid:*:sessionid:%s", sessionIdHash), sessionLifetimeString}, + ) + + err := resp.Error() + if err != nil { + if valkey.IsValkeyNil(err) { + return pkg.Wrap(pkg.ErrNotFound, err, op, "nil response") + } + return pkg.Wrap(pkg.ErrUnhandled, err, op, "unhandled valkey error") + } + + return nil +} + +const deleteSessionScript = `local result = redis.call('SCAN', 0, 'MATCH', ARGV[1]) +return #result[2] > 0 and redis.call('DEL', result[2][1]) == 1` + +func (r *ValkeyRepository) DeleteSession(ctx context.Context, sessionId string) error { + const op = "ValkeyRepository.DeleteSession" + + sessionIdHash := (&models.Session{Id: sessionId}).SessionIdHash() + + resp := valkey.NewLuaScript(deleteSessionScript).Exec( + ctx, + r.db, + nil, + []string{fmt.Sprintf("userid:*:sessionid:%s", sessionIdHash)}, + ) + + err := resp.Error() + if err != nil { + if valkey.IsValkeyNil(err) { + return pkg.Wrap(pkg.ErrNotFound, err, op, "nil response") + } + return pkg.Wrap(pkg.ErrUnhandled, err, op, "unhandled valkey error") + } + + return nil +} + +const ( + deleteUserSessionsScript = `local cursor = 0 +local dels = 0 +repeat + local result = redis.call('SCAN', cursor, 'MATCH', ARGV[1]) + for _,key in ipairs(result[2]) do + redis.call('DEL', key) + dels = dels + 1 + end + cursor = tonumber(result[1]) +until cursor == 0 +return dels` +) + +func (r *ValkeyRepository) DeleteAllSessions(ctx context.Context, userId int32) error { + const op = "ValkeyRepository.DeleteAllSessions" + + userIdHash := (&models.Session{UserId: userId}).UserIdHash() + + resp := valkey.NewLuaScript(deleteUserSessionsScript).Exec( + ctx, + r.db, + nil, + []string{fmt.Sprintf("userid:%s:sessionid:*", userIdHash)}, + ) + + err := resp.Error() + if err != nil { + if valkey.IsValkeyNil(err) { + return pkg.Wrap(pkg.ErrNotFound, err, op, "nil response") + } + return pkg.Wrap(pkg.ErrUnhandled, err, op, "unhandled valkey error") + } + + return nil +} diff --git a/internal/sessions/repository/valkey_repository_test.go b/internal/sessions/repository/valkey_repository_test.go new file mode 100644 index 0000000..9acdacf --- /dev/null +++ b/internal/sessions/repository/valkey_repository_test.go @@ -0,0 +1,312 @@ +package repository_test + +import ( + "context" + "fmt" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/internal/sessions/repository" + "git.sch9.ru/new_gate/ms-tester/pkg" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/valkey-io/valkey-go" + "github.com/valkey-io/valkey-go/mock" + "go.uber.org/mock/gomock" + "strconv" + "testing" + "time" +) + +func TestValkeyRepository_CreateSession(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + client := mock.NewClient(ctrl) + sessionRepo := repository.NewValkeyRepository(client) + + t.Run("success", func(t *testing.T) { + session := &models.Session{ + Id: uuid.NewString(), + UserId: 1, + Role: models.RoleAdmin, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(repository.SessionLifetime), + UserAgent: "Mozilla/5.0", + Ip: "127.0.0.1", + } + + matcher := mock.MatchFn(func(cmd []string) bool { + if cmd[0] != "SET" { + return false + } + if cmd[1] != session.Key() { + return false + } + if cmd[3] != "EXAT" { + return false + } + if cmd[4] != strconv.FormatInt(session.ExpiresAt.Unix(), 10) { + return false + } + return true + }) + + ctx := context.Background() + client.EXPECT().Do(ctx, matcher) + err := sessionRepo.CreateSession(ctx, session) + require.NoError(t, err) + }) +} + +func TestValkeyRepository_ReadSession(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + client := mock.NewClient(ctrl) + sessionRepo := repository.NewValkeyRepository(client) + + t.Run("success", func(t *testing.T) { + session := &models.Session{ + Id: uuid.NewString(), + UserId: 1, + Role: models.RoleAdmin, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(repository.SessionLifetime), + UserAgent: "Mozilla/5.0", + Ip: "127.0.0.1", + } + + matcher := mock.MatchFn(func(cmd []string) bool { + fmt.Println(cmd) + + if cmd[0] != "EVALSHA" { + return false + } + if cmd[2] != "0" { + return false + } + if cmd[3] != fmt.Sprintf("userid:*:sessionid:%s", session.SessionIdHash()) { + return false + } + return true + }) + + d, err := session.JSON() + require.NoError(t, err) + ctx := context.Background() + client.EXPECT().Do(ctx, matcher).Return(mock.Result(mock.ValkeyString(string(d)))) + res, err := sessionRepo.ReadSession(ctx, session.Id) + require.NoError(t, err) + fmt.Println(res.CreatedAt.Unix(), res.ExpiresAt.UnixNano()) + fmt.Println(session.CreatedAt.Unix(), session.ExpiresAt.UnixNano()) + require.EqualExportedValues(t, session, res) + }) + + t.Run("not found", func(t *testing.T) { + session := &models.Session{ + Id: uuid.NewString(), + } + + matcher := mock.MatchFn(func(cmd []string) bool { + if cmd[0] != "EVALSHA" { + return false + } + if cmd[2] != "0" { + return false + } + if cmd[3] != fmt.Sprintf("userid:*:sessionid:%s", session.SessionIdHash()) { + return false + } + return true + }) + + ctx := context.Background() + client.EXPECT().Do(ctx, matcher).Return(mock.ErrorResult(valkey.Nil)) + res, err := sessionRepo.ReadSession(ctx, session.Id) + require.ErrorIs(t, err, pkg.ErrNotFound) + require.ErrorIs(t, err, valkey.Nil) + require.Empty(t, res) + }) +} + +func TestValkeyRepository_UpdateSession(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + client := mock.NewClient(ctrl) + sessionRepo := repository.NewValkeyRepository(client) + + t.Run("success", func(t *testing.T) { + session := &models.Session{ + Id: uuid.NewString(), + } + + matcher := mock.MatchFn(func(cmd []string) bool { + if cmd[0] != "EVALSHA" { + return false + } + if cmd[2] != "0" { + return false + } + if cmd[3] != fmt.Sprintf("userid:*:sessionid:%s", session.SessionIdHash()) { + return false + } + return true + }) + + ctx := context.Background() + client.EXPECT().Do(ctx, matcher) + err := sessionRepo.UpdateSession(ctx, session.Id) + require.NoError(t, err) + }) + + t.Run("not found", func(t *testing.T) { + session := &models.Session{ + Id: uuid.NewString(), + } + + matcher := mock.MatchFn(func(cmd []string) bool { + if cmd[0] != "EVALSHA" { + return false + } + if cmd[2] != "0" { + return false + } + if cmd[3] != fmt.Sprintf("userid:*:sessionid:%s", session.SessionIdHash()) { + return false + } + return true + }) + + ctx := context.Background() + client.EXPECT().Do(ctx, matcher).Return(mock.ErrorResult(valkey.Nil)) + err := sessionRepo.UpdateSession(ctx, session.Id) + require.ErrorIs(t, err, pkg.ErrNotFound) + require.ErrorIs(t, err, valkey.Nil) + }) +} + +func TestValkeyRepository_DeleteSession(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + client := mock.NewClient(ctrl) + sessionRepo := repository.NewValkeyRepository(client) + + t.Run("success", func(t *testing.T) { + session := &models.Session{ + Id: uuid.NewString(), + } + + matcher := mock.MatchFn(func(cmd []string) bool { + if cmd[0] != "EVALSHA" { + return false + } + if cmd[2] != "0" { + return false + } + if cmd[3] != fmt.Sprintf("userid:*:sessionid:%s", session.SessionIdHash()) { + return false + } + return true + }) + + ctx := context.Background() + client.EXPECT().Do(ctx, matcher) + err := sessionRepo.DeleteSession(ctx, session.Id) + require.NoError(t, err) + }) + + t.Run("not found", func(t *testing.T) { + session := &models.Session{ + Id: uuid.NewString(), + } + + matcher := mock.MatchFn(func(cmd []string) bool { + if cmd[0] != "EVALSHA" { + return false + } + if cmd[2] != "0" { + return false + } + if cmd[3] != fmt.Sprintf("userid:*:sessionid:%s", session.SessionIdHash()) { + return false + } + return true + }) + + ctx := context.Background() + client.EXPECT().Do(ctx, matcher).Return(mock.ErrorResult(valkey.Nil)) + err := sessionRepo.DeleteSession(ctx, session.Id) + require.ErrorIs(t, err, pkg.ErrNotFound) + require.ErrorIs(t, err, valkey.Nil) + }) +} + +func TestValkeyRepository_DeleteAllSessions(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + client := mock.NewClient(ctrl) + sessionRepo := repository.NewValkeyRepository(client) + + t.Run("success", func(t *testing.T) { + session := &models.Session{ + UserId: 1, + } + + matcher := mock.MatchFn(func(cmd []string) bool { + fmt.Println(cmd) + + if cmd[0] != "EVALSHA" { + return false + } + if cmd[2] != "0" { + return false + } + if cmd[3] != fmt.Sprintf("userid:%s:sessionid:*", session.UserIdHash()) { + return false + } + return true + }) + + ctx := context.Background() + client.EXPECT().Do(ctx, matcher) + err := sessionRepo.DeleteAllSessions(ctx, session.UserId) + require.NoError(t, err) + }) + + t.Run("not found", func(t *testing.T) { + session := &models.Session{ + UserId: 1, + } + + matcher := mock.MatchFn(func(cmd []string) bool { + if cmd[0] != "EVALSHA" { + return false + } + if cmd[2] != "0" { + return false + } + if cmd[3] != fmt.Sprintf("userid:%s:sessionid:*", session.UserIdHash()) { + return false + } + return true + }) + + ctx := context.Background() + client.EXPECT().Do(ctx, matcher).Return(mock.ErrorResult(valkey.Nil)) + err := sessionRepo.DeleteAllSessions(ctx, session.UserId) + require.ErrorIs(t, err, pkg.ErrNotFound) + require.ErrorIs(t, err, valkey.Nil) + }) +} diff --git a/internal/sessions/usecase.go b/internal/sessions/usecase.go new file mode 100644 index 0000000..d565d7a --- /dev/null +++ b/internal/sessions/usecase.go @@ -0,0 +1,14 @@ +package sessions + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +type UseCase interface { + CreateSession(ctx context.Context, creation *models.Session) error + ReadSession(ctx context.Context, sessionId string) (*models.Session, error) + UpdateSession(ctx context.Context, sessionId string) error + DeleteSession(ctx context.Context, sessionId string) error + DeleteAllSessions(ctx context.Context, userId int32) error +} diff --git a/internal/sessions/usecase/usecase.go b/internal/sessions/usecase/usecase.go new file mode 100644 index 0000000..06ae511 --- /dev/null +++ b/internal/sessions/usecase/usecase.go @@ -0,0 +1,78 @@ +package usecase + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/config" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/internal/sessions" + "git.sch9.ru/new_gate/ms-tester/pkg" +) + +type SessionsUC struct { + sessionsRepo sessions.ValkeyRepository + cfg config.Config +} + +func NewUseCase( + sessionRepo sessions.ValkeyRepository, + cfg config.Config, +) *SessionsUC { + return &SessionsUC{ + sessionsRepo: sessionRepo, + cfg: cfg, + } +} + +// CreateSession is for login only. There are no permission checks! DO NOT USE IT AS AN ENDPOINT RESPONSE! +func (u *SessionsUC) CreateSession(ctx context.Context, creation *models.Session) error { + const op = "UseCase.CreateSession" + + err := u.sessionsRepo.CreateSession(ctx, creation) + if err != nil { + return pkg.Wrap(nil, err, op, "cannot create session") + } + + return nil +} + +// ReadSession is for internal use only. There are no permission checks! DO NOT USE IT AS AN ENDPOINT RESPONSE! +func (u *SessionsUC) ReadSession(ctx context.Context, sessionId string) (*models.Session, error) { + const op = "UseCase.ReadSession" + + session, err := u.sessionsRepo.ReadSession(ctx, sessionId) + if err != nil { + return nil, pkg.Wrap(nil, err, op, "cannot read session") + } + return session, nil +} + +func (u *SessionsUC) UpdateSession(ctx context.Context, sessionId string) error { + const op = "UseCase.UpdateSession" + + err := u.sessionsRepo.UpdateSession(ctx, sessionId) + if err != nil { + return pkg.Wrap(nil, err, op, "cannot update session") + } + return nil +} + +func (u *SessionsUC) DeleteSession(ctx context.Context, sessionId string) error { + const op = "UseCase.DeleteSession" + + err := u.sessionsRepo.DeleteSession(ctx, sessionId) + if err != nil { + return pkg.Wrap(nil, err, op, "cannot delete session") + } + return nil +} + +func (u *SessionsUC) DeleteAllSessions(ctx context.Context, userId int32) error { + const op = "UseCase.DeleteAllSessions" + + err := u.sessionsRepo.DeleteAllSessions(ctx, userId) + if err != nil { + return pkg.Wrap(nil, err, op, "cannot delete all sessions") + } + + return nil +} diff --git a/internal/sessions/valkey_repository.go b/internal/sessions/valkey_repository.go new file mode 100644 index 0000000..737a9c2 --- /dev/null +++ b/internal/sessions/valkey_repository.go @@ -0,0 +1,14 @@ +package sessions + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +type ValkeyRepository interface { + CreateSession(ctx context.Context, creation *models.Session) error + ReadSession(ctx context.Context, sessionId string) (*models.Session, error) + UpdateSession(ctx context.Context, sessionId string) error + DeleteSession(ctx context.Context, sessionId string) error + DeleteAllSessions(ctx context.Context, userId int32) error +} diff --git a/internal/tester/delivery/rest/handlers.go b/internal/tester/delivery/rest/handlers.go deleted file mode 100644 index 8c017b4..0000000 --- a/internal/tester/delivery/rest/handlers.go +++ /dev/null @@ -1,622 +0,0 @@ -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" - "github.com/gofiber/fiber/v2" - "io" -) - -type TesterHandlers struct { - problemsUC tester.ProblemUseCase - contestsUC tester.ContestUseCase -} - -func NewTesterHandlers(problemsUC tester.ProblemUseCase, contestsUC tester.ContestUseCase) *TesterHandlers { - return &TesterHandlers{ - problemsUC: problemsUC, - contestsUC: contestsUC, - } -} - -func (h *TesterHandlers) ListContests(c *fiber.Ctx, params testerv1.ListContestsParams) error { - contestsList, err := h.contestsUC.ListContests(c.Context(), models.ContestsFilter{ - Page: params.Page, - PageSize: params.PageSize, - }) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - resp := testerv1.ListContestsResponse{ - Contests: make([]testerv1.ContestsListItem, len(contestsList.Contests)), - Pagination: P2P(contestsList.Pagination), - } - - for i, contest := range contestsList.Contests { - resp.Contests[i] = CLI2CLI(*contest) - } - - return c.JSON(resp) -} - -func (h *TesterHandlers) ListProblems(c *fiber.Ctx, params testerv1.ListProblemsParams) error { - problemsList, err := h.problemsUC.ListProblems(c.Context(), models.ProblemsFilter{ - Page: params.Page, - PageSize: params.PageSize, - }) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - resp := testerv1.ListProblemsResponse{ - Problems: make([]testerv1.ProblemsListItem, len(problemsList.Problems)), - Pagination: P2P(problemsList.Pagination), - } - - for i, problem := range problemsList.Problems { - resp.Problems[i] = PLI2PLI(*problem) - } - - return c.JSON(resp) -} - -func (h *TesterHandlers) CreateContest(c *fiber.Ctx) error { - id, err := h.contestsUC.CreateContest(c.Context(), "Название контеста") - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.JSON(testerv1.CreateContestResponse{ - Id: id, - }) -} - -func (h *TesterHandlers) DeleteContest(c *fiber.Ctx, id int32) error { - err := h.contestsUC.DeleteContest(c.Context(), id) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.SendStatus(fiber.StatusOK) -} - -func (h *TesterHandlers) GetContest(c *fiber.Ctx, id int32) error { - contest, err := h.contestsUC.ReadContestById(c.Context(), id) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - //token, ok := c.Locals(TokenKey).(*models.JWT) - //if !ok { - // return c.SendStatus(fiber.StatusUnauthorized) - //} - - tasks, err := h.contestsUC.ReadTasks(c.Context(), id) - if err != nil { - 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 { - Solution testerv1.Solution `json:"solution"` - Task testerv1.TasksListItem `json:"task"` - }, len(tasks)), - } - - 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: solution, - Task: TLI2TLI(*task), - } - } - - return c.JSON(resp) -} - -func (h *TesterHandlers) DeleteParticipant(c *fiber.Ctx, params testerv1.DeleteParticipantParams) error { - err := h.contestsUC.DeleteParticipant(c.Context(), params.ParticipantId) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.SendStatus(fiber.StatusOK) -} - -func (h *TesterHandlers) AddParticipant(c *fiber.Ctx, params testerv1.AddParticipantParams) error { - id, err := h.contestsUC.AddParticipant(c.Context(), params.ContestId, params.UserId) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.JSON(testerv1.AddParticipantResponse{ - Id: id, - }) -} - -func (h *TesterHandlers) DeleteTask(c *fiber.Ctx, id int32) error { - err := h.contestsUC.DeleteTask(c.Context(), id) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.SendStatus(fiber.StatusOK) -} - -func (h *TesterHandlers) AddTask(c *fiber.Ctx, params testerv1.AddTaskParams) error { - id, err := h.contestsUC.AddTask(c.Context(), params.ContestId, params.ProblemId) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.JSON(testerv1.AddTaskResponse{ - Id: id, - }) -} - -func (h *TesterHandlers) CreateProblem(c *fiber.Ctx) error { - id, err := h.problemsUC.CreateProblem(c.Context(), "Название задачи") - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.JSON(testerv1.CreateProblemResponse{ - Id: id, - }) -} - -func (h *TesterHandlers) DeleteProblem(c *fiber.Ctx, id int32) error { - err := h.problemsUC.DeleteProblem(c.Context(), id) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.SendStatus(fiber.StatusOK) - -} - -func (h *TesterHandlers) GetProblem(c *fiber.Ctx, id int32) error { - problem, err := h.problemsUC.ReadProblemById(c.Context(), id) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.JSON( - testerv1.GetProblemResponse{Problem: *PR2PR(problem)}, - ) -} - -func (h *TesterHandlers) ListParticipants(c *fiber.Ctx, params testerv1.ListParticipantsParams) error { - participantsList, err := h.contestsUC.ListParticipants(c.Context(), models.ParticipantsFilter{ - Page: params.Page, - PageSize: params.PageSize, - ContestId: params.ContestId, - }) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - resp := testerv1.ListParticipantsResponse{ - Participants: make([]testerv1.ParticipantsListItem, len(participantsList.Participants)), - Pagination: P2P(participantsList.Pagination), - } - - for i, participant := range participantsList.Participants { - resp.Participants[i] = PTLI2PTLI(*participant) - } - - return c.JSON(resp) -} - -func (h *TesterHandlers) UpdateProblem(c *fiber.Ctx, id int32) error { - var req testerv1.UpdateProblemRequest - err := c.BodyParser(&req) - if err != nil { - return err - } - - err = h.problemsUC.UpdateProblem(c.Context(), id, models.ProblemUpdate{ - Title: req.Title, - MemoryLimit: req.MemoryLimit, - TimeLimit: req.TimeLimit, - - Legend: req.Legend, - InputFormat: req.InputFormat, - OutputFormat: req.OutputFormat, - Notes: req.Notes, - Scoring: req.Scoring, - }) - - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.SendStatus(fiber.StatusOK) -} - -func (h *TesterHandlers) UploadProblem(c *fiber.Ctx, id int32) error { - var req testerv1.UploadProblemRequest - err := c.BodyParser(&req) - if err != nil { - return err - } - - data, err := req.Archive.Bytes() - if err != nil { - return err - } - if err = h.problemsUC.UploadProblem(c.Context(), id, data); err != nil { - return err - } - return nil -} - -func (h *TesterHandlers) UpdateContest(c *fiber.Ctx, id int32) error { - var req testerv1.UpdateContestRequest - err := c.BodyParser(&req) - if err != nil { - return err - } - - err = h.contestsUC.UpdateContest(c.Context(), id, models.ContestUpdate{ - Title: req.Title, - }) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.SendStatus(fiber.StatusOK) -} - -func (h *TesterHandlers) UpdateParticipant(c *fiber.Ctx, params testerv1.UpdateParticipantParams) error { - var req testerv1.UpdateParticipantRequest - err := c.BodyParser(&req) - if err != nil { - return err - } - - err = h.contestsUC.UpdateParticipant(c.Context(), params.ParticipantId, models.ParticipantUpdate{ - Name: req.Name, - }) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.SendStatus(fiber.StatusOK) -} - -func (h *TesterHandlers) ListSolutions(c *fiber.Ctx, params testerv1.ListSolutionsParams) error { - solutionsList, err := h.contestsUC.ListSolutions(c.Context(), models.SolutionsFilter{ - ContestId: params.ContestId, - Page: params.Page, - PageSize: params.PageSize, - ParticipantId: params.ParticipantId, - TaskId: params.TaskId, - Language: params.Language, - Order: params.Order, - State: params.State, - }) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - resp := testerv1.ListSolutionsResponse{ - Solutions: make([]testerv1.SolutionsListItem, len(solutionsList.Solutions)), - Pagination: P2P(solutionsList.Pagination), - } - - for i, solution := range solutionsList.Solutions { - resp.Solutions[i] = SLI2SLI(*solution) - } - - return c.JSON(resp) -} - -const ( - maxSolutionSize int64 = 10 * 1024 * 1024 -) - -func (h *TesterHandlers) CreateSolution(c *fiber.Ctx, params testerv1.CreateSolutionParams) error { - s, err := c.FormFile("solution") - if err != nil { - return err - } - - if s.Size == 0 || s.Size > maxSolutionSize { - return c.SendStatus(fiber.StatusBadRequest) - } - - f, err := s.Open() - if err != nil { - return err - } - defer f.Close() - - b, err := io.ReadAll(f) - if err != nil { - return err - } - - id, err := h.contestsUC.CreateSolution(c.Context(), &models.SolutionCreation{ - TaskId: params.TaskId, - ParticipantId: 1, - Language: params.Language, - Penalty: 0, - Solution: string(b), - }) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.JSON(testerv1.CreateSolutionResponse{ - Id: id, - }) -} - -func (h *TesterHandlers) GetSolution(c *fiber.Ctx, id int32) error { - solution, err := h.contestsUC.ReadSolution(c.Context(), id) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - return c.JSON( - testerv1.GetSolutionResponse{Solution: S2S(*solution)}, - ) -} - -func (h *TesterHandlers) GetTask(c *fiber.Ctx, id int32) error { - contest, err := h.contestsUC.ReadContestById(c.Context(), id) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - tasks, err := h.contestsUC.ReadTasks(c.Context(), id) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - t, err := h.contestsUC.ReadTask(c.Context(), id) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - resp := testerv1.GetTaskResponse{ - Contest: C2C(*contest), - Tasks: make([]testerv1.TasksListItem, len(tasks)), - Task: *T2T(t), - } - - for i, task := range tasks { - resp.Tasks[i] = TLI2TLI(*task) - } - - return c.JSON(resp) -} - -func (h *TesterHandlers) GetMonitor(c *fiber.Ctx, params testerv1.GetMonitorParams) error { - contest, err := h.contestsUC.ReadContestById(c.Context(), params.ContestId) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - monitor, err := h.contestsUC.ReadMonitor(c.Context(), params.ContestId) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - tasks, err := h.contestsUC.ReadTasks(c.Context(), params.ContestId) - if err != nil { - return c.SendStatus(pkg.ToREST(err)) - } - - resp := testerv1.GetMonitorResponse{ - Contest: C2C(*contest), - Tasks: make([]testerv1.TasksListItem, len(tasks)), - Participants: make([]testerv1.ParticipantsStat, len(monitor.Participants)), - SummaryPerProblem: make([]testerv1.ProblemStatSummary, len(monitor.Summary)), - } - - for i, participant := range monitor.Participants { - resp.Participants[i] = testerv1.ParticipantsStat{ - Id: participant.Id, - Name: participant.Name, - PenaltyInTotal: participant.PenaltyInTotal, - Solutions: make([]testerv1.SolutionsListItem, len(participant.Solutions)), - SolvedInTotal: participant.SolvedInTotal, - } - - for j, solution := range participant.Solutions { - resp.Participants[i].Solutions[j] = SLI2SLI(*solution) - } - } - - for i, problem := range monitor.Summary { - resp.SummaryPerProblem[i] = testerv1.ProblemStatSummary{ - Id: problem.Id, - Success: problem.Success, - Total: problem.Total, - } - } - - for i, task := range tasks { - resp.Tasks[i] = TLI2TLI(*task) - } - - return c.JSON(resp) -} - -func P2P(p models.Pagination) testerv1.Pagination { - return testerv1.Pagination{ - Page: p.Page, - Total: p.Total, - } -} - -func C2C(c models.Contest) testerv1.Contest { - return testerv1.Contest{ - Id: c.Id, - Title: c.Title, - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, - } -} - -func CLI2CLI(c models.ContestsListItem) testerv1.ContestsListItem { - return testerv1.ContestsListItem{ - Id: c.Id, - Title: c.Title, - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, - } -} - -func PLI2PLI(p models.ProblemsListItem) testerv1.ProblemsListItem { - return testerv1.ProblemsListItem{ - Id: p.Id, - Title: p.Title, - MemoryLimit: p.MemoryLimit, - TimeLimit: p.TimeLimit, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, - SolvedCount: p.SolvedCount, - } -} - -func TLI2TLI(t models.TasksListItem) testerv1.TasksListItem { - return testerv1.TasksListItem{ - Id: t.Id, - Position: t.Position, - Title: t.Title, - MemoryLimit: t.MemoryLimit, - ProblemId: t.ProblemId, - TimeLimit: t.TimeLimit, - CreatedAt: t.CreatedAt, - UpdatedAt: t.UpdatedAt, - } -} - -func T2T(t *models.Task) *testerv1.Task { - return &testerv1.Task{ - Id: t.Id, - Title: t.Title, - MemoryLimit: t.MemoryLimit, - TimeLimit: t.TimeLimit, - - InputFormatHtml: t.InputFormatHtml, - LegendHtml: t.LegendHtml, - NotesHtml: t.NotesHtml, - OutputFormatHtml: t.OutputFormatHtml, - Position: t.Position, - ScoringHtml: t.ScoringHtml, - - CreatedAt: t.CreatedAt, - UpdatedAt: t.UpdatedAt, - } -} - -func PR2PR(p *models.Problem) *testerv1.Problem { - return &testerv1.Problem{ - Id: p.Id, - Title: p.Title, - TimeLimit: p.TimeLimit, - MemoryLimit: p.MemoryLimit, - - Legend: p.Legend, - InputFormat: p.InputFormat, - OutputFormat: p.OutputFormat, - Notes: p.Notes, - Scoring: p.Scoring, - - LegendHtml: p.LegendHtml, - InputFormatHtml: p.InputFormatHtml, - OutputFormatHtml: p.OutputFormatHtml, - NotesHtml: p.NotesHtml, - ScoringHtml: p.ScoringHtml, - - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, - } -} - -func PTLI2PTLI(p models.ParticipantsListItem) testerv1.ParticipantsListItem { - return testerv1.ParticipantsListItem{ - Id: p.Id, - UserId: p.UserId, - Name: p.Name, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, - } -} - -func SLI2SLI(s models.SolutionsListItem) testerv1.SolutionsListItem { - return testerv1.SolutionsListItem{ - Id: s.Id, - - ParticipantId: s.ParticipantId, - ParticipantName: s.ParticipantName, - - State: s.State, - Score: s.Score, - Penalty: s.Penalty, - TimeStat: s.TimeStat, - MemoryStat: s.MemoryStat, - Language: s.Language, - - TaskId: s.TaskId, - TaskPosition: s.TaskPosition, - TaskTitle: s.TaskTitle, - - ContestId: s.ContestId, - ContestTitle: s.ContestTitle, - - CreatedAt: s.CreatedAt, - UpdatedAt: s.UpdatedAt, - } -} - -func S2S(s models.Solution) testerv1.Solution { - return testerv1.Solution{ - Id: s.Id, - - ParticipantId: s.ParticipantId, - ParticipantName: s.ParticipantName, - - Solution: s.Solution, - - State: s.State, - Score: s.Score, - Penalty: s.Penalty, - TimeStat: s.TimeStat, - MemoryStat: s.MemoryStat, - Language: s.Language, - - TaskId: s.TaskId, - TaskPosition: s.TaskPosition, - TaskTitle: s.TaskTitle, - - ContestId: s.ContestId, - ContestTitle: s.ContestTitle, - - CreatedAt: s.CreatedAt, - UpdatedAt: s.UpdatedAt, - } -} diff --git a/internal/tester/pg_repository.go b/internal/tester/pg_repository.go deleted file mode 100644 index 78b29cf..0000000 --- a/internal/tester/pg_repository.go +++ /dev/null @@ -1,53 +0,0 @@ -package tester - -import ( - "context" - "database/sql" - "git.sch9.ru/new_gate/ms-tester/internal/models" - "github.com/jmoiron/sqlx" -) - -type Querier interface { - Rebind(query string) string - QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) - GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error - ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) - SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error -} - -type Tx interface { - Querier - Commit() error - Rollback() error -} - -type ProblemPostgresRepository interface { - BeginTx(ctx context.Context) (Tx, error) - DB() Querier - CreateProblem(ctx context.Context, q Querier, title string) (int32, error) - ReadProblemById(ctx context.Context, q Querier, id int32) (*models.Problem, error) - DeleteProblem(ctx context.Context, q Querier, id int32) error - ListProblems(ctx context.Context, q Querier, filter models.ProblemsFilter) (*models.ProblemsList, error) - UpdateProblem(ctx context.Context, q Querier, id int32, heading models.ProblemUpdate) error -} - -type ContestRepository interface { - CreateContest(ctx context.Context, title string) (int32, error) - ReadContestById(ctx context.Context, id int32) (*models.Contest, error) - DeleteContest(ctx context.Context, id int32) error - AddTask(ctx context.Context, contestId int32, taskId int32) (int32, error) - DeleteTask(ctx context.Context, taskId int32) error - AddParticipant(ctx context.Context, contestId int32, userId int32) (int32, error) - DeleteParticipant(ctx context.Context, participantId int32) error - ReadTasks(ctx context.Context, contestId int32) ([]*models.TasksListItem, error) - ListContests(ctx context.Context, filter models.ContestsFilter) (*models.ContestsList, error) - ListParticipants(ctx context.Context, filter models.ParticipantsFilter) (*models.ParticipantsList, error) - UpdateContest(ctx context.Context, id int32, contestUpdate models.ContestUpdate) error - UpdateParticipant(ctx context.Context, id int32, participantUpdate models.ParticipantUpdate) error - ReadSolution(ctx context.Context, id int32) (*models.Solution, error) - CreateSolution(ctx context.Context, creation *models.SolutionCreation) (int32, error) - 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) -} diff --git a/internal/tester/repository/error.go b/internal/tester/repository/error.go deleted file mode 100644 index 53b33a0..0000000 --- a/internal/tester/repository/error.go +++ /dev/null @@ -1,27 +0,0 @@ -package repository - -import ( - "database/sql" - "errors" - "git.sch9.ru/new_gate/ms-tester/pkg" - "github.com/jackc/pgerrcode" - "github.com/jackc/pgx/v5/pgconn" -) - -func handlePgErr(err error, op string) error { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) { - if pgerrcode.IsIntegrityConstraintViolation(pgErr.Code) { - return pkg.Wrap(pkg.ErrBadInput, err, op, pgErr.Message) - } - if pgerrcode.IsNoData(pgErr.Code) { - return pkg.Wrap(pkg.ErrNotFound, err, op, pgErr.Message) - } - } - - if errors.Is(err, sql.ErrNoRows) { - return pkg.Wrap(pkg.ErrNotFound, err, op, "no rows found") - } - - return pkg.Wrap(pkg.ErrUnhandled, err, op, "unexpected error") -} diff --git a/internal/tester/repository/pg_contests_repository.go b/internal/tester/repository/pg_contests_repository.go deleted file mode 100644 index 94c34e2..0000000 --- a/internal/tester/repository/pg_contests_repository.go +++ /dev/null @@ -1,690 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "git.sch9.ru/new_gate/ms-tester/internal/models" - "github.com/jmoiron/sqlx" - "strings" -) - -type ContestRepository struct { - db *sqlx.DB -} - -func NewContestRepository(db *sqlx.DB) *ContestRepository { - return &ContestRepository{ - db: db, - } -} - -const createContestQuery = "INSERT INTO contests (title) VALUES (?) RETURNING id" - -func (r *ContestRepository) CreateContest(ctx context.Context, title string) (int32, error) { - const op = "ContestRepository.CreateContest" - - query := r.db.Rebind(createContestQuery) - - rows, err := r.db.QueryxContext(ctx, query, title) - if err != nil { - return 0, handlePgErr(err, op) - } - - defer rows.Close() - var id int32 - rows.Next() - err = rows.Scan(&id) - if err != nil { - return 0, handlePgErr(err, op) - } - - return id, nil -} - -const readContestByIdQuery = "SELECT * from contests WHERE id=? LIMIT 1" - -func (r *ContestRepository) ReadContestById(ctx context.Context, id int32) (*models.Contest, error) { - const op = "ContestRepository.ReadContestById" - - var contest models.Contest - query := r.db.Rebind(readContestByIdQuery) - err := r.db.GetContext(ctx, &contest, query, id) - if err != nil { - return nil, handlePgErr(err, op) - } - return &contest, nil -} - -const deleteContestQuery = "DELETE FROM contests WHERE id=?" - -func (r *ContestRepository) DeleteContest(ctx context.Context, id int32) error { - const op = "ContestRepository.DeleteContest" - - query := r.db.Rebind(deleteContestQuery) - _, err := r.db.ExecContext(ctx, query, id) - if err != nil { - return handlePgErr(err, op) - } - - return nil -} - -const addTaskQuery = `INSERT INTO tasks (problem_id, contest_id, position) -VALUES (?, ?, COALESCE((SELECT MAX(position) FROM tasks WHERE contest_id = ?), 0) + 1) -RETURNING id -` - -func (r *ContestRepository) AddTask(ctx context.Context, contestId int32, problemId int32) (int32, error) { - const op = "ContestRepository.AddTask" - - query := r.db.Rebind(addTaskQuery) - rows, err := r.db.QueryxContext(ctx, query, problemId, contestId, contestId) - if err != nil { - return 0, handlePgErr(err, op) - } - defer rows.Close() - var id int32 - rows.Next() - err = rows.Scan(&id) - if err != nil { - return 0, handlePgErr(err, op) - } - return id, nil -} - -const deleteTaskQuery = "DELETE FROM tasks WHERE id=?" - -func (r *ContestRepository) DeleteTask(ctx context.Context, taskId int32) error { - const op = "ContestRepository.DeleteTask" - - query := r.db.Rebind(deleteTaskQuery) - _, err := r.db.ExecContext(ctx, query, taskId) - if err != nil { - return handlePgErr(err, op) - } - return nil -} - -const addParticipantQuery = "INSERT INTO participants (user_id ,contest_id, name) VALUES (?, ?, ?) RETURNING id" - -func (r *ContestRepository) AddParticipant(ctx context.Context, contestId int32, userId int32) (int32, error) { - const op = "ContestRepository.AddParticipant" - - query := r.db.Rebind(addParticipantQuery) - name := "" - rows, err := r.db.QueryxContext(ctx, query, contestId, userId, name) - if err != nil { - return 0, handlePgErr(err, op) - } - defer rows.Close() - var id int32 - rows.Next() - err = rows.Scan(&id) - if err != nil { - return 0, err - } - return id, nil -} - -const deleteParticipantQuery = "DELETE FROM participants WHERE id=?" - -func (r *ContestRepository) DeleteParticipant(ctx context.Context, participantId int32) error { - const op = "ContestRepository.DeleteParticipant" - - query := r.db.Rebind(deleteParticipantQuery) - _, err := r.db.ExecContext(ctx, query, participantId) - if err != nil { - return handlePgErr(err, op) - } - return nil -} - -const readTasksQuery = `SELECT tasks.id, - problem_id, - contest_id, - position, - title, - memory_limit, - time_limit, - tasks.created_at, - tasks.updated_at -FROM tasks - INNER JOIN problems ON tasks.problem_id = problems.id -WHERE contest_id = ? ORDER BY position` - -func (r *ContestRepository) ReadTasks(ctx context.Context, contestId int32) ([]*models.TasksListItem, error) { - const op = "ContestRepository.ReadTasks" - - var tasks []*models.TasksListItem - query := r.db.Rebind(readTasksQuery) - err := r.db.SelectContext(ctx, &tasks, query, contestId) - if err != nil { - return nil, handlePgErr(err, op) - } - return tasks, nil -} - -const ( - readContestsListQuery = `SELECT id, title, created_at, updated_at FROM contests LIMIT ? OFFSET ?` - countContestsQuery = "SELECT COUNT(*) FROM contests" -) - -func (r *ContestRepository) ListContests(ctx context.Context, filter models.ContestsFilter) (*models.ContestsList, error) { - const op = "ContestRepository.ReadTasks" - - var contests []*models.ContestsListItem - query := r.db.Rebind(readContestsListQuery) - err := r.db.SelectContext(ctx, &contests, query, filter.PageSize, filter.Offset()) - if err != nil { - return nil, handlePgErr(err, op) - } - - query = r.db.Rebind(countContestsQuery) - var count int32 - err = r.db.GetContext(ctx, &count, query) - if err != nil { - return nil, handlePgErr(err, op) - } - - return &models.ContestsList{ - Contests: contests, - Pagination: models.Pagination{ - Total: models.Total(count, filter.PageSize), - Page: filter.Page, - }, - }, nil -} - -const ( - readParticipantsListQuery = `SELECT id, user_id, name, created_at, updated_at FROM participants WHERE contest_id = ? LIMIT ? OFFSET ?` - countParticipantsQuery = "SELECT COUNT(*) FROM participants WHERE contest_id = ?" -) - -func (r *ContestRepository) ListParticipants(ctx context.Context, filter models.ParticipantsFilter) (*models.ParticipantsList, error) { - const op = "ContestRepository.ReadParticipants" - - if filter.PageSize > 20 { - filter.PageSize = 1 - } - - 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 { - return nil, handlePgErr(err, op) - } - - query = r.db.Rebind(countParticipantsQuery) - var count int32 - err = r.db.GetContext(ctx, &count, query, filter.ContestId) - if err != nil { - return nil, handlePgErr(err, op) - } - - return &models.ParticipantsList{ - Participants: participants, - Pagination: models.Pagination{ - Total: models.Total(count, filter.PageSize), - Page: filter.Page, - }, - }, nil -} - -const ( - updateContestQuery = "UPDATE contests SET title = COALESCE(?, title) WHERE id = ?" -) - -func (r *ContestRepository) UpdateContest(ctx context.Context, id int32, contestUpdate models.ContestUpdate) error { - const op = "ContestRepository.UpdateContest" - - query := r.db.Rebind(updateContestQuery) - _, err := r.db.ExecContext(ctx, query, contestUpdate.Title, id) - if err != nil { - return handlePgErr(err, op) - } - - return nil -} - -const ( - updateParticipantQuery = "UPDATE participants SET name = COALESCE(?, name) WHERE id = ?" -) - -func (r *ContestRepository) UpdateParticipant(ctx context.Context, id int32, participantUpdate models.ParticipantUpdate) error { - const op = "ContestRepository.UpdateParticipant" - - query := r.db.Rebind(updateParticipantQuery) - _, err := r.db.ExecContext(ctx, query, participantUpdate.Name, id) - if err != nil { - return handlePgErr(err, op) - } - - return nil -} - -const ( - readSolutionQuery = "SELECT * FROM solutions WHERE id = ?" -) - -func (r *ContestRepository) ReadSolution(ctx context.Context, id int32) (*models.Solution, error) { - const op = "ContestRepository.ReadSolution" - - query := r.db.Rebind(readSolutionQuery) - var solution models.Solution - err := r.db.GetContext(ctx, &solution, query, id) - if err != nil { - return nil, handlePgErr(err, op) - } - - return &solution, nil -} - -const ( - createSolutionQuery = `INSERT INTO solutions (task_id, participant_id, language, penalty, solution) -VALUES (?, ?, ?, ?, ?) -RETURNING id` -) - -func (r *ContestRepository) CreateSolution(ctx context.Context, creation *models.SolutionCreation) (int32, error) { - const op = "ContestRepository.CreateSolution" - - query := r.db.Rebind(createSolutionQuery) - - rows, err := r.db.QueryxContext(ctx, - query, - creation.TaskId, - creation.ParticipantId, - creation.Language, - creation.Penalty, - creation.Solution, - ) - if err != nil { - return 0, handlePgErr(err, op) - } - - defer rows.Close() - var id int32 - rows.Next() - err = rows.Scan(&id) - if err != nil { - return 0, handlePgErr(err, op) - } - - return id, nil -} - -func (r *ContestRepository) ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error) { - const op = "ContestRepository.ListSolutions" - - baseQuery := ` -SELECT 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 -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(countQuery), args...).Scan(&totalCount) - if err != nil { - return nil, handlePgErr(err, op) - } - - 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() - - var solutions []*models.SolutionsListItem - for rows.Next() { - var solution models.SolutionsListItem - err = rows.StructScan(&solution) - if err != nil { - return nil, handlePgErr(err, op) - } - solutions = append(solutions, &solution) - } - - if err = rows.Err(); err != nil { - return nil, handlePgErr(err, op) - } - - return &models.SolutionsList{ - Solutions: solutions, - Pagination: models.Pagination{ - Total: models.Total(totalCount, filter.PageSize), - Page: filter.Page, - }, - }, nil -} - -const ( - readTaskQuery = ` - SELECT - t.id, - t.position, - p.title, - p.time_limit, - p.memory_limit, - t.problem_id, - t.contest_id, - p.legend_html, - p.input_format_html, - p.output_format_html, - p.notes_html, - p.scoring_html, - t.created_at, - t.updated_at - FROM tasks t - LEFT JOIN problems p ON t.problem_id = p.id - WHERE t.id = ? - ` -) - -func (r *ContestRepository) ReadTask(ctx context.Context, id int32) (*models.Task, error) { - const op = "ContestRepository.ReadTask" - - query := r.db.Rebind(readTaskQuery) - var task models.Task - err := r.db.GetContext(ctx, &task, query, id) - if err != nil { - return nil, handlePgErr(err, op) - } - - return &task, nil -} - -const ( - // state=5 - AC - readStatisticsQuery = ` -SELECT t.id as task_id, - t.position, - COUNT(*) as total, - COUNT(CASE WHEN s.state = 5 THEN 1 END) as success -FROM tasks t - LEFT JOIN solutions s ON t.id = s.task_id -WHERE t.contest_id = ? -GROUP BY t.id, t.position -ORDER BY t.position; -` - - solutionsQuery = ` -WITH RankedSolutions AS ( - SELECT - 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 as contest_title, - - s.updated_at, - s.created_at, - ROW_NUMBER() OVER ( - PARTITION BY s.participant_id, s.task_id - ORDER BY - CASE WHEN s.state = 5 THEN 0 ELSE 1 END, - s.created_at - ) as rn - 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 t.contest_id = ? -) -SELECT - rs.id, - - rs.participant_id, - rs.participant_name, - - rs.state, - rs.score, - rs.penalty, - rs.time_stat, - rs.memory_stat, - rs.language, - - rs.task_id, - rs.task_position, - rs.task_title, - - rs.contest_id, - rs.contest_title, - - rs.updated_at, - rs.created_at -FROM RankedSolutions rs -WHERE rs.rn = 1; - -` - - participantsQuery = ` -WITH Attempts AS ( - SELECT - s.participant_id, - s.task_id, - COUNT(*) FILTER (WHERE s.state != 5 AND s.created_at < ( - SELECT MIN(s2.created_at) - FROM solutions s2 - WHERE s2.participant_id = s.participant_id - AND s2.task_id = s.task_id - AND s2.state = 5 - )) as failed_attempts, - 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 = :contest_id - GROUP BY s.participant_id, s.task_id -) -SELECT - p.id, - 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 * :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 = :contest_id -GROUP BY p.id, p.name -` -) - -func (r *ContestRepository) ReadMonitor(ctx context.Context, contestId int32) (*models.Monitor, error) { - const op = "ContestRepository.ReadMonitor" - - query := r.db.Rebind(readStatisticsQuery) - rows, err := r.db.QueryxContext(ctx, query, contestId) - if err != nil { - return nil, handlePgErr(err, op) - } - defer rows.Close() - - var monitor models.Monitor - for rows.Next() { - var stat models.ProblemStatSummary - err = rows.StructScan(&stat) - if err != nil { - return nil, handlePgErr(err, op) - } - monitor.Summary = append(monitor.Summary, &stat) - } - - var solutions []*models.SolutionsListItem - err = r.db.SelectContext(ctx, &solutions, r.db.Rebind(solutionsQuery), contestId) - if err != nil { - return nil, handlePgErr(err, op) - } - - penalty := int32(20) // FIXME - namedQuery := r.db.Rebind(participantsQuery) - rows3, err := r.db.NamedQueryContext(ctx, namedQuery, map[string]interface{}{ - "contest_id": contestId, - "penalty": penalty, - }) - if err != nil { - return nil, handlePgErr(err, op) - } - defer rows3.Close() - - solutionsMap := make(map[int32][]*models.SolutionsListItem) - for _, solution := range solutions { - solutionsMap[solution.ParticipantId] = append(solutionsMap[solution.ParticipantId], solution) - } - - for rows3.Next() { - var stat models.ParticipantsStat - err = rows3.StructScan(&stat) - if err != nil { - return nil, handlePgErr(err, op) - } - - if sols, ok := solutionsMap[stat.Id]; ok { - stat.Solutions = sols - } - - monitor.Participants = append(monitor.Participants, &stat) - } - - 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 -} diff --git a/internal/tester/repository/pg_contests_repository_test.go b/internal/tester/repository/pg_contests_repository_test.go deleted file mode 100644 index be2cccd..0000000 --- a/internal/tester/repository/pg_contests_repository_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package repository - -import ( - "context" - "github.com/DATA-DOG/go-sqlmock" - "github.com/jmoiron/sqlx" - "github.com/stretchr/testify/require" - "go.uber.org/zap" - "testing" -) - -func TestContestRepository_CreateContest(t *testing.T) { - 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() - - contestRepo := NewContestRepository(sqlxDB, zap.NewNop()) - - t.Run("valid contest creation", func(t *testing.T) { - title := "Contest title" - - rows := sqlmock.NewRows([]string{"id"}).AddRow(1) - - mock.ExpectQuery(sqlxDB.Rebind(createContestQuery)).WithArgs(title).WillReturnRows(rows) - - id, err := contestRepo.CreateContest(context.Background(), title) - require.NoError(t, err) - require.Equal(t, int32(1), id) - }) -} - -func TestContestRepository_DeleteContest(t *testing.T) { - 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() - - contestRepo := NewContestRepository(sqlxDB, zap.NewNop()) - - t.Run("valid contest deletion", func(t *testing.T) { - id := int32(1) - rows := sqlmock.NewResult(1, 1) - - mock.ExpectExec(sqlxDB.Rebind(deleteContestQuery)).WithArgs(id).WillReturnResult(rows) - - err = contestRepo.DeleteContest(context.Background(), id) - require.NoError(t, err) - }) -} - -func TestContestRepository_AddTask(t *testing.T) { - 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() - - contestRepo := NewContestRepository(sqlxDB, zap.NewNop()) - - t.Run("valid task additional", func(t *testing.T) { - taskId := int32(1) - contestId := int32(1) - - rows := sqlmock.NewRows([]string{"id"}).AddRow(1) - - mock.ExpectQuery(sqlxDB.Rebind(addTaskQuery)).WithArgs(taskId, contestId, contestId).WillReturnRows(rows) - - id, err := contestRepo.AddTask(context.Background(), contestId, taskId) - - require.NoError(t, err) - require.Equal(t, int32(1), id) - - }) -} - -func TestContestRepository_DeleteTask(t *testing.T) { - 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() - contestRepo := NewContestRepository(sqlxDB, zap.NewNop()) - t.Run("valid task deletion", func(t *testing.T) { - id := int32(1) - rows := sqlmock.NewResult(1, 1) - - mock.ExpectExec(sqlxDB.Rebind(deleteTaskQuery)).WithArgs(id).WillReturnResult(rows) - - err = contestRepo.DeleteTask(context.Background(), id) - require.NoError(t, err) - }) -} - -func TestContestRepository_AddParticipant(t *testing.T) { - 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() - contestRepo := NewContestRepository(sqlxDB, zap.NewNop()) - - t.Run("valid participant addition", func(t *testing.T) { - contestId := int32(1) - userId := int32(1) - name := "" - - rows := sqlmock.NewRows([]string{"id"}).AddRow(1) - - mock.ExpectQuery(sqlxDB.Rebind(addParticipantQuery)).WithArgs(contestId, userId, name).WillReturnRows(rows) - - id, err := contestRepo.AddParticipant(context.Background(), contestId, userId) - - require.NoError(t, err) - require.Equal(t, int32(1), id) - }) -} - -func TestContestRepository_DeleteParticipant(t *testing.T) { - 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() - - contestRepo := NewContestRepository(sqlxDB, zap.NewNop()) - - t.Run("valid participant deletion", func(t *testing.T) { - id := int32(1) - rows := sqlmock.NewResult(1, 1) - - mock.ExpectExec(sqlxDB.Rebind(deleteParticipantQuery)).WithArgs(id).WillReturnResult(rows) - - err = contestRepo.DeleteParticipant(context.Background(), id) - require.NoError(t, err) - }) -} diff --git a/internal/tester/repository/pg_problems_repository.go b/internal/tester/repository/pg_problems_repository.go deleted file mode 100644 index d4f16ad..0000000 --- a/internal/tester/repository/pg_problems_repository.go +++ /dev/null @@ -1,184 +0,0 @@ -package repository - -import ( - "context" - - "git.sch9.ru/new_gate/ms-tester/internal/models" - "git.sch9.ru/new_gate/ms-tester/internal/tester" - "github.com/jmoiron/sqlx" -) - -type ProblemRepository struct { - _db *sqlx.DB -} - -func NewProblemRepository(db *sqlx.DB) *ProblemRepository { - return &ProblemRepository{ - _db: db, - } -} - -func (r *ProblemRepository) BeginTx(ctx context.Context) (tester.Tx, error) { - tx, err := r._db.BeginTxx(ctx, nil) - if err != nil { - return nil, err - } - - return tx, nil -} - -func (r *ProblemRepository) DB() tester.Querier { - return r._db -} - -const createProblemQuery = "INSERT INTO problems (title) VALUES (?) RETURNING id" - -func (r *ProblemRepository) CreateProblem(ctx context.Context, q tester.Querier, title string) (int32, error) { - const op = "ProblemRepository.CreateProblem" - - query := q.Rebind(createProblemQuery) - rows, err := q.QueryxContext(ctx, query, title) - if err != nil { - return 0, handlePgErr(err, op) - } - - defer rows.Close() - var id int32 - rows.Next() - err = rows.Scan(&id) - if err != nil { - return 0, handlePgErr(err, op) - } - - return id, nil -} - -const readProblemQuery = "SELECT * from problems WHERE id=? LIMIT 1" - -func (r *ProblemRepository) ReadProblemById(ctx context.Context, q tester.Querier, id int32) (*models.Problem, error) { - const op = "ProblemRepository.ReadProblemById" - - var problem models.Problem - query := q.Rebind(readProblemQuery) - err := q.GetContext(ctx, &problem, query, id) - if err != nil { - return nil, handlePgErr(err, op) - } - - return &problem, nil -} - -const deleteProblemQuery = "DELETE FROM problems WHERE id=?" - -func (r *ProblemRepository) DeleteProblem(ctx context.Context, q tester.Querier, id int32) error { - const op = "ProblemRepository.DeleteProblem" - - query := q.Rebind(deleteProblemQuery) - _, err := q.ExecContext(ctx, query, id) - if err != nil { - return handlePgErr(err, op) - } - - return nil -} - -const ( - ListProblemsQuery = ` - SELECT - p.id,p.title,p.memory_limit,p.time_limit,p.created_at,p.updated_at, - COALESCE(solved_count, 0) AS solved_count - FROM problems p - LEFT JOIN ( - SELECT - t.problem_id, - COUNT(DISTINCT s.participant_id) AS solved_count - FROM solutions s - JOIN tasks t ON s.task_id = t.id - WHERE s.state = 5 - GROUP BY t.problem_id - ) sol ON p.id = sol.problem_id - LIMIT ? OFFSET ?` - CountProblemsQuery = "SELECT COUNT(*) FROM problems" -) - -func (r *ProblemRepository) ListProblems(ctx context.Context, q tester.Querier, filter models.ProblemsFilter) (*models.ProblemsList, error) { - const op = "ContestRepository.ListProblems" - - if filter.PageSize > 20 || filter.PageSize < 1 { - filter.PageSize = 1 - } - - var problems []*models.ProblemsListItem - query := q.Rebind(ListProblemsQuery) - err := q.SelectContext(ctx, &problems, query, filter.PageSize, filter.Offset()) - if err != nil { - return nil, handlePgErr(err, op) - } - - query = q.Rebind(CountProblemsQuery) - - var count int32 - err = q.GetContext(ctx, &count, query) - if err != nil { - return nil, handlePgErr(err, op) - } - - return &models.ProblemsList{ - Problems: problems, - Pagination: models.Pagination{ - Total: models.Total(count, filter.PageSize), - Page: filter.Page, - }, - }, nil -} - -const ( - UpdateProblemQuery = `UPDATE problems -SET title = COALESCE(?, title), - time_limit = COALESCE(?, time_limit), - memory_limit = COALESCE(?, memory_limit), - - legend = COALESCE(?, legend), - input_format = COALESCE(?, input_format), - output_format = COALESCE(?, output_format), - notes = COALESCE(?, notes), - scoring = COALESCE(?, scoring), - - legend_html = COALESCE(?, legend_html), - input_format_html = COALESCE(?, input_format_html), - output_format_html = COALESCE(?, output_format_html), - notes_html = COALESCE(?, notes_html), - scoring_html = COALESCE(?, scoring_html) - -WHERE id=?` -) - -func (r *ProblemRepository) UpdateProblem(ctx context.Context, q tester.Querier, id int32, problem models.ProblemUpdate) error { - const op = "ProblemRepository.UpdateProblem" - - query := q.Rebind(UpdateProblemQuery) - _, err := q.ExecContext(ctx, query, - problem.Title, - problem.TimeLimit, - problem.MemoryLimit, - - problem.Legend, - problem.InputFormat, - problem.OutputFormat, - problem.Notes, - problem.Scoring, - - problem.LegendHtml, - problem.InputFormatHtml, - problem.OutputFormatHtml, - problem.NotesHtml, - problem.ScoringHtml, - - id, - ) - if err != nil { - return handlePgErr(err, op) - } - - return nil -} diff --git a/internal/tester/repository/pg_problems_repository_test.go b/internal/tester/repository/pg_problems_repository_test.go deleted file mode 100644 index 76d67cf..0000000 --- a/internal/tester/repository/pg_problems_repository_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package repository - -import ( - "context" - "github.com/DATA-DOG/go-sqlmock" - "github.com/jmoiron/sqlx" - "github.com/stretchr/testify/require" - "go.uber.org/zap" - "testing" -) - -func TestProblemRepository_CreateProblem(t *testing.T) { - 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 creation", func(t *testing.T) { - title := "Problem title" - - rows := sqlmock.NewRows([]string{"id"}).AddRow(1) - - mock.ExpectQuery(sqlxDB.Rebind(createProblemQuery)).WithArgs(title).WillReturnRows(rows) - - id, err := problemRepo.CreateProblem(context.Background(), title) - require.NoError(t, err) - require.Equal(t, int32(1), id) - }) -} - -func TestProblemRepository_DeleteProblem(t *testing.T) { - 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) - - mock.ExpectExec(sqlxDB.Rebind(deleteProblemQuery)).WithArgs(id).WillReturnResult(rows) - - err = problemRepo.DeleteProblem(context.Background(), id) - require.NoError(t, err) - }) -} diff --git a/internal/tester/usecase.go b/internal/tester/usecase.go deleted file mode 100644 index a9088a2..0000000 --- a/internal/tester/usecase.go +++ /dev/null @@ -1,37 +0,0 @@ -package tester - -import ( - "context" - - "git.sch9.ru/new_gate/ms-tester/internal/models" -) - -type ProblemUseCase interface { - CreateProblem(ctx context.Context, title string) (int32, error) - ReadProblemById(ctx context.Context, id int32) (*models.Problem, error) - DeleteProblem(ctx context.Context, id int32) error - ListProblems(ctx context.Context, filter models.ProblemsFilter) (*models.ProblemsList, error) - UpdateProblem(ctx context.Context, id int32, problem models.ProblemUpdate) error - UploadProblem(ctx context.Context, id int32, archive []byte) error -} - -type ContestUseCase interface { - CreateContest(ctx context.Context, title string) (int32, error) - ReadContestById(ctx context.Context, id int32) (*models.Contest, error) - DeleteContest(ctx context.Context, id int32) error - AddTask(ctx context.Context, contestId int32, taskId int32) (int32, error) - DeleteTask(ctx context.Context, taskId int32) error - AddParticipant(ctx context.Context, contestId int32, userId int32) (int32, error) - DeleteParticipant(ctx context.Context, participantId int32) error - ReadTasks(ctx context.Context, contestId int32) ([]*models.TasksListItem, error) - ListContests(ctx context.Context, filter models.ContestsFilter) (*models.ContestsList, error) - ListParticipants(ctx context.Context, filter models.ParticipantsFilter) (*models.ParticipantsList, error) - UpdateContest(ctx context.Context, id int32, contestUpdate models.ContestUpdate) error - UpdateParticipant(ctx context.Context, id int32, participantUpdate models.ParticipantUpdate) error - ReadSolution(ctx context.Context, id int32) (*models.Solution, error) - CreateSolution(ctx context.Context, creation *models.SolutionCreation) (int32, error) - 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) -} diff --git a/internal/tester/usecase/contests_usecase.go b/internal/tester/usecase/contests_usecase.go deleted file mode 100644 index 28761dd..0000000 --- a/internal/tester/usecase/contests_usecase.go +++ /dev/null @@ -1,91 +0,0 @@ -package usecase - -import ( - "context" - "git.sch9.ru/new_gate/ms-tester/internal/models" - "git.sch9.ru/new_gate/ms-tester/internal/tester" -) - -type ContestUseCase struct { - contestRepo tester.ContestRepository -} - -func NewContestUseCase( - contestRepo tester.ContestRepository, -) *ContestUseCase { - return &ContestUseCase{ - contestRepo: contestRepo, - } -} - -func (uc *ContestUseCase) CreateContest(ctx context.Context, title string) (int32, error) { - return uc.contestRepo.CreateContest(ctx, title) -} - -func (uc *ContestUseCase) ReadContestById(ctx context.Context, id int32) (*models.Contest, error) { - return uc.contestRepo.ReadContestById(ctx, id) -} - -func (uc *ContestUseCase) DeleteContest(ctx context.Context, id int32) error { - return uc.contestRepo.DeleteContest(ctx, id) -} - -func (uc *ContestUseCase) AddTask(ctx context.Context, contestId int32, taskId int32) (id int32, err error) { - return uc.contestRepo.AddTask(ctx, contestId, taskId) -} - -func (uc *ContestUseCase) DeleteTask(ctx context.Context, taskId int32) error { - return uc.contestRepo.DeleteTask(ctx, taskId) -} - -func (uc *ContestUseCase) AddParticipant(ctx context.Context, contestId int32, userId int32) (id int32, err error) { - return uc.contestRepo.AddParticipant(ctx, contestId, userId) -} - -func (uc *ContestUseCase) DeleteParticipant(ctx context.Context, participantId int32) error { - return uc.contestRepo.DeleteParticipant(ctx, participantId) -} - -func (uc *ContestUseCase) ReadTasks(ctx context.Context, contestId int32) ([]*models.TasksListItem, error) { - return uc.contestRepo.ReadTasks(ctx, contestId) -} - -func (uc *ContestUseCase) ListContests(ctx context.Context, filter models.ContestsFilter) (*models.ContestsList, error) { - return uc.contestRepo.ListContests(ctx, filter) -} - -func (uc *ContestUseCase) ListParticipants(ctx context.Context, filter models.ParticipantsFilter) (*models.ParticipantsList, error) { - return uc.contestRepo.ListParticipants(ctx, filter) -} - -func (uc *ContestUseCase) UpdateContest(ctx context.Context, id int32, contestUpdate models.ContestUpdate) error { - return uc.contestRepo.UpdateContest(ctx, id, contestUpdate) -} - -func (uc *ContestUseCase) UpdateParticipant(ctx context.Context, id int32, participantUpdate models.ParticipantUpdate) error { - return uc.contestRepo.UpdateParticipant(ctx, id, participantUpdate) -} - -func (uc *ContestUseCase) ReadSolution(ctx context.Context, id int32) (*models.Solution, error) { - return uc.contestRepo.ReadSolution(ctx, id) -} - -func (uc *ContestUseCase) CreateSolution(ctx context.Context, creation *models.SolutionCreation) (int32, error) { - return uc.contestRepo.CreateSolution(ctx, creation) -} - -func (uc *ContestUseCase) ListSolutions(ctx context.Context, filter models.SolutionsFilter) (*models.SolutionsList, error) { - return uc.contestRepo.ListSolutions(ctx, filter) -} - -func (uc *ContestUseCase) ReadTask(ctx context.Context, id int32) (*models.Task, error) { - return uc.contestRepo.ReadTask(ctx, id) -} - -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) -} diff --git a/internal/users/delivery.go b/internal/users/delivery.go new file mode 100644 index 0000000..4ef4149 --- /dev/null +++ b/internal/users/delivery.go @@ -0,0 +1,14 @@ +package users + +import ( + testerv1 "git.sch9.ru/new_gate/ms-tester/contracts/tester/v1" + "github.com/gofiber/fiber/v2" +) + +type UsersHandlers interface { + ListUsers(c *fiber.Ctx, params testerv1.ListUsersParams) error + CreateUser(c *fiber.Ctx) error + DeleteUser(c *fiber.Ctx, id int32) error + GetUser(c *fiber.Ctx, id int32) error + UpdateUser(c *fiber.Ctx, id int32) error +} diff --git a/internal/users/delivery/rest/handlers.go b/internal/users/delivery/rest/handlers.go new file mode 100644 index 0000000..b15b60a --- /dev/null +++ b/internal/users/delivery/rest/handlers.go @@ -0,0 +1,204 @@ +package rest + +import ( + "context" + 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/users" + "git.sch9.ru/new_gate/ms-tester/pkg" + "github.com/gofiber/fiber/v2" +) + +type Handlers struct { + usersUC users.UseCase +} + +func NewHandlers(usersUC users.UseCase) *Handlers { + return &Handlers{ + usersUC: usersUC, + } +} + +const ( + sessionKey = "session" +) + +func sessionFromCtx(ctx context.Context) (*models.Session, error) { + const op = "sessionFromCtx" + + session, ok := ctx.Value(sessionKey).(*models.Session) + if !ok { + return nil, pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "") + } + + return session, nil +} + +func (h *Handlers) CreateUser(c *fiber.Ctx) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + var req = &testerv1.CreateUserRequest{} + err := c.BodyParser(req) + if err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + id, err := h.usersUC.CreateUser(ctx, + &models.UserCreation{ + Username: req.Username, + Password: req.Password, + Role: models.RoleStudent, + }, + ) + + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(testerv1.CreateUserResponse{Id: id}) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) GetUser(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher, models.RoleStudent: + user, err := h.usersUC.ReadUserById(c.Context(), id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.JSON(testerv1.GetUserResponse{ + User: UserDTO(*user), + }) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) UpdateUser(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin: + var req = &testerv1.UpdateUserRequest{} + err := c.BodyParser(req) + if err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + err = h.usersUC.UpdateUser(c.Context(), id, &models.UserUpdate{ + Username: req.Username, + Role: RoleDTO(req.Role), + }) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) DeleteUser(c *fiber.Ctx, id int32) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin: + ctx := c.Context() + + err := h.usersUC.DeleteUser(ctx, id) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + return c.SendStatus(fiber.StatusOK) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func (h *Handlers) ListUsers(c *fiber.Ctx, params testerv1.ListUsersParams) error { + ctx := c.Context() + + session, err := sessionFromCtx(ctx) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + switch session.Role { + case models.RoleAdmin, models.RoleTeacher: + usersList, err := h.usersUC.ListUsers(c.Context(), models.UsersListFilters{ + PageSize: params.PageSize, + Page: params.Page, + }) + if err != nil { + return c.SendStatus(pkg.ToREST(err)) + } + + resp := testerv1.ListUsersResponse{ + Users: make([]testerv1.User, len(usersList.Users)), + Pagination: PaginationDTO(usersList.Pagination), + } + + for i, user := range usersList.Users { + resp.Users[i] = UserDTO(*user) + } + + return c.JSON(resp) + default: + return c.SendStatus(pkg.ToREST(pkg.NoPermission)) + } +} + +func RoleDTO(i *int32) *models.Role { + if i == nil { + return nil + } + ii := models.Role(*i) + return &ii +} + +func PaginationDTO(p models.Pagination) testerv1.Pagination { + return testerv1.Pagination{ + Page: p.Page, + Total: p.Total, + } +} + +// UserDTO sanitizes password +func UserDTO(u models.User) testerv1.User { + return testerv1.User{ + Id: u.Id, + Username: u.Username, + Role: int32(u.Role), + CreatedAt: u.CreatedAt, + ModifiedAt: u.UpdatedAt, + } +} diff --git a/internal/users/pg_repository.go b/internal/users/pg_repository.go new file mode 100644 index 0000000..63506f0 --- /dev/null +++ b/internal/users/pg_repository.go @@ -0,0 +1,33 @@ +package users + +import ( + "context" + "database/sql" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "github.com/jmoiron/sqlx" +) + +type Querier interface { + Rebind(query string) string + QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) + GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error +} + +type Tx interface { + Querier + Commit() error + Rollback() error +} + +type Repository interface { + BeginTx(ctx context.Context) (Tx, error) + DB() Querier + CreateUser(ctx context.Context, q Querier, user *models.UserCreation) (int32, error) + ReadUserByUsername(ctx context.Context, q Querier, username string) (*models.User, error) + ReadUserById(ctx context.Context, q Querier, id int32) (*models.User, error) + UpdateUser(ctx context.Context, q Querier, id int32, update *models.UserUpdate) error + DeleteUser(ctx context.Context, q Querier, id int32) error + ListUsers(ctx context.Context, q Querier, filters models.UsersListFilters) (*models.UsersList, error) +} diff --git a/internal/users/repository/pg_repository.go b/internal/users/repository/pg_repository.go new file mode 100644 index 0000000..e314c7e --- /dev/null +++ b/internal/users/repository/pg_repository.go @@ -0,0 +1,156 @@ +package repository + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/internal/users" + "git.sch9.ru/new_gate/ms-tester/pkg" + "github.com/jmoiron/sqlx" +) + +type Repository struct { + _db *sqlx.DB +} + +func NewRepository(db *sqlx.DB) *Repository { + return &Repository{ + _db: db, + } +} + +func (r *Repository) BeginTx(ctx context.Context) (users.Tx, error) { + tx, err := r._db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + + return tx, nil +} + +func (r *Repository) DB() users.Querier { + return r._db +} + +const CreateUserQuery = ` +INSERT INTO users + (username, hashed_pwd, role) +VALUES ($1, $2, $3) +RETURNING id +` + +func (r *Repository) CreateUser(ctx context.Context, q users.Querier, user *models.UserCreation) (int32, error) { + const op = "Caller.CreateUser" + + rows, err := q.QueryxContext( + ctx, + CreateUserQuery, + user.Username, + user.Password, + user.Role, + ) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + + defer rows.Close() + var id int32 + rows.Next() + err = rows.Scan(&id) + if err != nil { + return 0, pkg.HandlePgErr(err, op) + } + + return id, nil +} + +const ReadUserByUsernameQuery = "SELECT * from users WHERE username=$1 LIMIT 1" + +func (r *Repository) ReadUserByUsername(ctx context.Context, q users.Querier, username string) (*models.User, error) { + const op = "Caller.ReadUserByUsername" + + var user models.User + err := q.GetContext(ctx, &user, ReadUserByUsernameQuery, username) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + return &user, nil +} + +const ReadUserByIdQuery = "SELECT * from users WHERE id=$1 LIMIT 1" + +func (r *Repository) ReadUserById(ctx context.Context, q users.Querier, id int32) (*models.User, error) { + const op = "Caller.ReadUserById" + + var user models.User + err := q.GetContext(ctx, &user, ReadUserByIdQuery, id) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + return &user, nil +} + +const UpdateUserQuery = ` +UPDATE users +SET username = COALESCE($1, username), + role = COALESCE($2, role) +WHERE id = $3 +` + +func (r *Repository) UpdateUser(ctx context.Context, q users.Querier, id int32, update *models.UserUpdate) error { + const op = "Caller.UpdateUser" + + _, err := q.ExecContext( + ctx, + UpdateUserQuery, + update.Username, + update.Role, + id, + ) + + if err != nil { + return pkg.HandlePgErr(err, op) + } + return nil +} + +const DeleteUserQuery = "DELETE FROM users WHERE id = $1" + +func (r *Repository) DeleteUser(ctx context.Context, q users.Querier, id int32) error { + const op = "Caller.DeleteUser" + + _, err := q.ExecContext(ctx, DeleteUserQuery, id) + if err != nil { + return pkg.HandlePgErr(err, op) + } + + return nil +} + +const ( + ListUsersQuery = "SELECT * FROM users LIMIT $1 OFFSET $2" + CountUsersQuery = "SELECT COUNT(*) FROM users" +) + +func (r *Repository) ListUsers(ctx context.Context, q users.Querier, filters models.UsersListFilters) (*models.UsersList, error) { + const op = "Caller.ListUsers" + + list := make([]*models.User, 0) + err := q.SelectContext(ctx, &list, ListUsersQuery, filters.PageSize, filters.Offset()) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + var count int32 + err = q.GetContext(ctx, &count, CountUsersQuery) + if err != nil { + return nil, pkg.HandlePgErr(err, op) + } + + return &models.UsersList{ + Users: list, + Pagination: models.Pagination{ + Total: models.Total(count, filters.PageSize), + Page: filters.Page, + }, + }, nil +} diff --git a/internal/users/repository/pg_repository_test.go b/internal/users/repository/pg_repository_test.go new file mode 100644 index 0000000..caf3a49 --- /dev/null +++ b/internal/users/repository/pg_repository_test.go @@ -0,0 +1,222 @@ +package repository_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/internal/users/repository" + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" +) + +// setupTestDB creates a mocked sqlx.DB and sqlmock instance for testing. +func setupTestDB(t *testing.T) (*sqlx.DB, sqlmock.Sqlmock) { + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + assert.NoError(t, err) + sqlxDB := sqlx.NewDb(db, "sqlmock") + return sqlxDB, mock +} + +func TestRepository_CreateUser(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + var expectedId int32 = 1 + user := &models.UserCreation{ + Username: "testuser", + Password: "hashed-password", + Role: models.RoleAdmin, + } + + mock.ExpectQuery(repository.CreateUserQuery). + WithArgs(user.Username, sqlmock.AnyArg(), user.Role). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(expectedId)) + + id, err := repo.CreateUser(ctx, db, user) + assert.NoError(t, err) + assert.Equal(t, expectedId, id) + }) +} + +func TestRepository_ReadUserByUsername(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + expected := &models.User{ + Id: 1, + Username: "testuser", + HashedPassword: "hashed-password", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Role: models.RoleAdmin, + } + + columns := []string{ + "id", + "username", + "hashed_pwd", + "created_at", + "updated_at", + "role", + } + + rows := sqlmock.NewRows(columns).AddRow( + expected.Id, + expected.Username, + expected.HashedPassword, + expected.CreatedAt, + expected.UpdatedAt, + expected.Role, + ) + + mock.ExpectQuery(repository.ReadUserByUsernameQuery).WithArgs(expected.Username).WillReturnRows(rows) + + user, err := repo.ReadUserByUsername(ctx, db, expected.Username) + assert.NoError(t, err) + assert.Equal(t, expected, user) + }) + + t.Run("not found", func(t *testing.T) { + ctx := context.Background() + + username := "testuser" + + mock.ExpectQuery(repository.ReadUserByUsernameQuery).WithArgs(username).WillReturnError(sql.ErrNoRows) + + user, err := repo.ReadUserByUsername(ctx, db, username) + assert.Error(t, err) + assert.Nil(t, user) + }) +} + +func TestRepository_ReadUserById(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + expected := &models.User{ + Id: 1, + Username: "testuser", + Role: models.RoleAdmin, + } + + mock.ExpectQuery(repository.ReadUserByIdQuery). + WithArgs(expected.Id). + WillReturnRows(sqlmock.NewRows([]string{"id", "username", "role"}). + AddRow(expected.Id, expected.Username, expected.Role)) + + user, err := repo.ReadUserById(ctx, db, expected.Id) + assert.NoError(t, err) + assert.Equal(t, expected, user) + }) + + t.Run("not found", func(t *testing.T) { + ctx := context.Background() + + userID := int32(1) + + mock.ExpectQuery(repository.ReadUserByIdQuery).WithArgs(userID).WillReturnError(sql.ErrNoRows) + + user, err := repo.ReadUserById(ctx, db, userID) + assert.Error(t, err) + assert.Nil(t, user) + }) +} + +func TestRepository_UpdateUser(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + userID := int32(1) + username := "testuser" + role := models.RoleStudent + update := &models.UserUpdate{ + Username: &username, + Role: &role, + } + + mock.ExpectExec(repository.UpdateUserQuery). + WithArgs(update.Username, update.Role, userID). + WillReturnResult(sqlmock.NewResult(0, 1)) + + err := repo.UpdateUser(ctx, db, userID, update) + assert.NoError(t, err) + }) +} + +func TestRepository_DeleteUser(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + userID := int32(1) + + mock.ExpectExec(repository.DeleteUserQuery). + WithArgs(userID). + WillReturnResult(sqlmock.NewResult(0, 1)) + + err := repo.DeleteUser(ctx, db, userID) + assert.NoError(t, err) + }) +} + +func TestRepository_ListUsers(t *testing.T) { + db, mock := setupTestDB(t) + defer db.Close() + + repo := repository.NewRepository(db) + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + + filters := models.UsersListFilters{ + Page: 1, + PageSize: 10, + } + expectedUsers := []*models.User{ + {Id: 1, Username: "user1", Role: models.RoleAdmin}, + {Id: 2, Username: "user2", Role: models.RoleStudent}, + } + totalCount := int32(2) + + mock.ExpectQuery(repository.ListUsersQuery). + WithArgs(filters.PageSize, filters.Offset()). + WillReturnRows(sqlmock.NewRows([]string{"id", "username", "role"}). + AddRow(expectedUsers[0].Id, expectedUsers[0].Username, expectedUsers[0].Role). + AddRow(expectedUsers[1].Id, expectedUsers[1].Username, expectedUsers[1].Role)) + + mock.ExpectQuery(repository.CountUsersQuery). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(totalCount)) + + result, err := repo.ListUsers(ctx, db, filters) + assert.NoError(t, err) + assert.Equal(t, expectedUsers, result.Users) + assert.Equal(t, models.Pagination{Total: 1, Page: 1}, result.Pagination) + }) +} diff --git a/internal/users/usecase.go b/internal/users/usecase.go new file mode 100644 index 0000000..a132637 --- /dev/null +++ b/internal/users/usecase.go @@ -0,0 +1,15 @@ +package users + +import ( + "context" + "git.sch9.ru/new_gate/ms-tester/internal/models" +) + +type UseCase interface { + CreateUser(ctx context.Context, user *models.UserCreation) (int32, error) + ReadUserById(ctx context.Context, id int32) (*models.User, error) + ReadUserByUsername(ctx context.Context, username string) (*models.User, error) + UpdateUser(ctx context.Context, id int32, update *models.UserUpdate) error + DeleteUser(ctx context.Context, id int32) error + ListUsers(ctx context.Context, filters models.UsersListFilters) (*models.UsersList, error) +} diff --git a/internal/users/usecase/usecase.go b/internal/users/usecase/usecase.go new file mode 100644 index 0000000..1b28928 --- /dev/null +++ b/internal/users/usecase/usecase.go @@ -0,0 +1,164 @@ +package usecase + +import ( + "context" + "errors" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/internal/sessions" + "git.sch9.ru/new_gate/ms-tester/internal/users" + "git.sch9.ru/new_gate/ms-tester/pkg" +) + +type UsersUC struct { + sessionRepo sessions.ValkeyRepository + usersRepo users.Repository +} + +func NewUseCase(sessionRepo sessions.ValkeyRepository, usersRepo users.Repository) *UsersUC { + return &UsersUC{ + sessionRepo: sessionRepo, + usersRepo: usersRepo, + } +} + +func (u *UsersUC) CreateUser(ctx context.Context, user *models.UserCreation) (int32, error) { + const op = "UseCase.CreateUser" + + err := user.HashPassword() + if err != nil { + return 0, pkg.Wrap(pkg.ErrBadInput, err, op, "bad password") + } + + id, err := u.usersRepo.CreateUser(ctx, u.usersRepo.DB(), user) + if err != nil { + return 0, pkg.Wrap(nil, err, op, "can't create user") + } + + return id, nil +} + +func (u *UsersUC) ListUsers(ctx context.Context, filters models.UsersListFilters) (*models.UsersList, error) { + const op = "UseCase.ListUsers" + + usersList, err := u.usersRepo.ListUsers(ctx, u.usersRepo.DB(), filters) + if err != nil { + return nil, pkg.Wrap(nil, err, op, "can't list users") + } + + return usersList, nil +} + +func (u *UsersUC) UpdateUser(ctx context.Context, id int32, update *models.UserUpdate) error { + const op = "UseCase.UpdateUser" + + tx, err := u.usersRepo.BeginTx(ctx) + if err != nil { + return pkg.Wrap(nil, err, op, "cannot start transaction") + } + + err = u.usersRepo.UpdateUser(ctx, tx, id, update) + if err != nil { + return pkg.Wrap(nil, errors.Join(err, tx.Rollback()), op, "cannot update user") + } + err = u.sessionRepo.DeleteAllSessions(ctx, id) + if err != nil { + return pkg.Wrap(nil, errors.Join(err, tx.Rollback()), op, "cannot delete all sessions") + } + err = tx.Commit() + if err != nil { + return pkg.Wrap(nil, err, op, "cannot commit transaction") + } + + return nil +} + +// ReadUserByUsername is for login only. There are no permission checks! DO NOT USE IT AS AN ENDPOINT RESPONSE! +func (u *UsersUC) ReadUserByUsername(ctx context.Context, username string) (*models.User, error) { + const op = "UseCase.ReadUserByUsername" + + user, err := u.usersRepo.ReadUserByUsername(ctx, u.usersRepo.DB(), username) + if err != nil { + return nil, pkg.Wrap(nil, err, op, "can't read user by username") + } + return user, nil +} + +func (u *UsersUC) ReadUserById(ctx context.Context, id int32) (*models.User, error) { + const op = "UseCase.ReadUserById" + + user, err := u.usersRepo.ReadUserById(ctx, u.usersRepo.DB(), id) + if err != nil { + return nil, pkg.Wrap(nil, err, op, "can't read user by id") + } + return user, nil +} + +func (u *UsersUC) DeleteUser(ctx context.Context, id int32) error { + const op = "UseCase.DeleteUser" + + tx, err := u.usersRepo.BeginTx(ctx) + if err != nil { + return pkg.Wrap(nil, err, op, "cannot start transaction") + } + + err = u.usersRepo.DeleteUser(ctx, tx, id) + if err != nil { + return pkg.Wrap(nil, errors.Join(err, tx.Rollback()), op, "cannot delete user") + } + + err = u.sessionRepo.DeleteAllSessions(ctx, id) + if err != nil { + return pkg.Wrap(nil, errors.Join(err, tx.Rollback()), op, "cannot delete all sessions") + } + err = tx.Commit() + if err != nil { + return pkg.Wrap(nil, err, op, "cannot commit transaction") + } + + return nil +} + +/* +func ValidEmail(str string) error { + emailAddress, err := mail.ParseAddress(str) + if err != nil || emailAddress.Address != str { + return errors.New("invalid email") + } + return nil +} + +func ValidUsername(str string) error { + if len(str) < 5 { + return errors.New("too short username") + } + if len(str) > 70 { + return errors.New("too long username") + } + if err := ValidEmail(str); err == nil { + return errors.New("username cannot be an email") + } + return nil +} + +func ValidPassword(str string) error { + if len(str) < 5 { + return errors.New("too short password") + } + if len(str) > 70 { + return errors.New("too long password") + } + return nil +} + +func ValidRole(role models.Role) error { + switch role { + case models.RoleAdmin: + return nil + case models.RoleTeacher: + return nil + case models.RoleStudent: + return nil + } + return errors.New("invalid role") +} +*/ diff --git a/main.go b/main.go index ce4db72..6f29a89 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,29 @@ package main import ( + "context" "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/internal/auth" + authHandlers "git.sch9.ru/new_gate/ms-tester/internal/auth/delivery/rest" + authUseCase "git.sch9.ru/new_gate/ms-tester/internal/auth/usecase" + "git.sch9.ru/new_gate/ms-tester/internal/contests" + contestsHandlers "git.sch9.ru/new_gate/ms-tester/internal/contests/delivery/rest" + contestsRepository "git.sch9.ru/new_gate/ms-tester/internal/contests/repository" + contestsUseCase "git.sch9.ru/new_gate/ms-tester/internal/contests/usecase" + "git.sch9.ru/new_gate/ms-tester/internal/middleware" + "git.sch9.ru/new_gate/ms-tester/internal/models" + "git.sch9.ru/new_gate/ms-tester/internal/problems" + problemsHandlers "git.sch9.ru/new_gate/ms-tester/internal/problems/delivery/rest" + problemsRepository "git.sch9.ru/new_gate/ms-tester/internal/problems/repository" + problemsUseCase "git.sch9.ru/new_gate/ms-tester/internal/problems/usecase" + sessionsRepository "git.sch9.ru/new_gate/ms-tester/internal/sessions/repository" + sessionsUseCase "git.sch9.ru/new_gate/ms-tester/internal/sessions/usecase" + "git.sch9.ru/new_gate/ms-tester/internal/users" + usersHandlers "git.sch9.ru/new_gate/ms-tester/internal/users/delivery/rest" + usersRepository "git.sch9.ru/new_gate/ms-tester/internal/users/repository" + usersUseCase "git.sch9.ru/new_gate/ms-tester/internal/users/usecase" "git.sch9.ru/new_gate/ms-tester/pkg" "github.com/gofiber/fiber/v2" fiberlogger "github.com/gofiber/fiber/v2/middleware/logger" @@ -42,20 +59,60 @@ func main() { defer db.Close() logger.Info("successfully connected to postgres") + logger.Info("connecting to redis") + vk, err := pkg.NewValkeyClient(cfg.RedisDSN) + if err != nil { + logger.Fatal(fmt.Sprintf("error connecting to redis: %s", err.Error())) + } + logger.Info("successfully connected to redis") + + usersRepo := usersRepository.NewRepository(db) + + _, err = usersRepo.CreateUser(context.Background(), + usersRepo.DB(), &models.UserCreation{ + Username: cfg.AdminUsername, + Password: cfg.AdminPassword, + Role: models.RoleAdmin, + }) + if err != nil { + logger.Error(fmt.Sprintf("error creating admin user: %s", err.Error())) + } + + sessionsRepo := sessionsRepository.NewValkeyRepository(vk) + sessionsUC := sessionsUseCase.NewUseCase(sessionsRepo, cfg) + + usersUC := usersUseCase.NewUseCase(sessionsRepo, usersRepo) + + authUC := authUseCase.NewUseCase(usersUC, sessionsUC) + pandocClient := pkg.NewPandocClient(&http.Client{}, cfg.Pandoc) - problemRepo := problemsRepository.NewProblemRepository(db) - problemUC := testerUseCase.NewProblemUseCase(problemRepo, pandocClient) + problemsRepo := problemsRepository.NewRepository(db) + problemsUC := problemsUseCase.NewUseCase(problemsRepo, pandocClient) - contestRepo := problemsRepository.NewContestRepository(db) - contestUC := testerUseCase.NewContestUseCase(contestRepo) + contestsRepo := contestsRepository.NewRepository(db) + contestsUC := contestsUseCase.NewContestUseCase(contestsRepo) server := fiber.New() - testerv1.RegisterHandlersWithOptions(server, rest.NewTesterHandlers(problemUC, contestUC), testerv1.FiberServerOptions{ + type MergedHandlers struct { + users.UsersHandlers + auth.AuthHandlers + contests.ContestsHandlers + problems.ProblemsHandlers + } + + merged := MergedHandlers{ + usersHandlers.NewHandlers(usersUC), + authHandlers.NewHandlers(authUC, cfg.JWTSecret), + contestsHandlers.NewHandlers(problemsUC, contestsUC, cfg.JWTSecret), + problemsHandlers.NewHandlers(problemsUC), + } + + testerv1.RegisterHandlersWithOptions(server, merged, testerv1.FiberServerOptions{ Middlewares: []testerv1.MiddlewareFunc{ fiberlogger.New(), - rest.AuthMiddleware(cfg.JWTSecret), + middleware.AuthMiddleware(cfg.JWTSecret, sessionsUC), //rest.AuthMiddleware(cfg.JWTSecret, userUC), //cors.New(cors.Config{ // AllowOrigins: "http://localhost:3000", diff --git a/migrations/20240727123308_initial.sql b/migrations/20240727123308_initial.sql index 87a50ad..f4adc18 100644 --- a/migrations/20240727123308_initial.sql +++ b/migrations/20240727123308_initial.sql @@ -15,18 +15,38 @@ $$ 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; + 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 users +( + id serial NOT NULL, + username varchar(70) UNIQUE NOT NULL, + hashed_pwd varchar(60) NOT NULL, + role integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + PRIMARY KEY (id), + CHECK (length(username) != 0 AND username = lower(username) AND username = trim(username)), + CHECK (length(hashed_pwd) != 0), + CHECK (role BETWEEN 0 AND 2) +); + +CREATE TRIGGER on_users_update + BEFORE UPDATE + ON users + FOR EACH ROW +EXECUTE PROCEDURE updated_at_update(); + CREATE TABLE IF NOT EXISTS problems ( id serial NOT NULL, @@ -92,7 +112,8 @@ CREATE TABLE IF NOT EXISTS tasks ); CREATE TRIGGER max_tasks_on_contest_check - BEFORE INSERT ON tasks + BEFORE INSERT + ON tasks FOR EACH STATEMENT EXECUTE FUNCTION check_max_tasks(); @@ -105,8 +126,8 @@ EXECUTE PROCEDURE updated_at_update(); CREATE TABLE IF NOT EXISTS participants ( id serial NOT NULL, - user_id integer NOT NULL, - contest_id integer NOT NULL REFERENCES contests (id), + user_id integer NOT NULL REFERENCES users (id) ON DELETE CASCADE, + contest_id integer NOT NULL REFERENCES contests (id) ON DELETE CASCADE, name varchar(64) NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), @@ -121,7 +142,6 @@ CREATE TRIGGER on_participants_update FOR EACH ROW EXECUTE PROCEDURE updated_at_update(); - CREATE TABLE IF NOT EXISTS solutions ( id serial NOT NULL, @@ -144,7 +164,6 @@ CREATE TRIGGER on_solutions_update ON solutions FOR EACH ROW EXECUTE PROCEDURE updated_at_update(); - -- +goose StatementEnd -- +goose Down @@ -160,6 +179,8 @@ 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 FUNCTION IF EXISTS updated_at_update(); +DROP TRIGGER IF EXISTS on_users_update ON users; +DROP TABLE IF EXISTS users; +DROP FUNCTION IF EXISTS updated_at_update(); DROP FUNCTION IF EXISTS check_max_tasks(); -- +goose StatementEnd \ No newline at end of file diff --git a/pkg/errors.go b/pkg/errors.go index 6efa146..0169a09 100644 --- a/pkg/errors.go +++ b/pkg/errors.go @@ -1,8 +1,11 @@ package pkg import ( + "database/sql" "errors" "fmt" + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5/pgconn" "net/http" ) @@ -35,3 +38,21 @@ func ToREST(err error) int { return http.StatusInternalServerError } + +func HandlePgErr(err error, op string) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgerrcode.IsIntegrityConstraintViolation(pgErr.Code) { + return Wrap(ErrBadInput, err, op, pgErr.Message) + } + if pgerrcode.IsNoData(pgErr.Code) { + return Wrap(ErrNotFound, err, op, pgErr.Message) + } + } + + if errors.Is(err, sql.ErrNoRows) { + return Wrap(ErrNotFound, err, op, "no rows found") + } + + return Wrap(ErrUnhandled, err, op, "unexpected error") +}