diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9776eca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.23.4 AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ + +RUN go mod tidy + +COPY . . + +RUN go build -o tracker . + +FROM debian:bullseye-slim + +RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /app/tracker /app/ +COPY --from=builder /app/tracker.db /app/ + +EXPOSE 8080 + +CMD ["./tracker"] diff --git a/README.md b/README.md index d52c399..5b5f5f2 100644 --- a/README.md +++ b/README.md @@ -1 +1,44 @@ -# 42_final \ No newline at end of file +Данный сервис позволяет управлять посылками клиентов, храня их в базе данных SQLite. + + +# **Основные возможности:** + +Регистрация посылки (номер, клиент, статус, адрес, дата регистрации) + +Изменение статуса (Registered -> Sent -> Delivered) + +Изменение адреса доставки (только для статуса registered) + +Получение списка посылок клиента + +Удаление посылки (возможно только в статусе registered) + + +Сервис написан на Go и взаимодействует с базой данных через database/sql. + + +# **Технологии:** + +Go – серверная часть + +SQLite – база данных + +database/sql – работа с БД + +Git – контроль версий + +Testify – тестирование + +Docker – контейнеризация + + +# **Покрываемые тесты:** + + +TestAddGetDelete – проверяет добавление, получение и удаление посылки + +TestSetAddress – проверяет обновление адреса + +TestSetStatus – проверяет обновление статуса + +TestGetByClient – проверяет получение посылок по клиенту diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ccf4e5f --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/Yandex-Practicum/42-docker-final + +go 1.22.0 + +require ( + github.com/stretchr/testify v1.8.4 + modernc.org/sqlite v1.29.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.16.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.41.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1a4926c --- /dev/null +++ b/go.sum @@ -0,0 +1,47 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.29.1 h1:19GY2qvWB4VPw0HppFlZCPAbmxFU41r+qjKZQdQ1ryA= +modernc.org/sqlite v1.29.1/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..a406215 --- /dev/null +++ b/main.go @@ -0,0 +1,177 @@ +package main + +import ( + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +const ( + ParcelStatusRegistered = "registered" + ParcelStatusSent = "sent" + ParcelStatusDelivered = "delivered" +) + +type Parcel struct { + Number int + Client int + Status string + Address string + CreatedAt string +} + +type ParcelService struct { + store ParcelStore +} + +func NewParcelService(store ParcelStore) ParcelService { + return ParcelService{store: store} +} + +func (s ParcelService) Register(client int, address string) (Parcel, error) { + parcel := Parcel{ + Client: client, + Status: ParcelStatusRegistered, + Address: address, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + } + + id, err := s.store.Add(parcel) + if err != nil { + return parcel, err + } + + parcel.Number = id + + fmt.Printf("Новая посылка № %d на адрес %s от клиента с идентификатором %d зарегистрирована %s\n", + parcel.Number, parcel.Address, parcel.Client, parcel.CreatedAt) + + return parcel, nil +} + +func (s ParcelService) PrintClientParcels(client int) error { + parcels, err := s.store.GetByClient(client) + if err != nil { + return err + } + + fmt.Printf("Посылки клиента %d:\n", client) + for _, parcel := range parcels { + fmt.Printf("Посылка № %d на адрес %s от клиента с идентификатором %d зарегистрирована %s, статус %s\n", + parcel.Number, parcel.Address, parcel.Client, parcel.CreatedAt, parcel.Status) + } + fmt.Println() + + return nil +} + +func (s ParcelService) NextStatus(number int) error { + parcel, err := s.store.Get(number) + if err != nil { + return err + } + + var nextStatus string + switch parcel.Status { + case ParcelStatusRegistered: + nextStatus = ParcelStatusSent + case ParcelStatusSent: + nextStatus = ParcelStatusDelivered + case ParcelStatusDelivered: + return nil + } + + fmt.Printf("У посылки № %d новый статус: %s\n", number, nextStatus) + + return s.store.SetStatus(number, nextStatus) +} + +func (s ParcelService) ChangeAddress(number int, address string) error { + return s.store.SetAddress(number, address) +} + +func (s ParcelService) Delete(number int) error { + return s.store.Delete(number) +} + +func main() { + db, err := sql.Open("sqlite", "tracker.db") + if err != nil { + fmt.Println(err) + return + } + defer db.Close() + + store := NewParcelStore(db) + service := NewParcelService(store) + + // регистрация посылки + client := 1 + address := "Псков, д. Пушкина, ул. Колотушкина, д. 5" + p, err := service.Register(client, address) + if err != nil { + fmt.Println(err) + return + } + + // изменение адреса + newAddress := "Саратов, д. Верхние Зори, ул. Козлова, д. 25" + err = service.ChangeAddress(p.Number, newAddress) + if err != nil { + fmt.Println(err) + return + } + + // изменение статуса + err = service.NextStatus(p.Number) + if err != nil { + fmt.Println(err) + return + } + + // вывод посылок клиента + err = service.PrintClientParcels(client) + if err != nil { + fmt.Println(err) + return + } + + // попытка удаления отправленной посылки + err = service.Delete(p.Number) + if err != nil { + fmt.Println(err) + return + } + + // вывод посылок клиента + // предыдущая посылка не должна удалиться, т.к. её статус НЕ «зарегистрирована» + err = service.PrintClientParcels(client) + if err != nil { + fmt.Println(err) + return + } + + // регистрация новой посылки + p, err = service.Register(client, address) + if err != nil { + fmt.Println(err) + return + } + + // удаление новой посылки + err = service.Delete(p.Number) + if err != nil { + fmt.Println(err) + return + } + + // вывод посылок клиента + // здесь не должно быть последней посылки, т.к. она должна была успешно удалиться + err = service.PrintClientParcels(client) + if err != nil { + fmt.Println(err) + return + } +} diff --git a/parcel.go b/parcel.go new file mode 100644 index 0000000..783b05c --- /dev/null +++ b/parcel.go @@ -0,0 +1,96 @@ +package main + +import ( + "database/sql" +) + +type ParcelStore struct { + db *sql.DB +} + +func NewParcelStore(db *sql.DB) ParcelStore { + return ParcelStore{db: db} +} + +func (s ParcelStore) Add(p Parcel) (int, error) { + res, err := s.db.Exec("INSERT INTO parcel (client, status, address, created_at) VALUES (:client, :status, :address, :created_at)", + sql.Named("client", p.Client), + sql.Named("status", p.Status), + sql.Named("address", p.Address), + sql.Named("created_at", p.CreatedAt)) + if err != nil { + return 0, err + } + + id, err := res.LastInsertId() + if err != nil { + return 0, err + } + + return int(id), nil +} + +func (s ParcelStore) Get(number int) (Parcel, error) { + p := Parcel{} + + row := s.db.QueryRow("SELECT number, client, status, address, created_at FROM parcel WHERE number = :number", + sql.Named("number", number)) + err := row.Scan(&p.Number, &p.Client, &p.Status, &p.Address, &p.CreatedAt) + if err != nil { + return p, err + } + + return p, nil +} + +func (s ParcelStore) GetByClient(client int) ([]Parcel, error) { + rows, err := s.db.Query("SELECT number, client, status, address, created_at FROM parcel WHERE client = :client", + sql.Named("client", client)) + if err != nil { + return nil, err + } + defer rows.Close() + + var res []Parcel + for rows.Next() { + p := Parcel{} + + err := rows.Scan(&p.Number, &p.Client, &p.Status, &p.Address, &p.CreatedAt) + if err != nil { + return nil, err + } + + res = append(res, p) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return res, nil +} + +func (s ParcelStore) SetStatus(number int, status string) error { + _, err := s.db.Exec("UPDATE parcel SET status = :status WHERE number = :number", + sql.Named("status", status), + sql.Named("number", number)) + + return err +} + +func (s ParcelStore) SetAddress(number int, address string) error { + _, err := s.db.Exec("UPDATE parcel SET address = :address WHERE number = :number AND status = :status", + sql.Named("address", address), + sql.Named("number", number), + sql.Named("status", ParcelStatusRegistered)) + + return err +} + +func (s ParcelStore) Delete(number int) error { + _, err := s.db.Exec("DELETE FROM parcel WHERE number = :number AND status = :status", + sql.Named("number", number), + sql.Named("status", ParcelStatusRegistered)) + + return err +} diff --git a/parcel_test.go b/parcel_test.go new file mode 100644 index 0000000..cd46e1c --- /dev/null +++ b/parcel_test.go @@ -0,0 +1,167 @@ +package main + +import ( + "database/sql" + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var ( + // randSource источник псевдо случайных чисел. + // Для повышения уникальности в качестве seed + // используется текущее время в unix формате (в виде числа) + randSource = rand.NewSource(time.Now().UnixNano()) + // randRange использует randSource для генерации случайных чисел + randRange = rand.New(randSource) +) + +// getTestParcel возвращает тестовую посылку +func getTestParcel() Parcel { + return Parcel{ + Client: 1000, + Status: ParcelStatusRegistered, + Address: "test", + CreatedAt: time.Now().UTC().Format(time.RFC3339), + } +} + +// TestAddGetDelete проверяет добавление, получение и удаление посылку +func TestAddGetDelete(t *testing.T) { + // prepare + db, err := sql.Open("sqlite", "tracker.db") + if err != nil { + require.NoError(t, err) + } + defer db.Close() + store := NewParcelStore(db) + parcel := getTestParcel() + + // add + parcel.Number, err = store.Add(parcel) + + require.NoError(t, err) + require.NotEmpty(t, parcel.Number) + + // get + stored, err := store.Get(parcel.Number) + + require.NoError(t, err) + require.Equal(t, parcel, stored) + + // delete + err = store.Delete(parcel.Number) + + stored, err = store.Get(parcel.Number) + require.Equal(t, sql.ErrNoRows, err) +} + +// TestSetAddress проверяет обновление адреса +func TestSetAddress(t *testing.T) { + // prepare + db, err := sql.Open("sqlite", "tracker.db") + if err != nil { + require.NoError(t, err) + } + defer db.Close() + store := NewParcelStore(db) + parcel := getTestParcel() + + // add + parcel.Number, err = store.Add(parcel) + + require.NoError(t, err) + require.NotEmpty(t, parcel.Number) + + // set address + newAddress := "new test address" + err = store.SetAddress(parcel.Number, newAddress) + + require.NoError(t, err) + + // check + stored, err := store.Get(parcel.Number) + + require.NoError(t, err) + require.Equal(t, newAddress, stored.Address) +} + +// TestSetStatus проверяет обновление статуса +func TestSetStatus(t *testing.T) { + // prepare + db, err := sql.Open("sqlite", "tracker.db") + if err != nil { + require.NoError(t, err) + } + defer db.Close() + store := NewParcelStore(db) + parcel := getTestParcel() + + // add + parcel.Number, err = store.Add(parcel) + + require.NoError(t, err) + require.NotEmpty(t, parcel.Number) + + // set status + err = store.SetStatus(parcel.Number, ParcelStatusSent) + + require.NoError(t, err) + + // check + stored, err := store.Get(parcel.Number) + + require.NoError(t, err) + require.Equal(t, ParcelStatusSent, stored.Status) +} + +// TestGetByClient проверяет получение посылок по идентификатору клиента +func TestGetByClient(t *testing.T) { + // prepare + db, err := sql.Open("sqlite", "tracker.db") + if err != nil { + require.NoError(t, err) + } + defer db.Close() + store := NewParcelStore(db) + + parcels := []Parcel{ + getTestParcel(), + getTestParcel(), + getTestParcel(), + } + parcelMap := map[int]Parcel{} + + // задаём всем посылкам одного клиента + client := randRange.Intn(10_000_000) + parcels[0].Client = client + parcels[1].Client = client + parcels[2].Client = client + + // add + for i := 0; i < len(parcels); i++ { + id, err := store.Add(parcels[i]) + + require.NoError(t, err) + require.NotEmpty(t, id) + + parcels[i].Number = id + parcelMap[id] = parcels[i] + } + + // get by client + storedParcels, err := store.GetByClient(client) + + require.NoError(t, err) + require.Len(t, storedParcels, len(parcels)) + + // check + for _, parcel := range storedParcels { + expectedParcel, ok := parcelMap[parcel.Number] + + require.True(t, ok) + require.Equal(t, expectedParcel, parcel) + } +} diff --git a/tracker.db b/tracker.db new file mode 100644 index 0000000..04ab843 Binary files /dev/null and b/tracker.db differ