all repos

onasty @ 04083be5ec1a57eb77bb64f7ca48c9eff2107417

a one-time notes service
24 files changed, 734 insertions(+), 23 deletions(-)
feat: impl the core of the app, notes (#5)

* feat(notes): set up boiletplade files

* feat(models): add note

* test(models): test validation of note

* chore(migrations): add notes

* feat(notes): create

* fix(e2e): app could be able to build here too

* feat(note): get by slug

* refactor(notesrv): use more descriptive name

* feat: add user_id to note

* fix(http): somehow it removes redicect

* go mod tidy

* chore(taskfile): add one more task for migrations

* fix(notesrv): handle if note has to be burnt

* remove unused lint ignore

* test(e2e): note create, unauthorized

* refactor(e2e): fix linter

* refactor(http): reorganize methods

* feat(notes): save user_id if user is authorized

FIRST TRY

* fix(notes): add migrations for storing author_ids

* go mod tidy

* feat(notes): store author_id if user authorized

* fix linting

* test(e2e): create note authorized

* fix(notes): notes might be readed and removed after

* test(e2e): get notes

* linter momonet

* chore(ci): update few versions

* refactor(e2e): rename files

* fix migration

* refactor(e2e): renaming

* refactor(e2e): some formatting
Author: Smirnov Oleksandr ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2024-07-28 15:41:31 +0300
Parent: 2cd7240
M .github/workflows/golang.yml
···
        9
        9
           release:

      
        10
        10
             runs-on: ubuntu-latest

      
        11
        11
             steps:

      
        12
        
        -      - uses: actions/checkout@v3

      
        
        12
        +      - uses: actions/checkout@v4

      
        13
        13
         

      
        14
        14
               - name: Set up Go

      
        15
        15
                 uses: actions/setup-go@v4

      
M .github/workflows/linter.yml
···
        8
        8
           golang:

      
        9
        9
             runs-on: ubuntu-latest

      
        10
        10
             steps:

      
        11
        
        -      - uses: actions/checkout@v3

      
        
        11
        +      - uses: actions/checkout@v4

      
        12
        12
         

      
        13
        13
               - name: Golangci Lint

      
        14
        14
                 uses: golangci/golangci-lint-action@v3

      
M cmd/server/main.go
···
        13
        13
         	"github.com/olexsmir/onasty/internal/config"

      
        14
        14
         	"github.com/olexsmir/onasty/internal/hasher"

      
        15
        15
         	"github.com/olexsmir/onasty/internal/jwtutil"

      
        
        16
        +	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        16
        17
         	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        
        18
        +	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

      
        17
        19
         	"github.com/olexsmir/onasty/internal/store/psql/sessionrepo"

      
        18
        20
         	"github.com/olexsmir/onasty/internal/store/psql/userepo"

      
        19
        21
         	"github.com/olexsmir/onasty/internal/store/psqlutil"

      ···
        55
        57
         	userepo := userepo.New(psqlDB)

      
        56
        58
         	usersrv := usersrv.New(userepo, sessionrepo, sha256Hasher, jwtTokenizer)

      
        57
        59
         

      
        58
        
        -	handler := httptransport.NewTransport(usersrv)

      
        
        60
        +	noterepo := noterepo.New(psqlDB)

      
        
        61
        +	notesrv := notesrv.New(noterepo)

      
        
        62
        +

      
        
        63
        +	handler := httptransport.NewTransport(usersrv, notesrv)

      
        59
        64
         

      
        60
        65
         	// http server

      
        61
        66
         	srv := httpserver.NewServer(cfg.ServerPort, handler.Handler())

      
M e2e/apiv1_auth_test.go
···
        71
        71
         	}

      
        72
        72
         }

      
        73
        73
         

      
        74
        
        -type apiv1AuthSignInRequest struct {

      
        75
        
        -	Email    string `json:"email"`

      
        76
        
        -	Password string `json:"password"`

      
        77
        
        -}

      
        78
        
        -

      
        79
        
        -type apiv1AuthSignInResponse struct {

      
        80
        
        -	AccessToken  string `json:"access_token"`

      
        81
        
        -	RefreshToken string `json:"refresh_token"`

      
        82
        
        -}

      
        
        74
        +type (

      
        
        75
        +	apiv1AuthSignInRequest struct {

      
        
        76
        +		Email    string `json:"email"`

      
        
        77
        +		Password string `json:"password"`

      
        
        78
        +	}

      
        
        79
        +	apiv1AuthSignInResponse struct {

      
        
        80
        +		AccessToken  string `json:"access_token"`

      
        
        81
        +		RefreshToken string `json:"refresh_token"`

      
        
        82
        +	}

      
        
        83
        +)

      
        83
        84
         

      
        84
        85
         func (e *AppTestSuite) TestAuthV1_SignIn() {

      
        85
        86
         	email := e.uuid() + "email@email.com"

      
A e2e/apiv1_notes_test.go
···
        
        1
        +package e2e

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"net/http"

      
        
        5
        +	"net/http/httptest"

      
        
        6
        +	"time"

      
        
        7
        +

      
        
        8
        +	"github.com/gofrs/uuid/v5"

      
        
        9
        +)

      
        
        10
        +

      
        
        11
        +type (

      
        
        12
        +	apiv1NoteCreateRequest struct {

      
        
        13
        +		Content              string    `json:"content"`

      
        
        14
        +		Slug                 string    `json:"slug"`

      
        
        15
        +		BurnBeforeExpiration bool      `json:"burn_before_expiration"`

      
        
        16
        +		ExpiresAt            time.Time `json:"expires_at"`

      
        
        17
        +	}

      
        
        18
        +	apiv1NoteCreateResponse struct {

      
        
        19
        +		Slug string `json:"slug"`

      
        
        20
        +	}

      
        
        21
        +)

      
        
        22
        +

      
        
        23
        +func (e *AppTestSuite) TestNoteV1_Create_unauthorized() {

      
        
        24
        +	tests := []struct {

      
        
        25
        +		name   string

      
        
        26
        +		inp    apiv1NoteCreateRequest

      
        
        27
        +		assert func(*httptest.ResponseRecorder, apiv1NoteCreateRequest)

      
        
        28
        +	}{

      
        
        29
        +		{

      
        
        30
        +			name: "empty request",

      
        
        31
        +			inp:  apiv1NoteCreateRequest{},

      
        
        32
        +			assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) {

      
        
        33
        +				e.Equal(r.Code, http.StatusBadRequest)

      
        
        34
        +			},

      
        
        35
        +		},

      
        
        36
        +		{

      
        
        37
        +			name: "content only",

      
        
        38
        +			inp:  apiv1NoteCreateRequest{Content: e.uuid()},

      
        
        39
        +			assert: func(r *httptest.ResponseRecorder, _ apiv1NoteCreateRequest) {

      
        
        40
        +				e.Equal(r.Code, http.StatusCreated)

      
        
        41
        +

      
        
        42
        +				var body apiv1NoteCreateResponse

      
        
        43
        +				e.readBodyAndUnjsonify(r.Body, &body)

      
        
        44
        +

      
        
        45
        +				_, err := uuid.FromString(body.Slug)

      
        
        46
        +				e.require.NoError(err)

      
        
        47
        +

      
        
        48
        +				dbNote := e.getNoteFromDBbySlug(body.Slug)

      
        
        49
        +				e.NotEmpty(dbNote)

      
        
        50
        +			},

      
        
        51
        +		},

      
        
        52
        +		{

      
        
        53
        +			name: "set slug",

      
        
        54
        +			inp: apiv1NoteCreateRequest{

      
        
        55
        +				Slug:    e.uuid() + "fuker",

      
        
        56
        +				Content: e.uuid(),

      
        
        57
        +			},

      
        
        58
        +			assert: func(r *httptest.ResponseRecorder, inp apiv1NoteCreateRequest) {

      
        
        59
        +				e.Equal(r.Code, http.StatusCreated)

      
        
        60
        +

      
        
        61
        +				var body apiv1NoteCreateResponse

      
        
        62
        +				e.readBodyAndUnjsonify(r.Body, &body)

      
        
        63
        +

      
        
        64
        +				dbNote := e.getNoteFromDBbySlug(inp.Slug)

      
        
        65
        +				e.NotEmpty(dbNote)

      
        
        66
        +			},

      
        
        67
        +		},

      
        
        68
        +		{

      
        
        69
        +			name: "all possible fields",

      
        
        70
        +			inp: apiv1NoteCreateRequest{

      
        
        71
        +				Content:              e.uuid(),

      
        
        72
        +				BurnBeforeExpiration: true,

      
        
        73
        +				ExpiresAt:            time.Now().Add(time.Hour),

      
        
        74
        +			},

      
        
        75
        +			assert: func(r *httptest.ResponseRecorder, inp apiv1NoteCreateRequest) {

      
        
        76
        +				e.Equal(r.Code, http.StatusCreated)

      
        
        77
        +

      
        
        78
        +				var body apiv1NoteCreateResponse

      
        
        79
        +				e.readBodyAndUnjsonify(r.Body, &body)

      
        
        80
        +

      
        
        81
        +				dbNote := e.getNoteFromDBbySlug(body.Slug)

      
        
        82
        +				e.NotEmpty(dbNote)

      
        
        83
        +

      
        
        84
        +				e.Equal(dbNote.Content, inp.Content)

      
        
        85
        +				e.Equal(dbNote.BurnBeforeExpiration, inp.BurnBeforeExpiration)

      
        
        86
        +				e.Equal(dbNote.ExpiresAt.Unix(), inp.ExpiresAt.Unix())

      
        
        87
        +			},

      
        
        88
        +		},

      
        
        89
        +	}

      
        
        90
        +

      
        
        91
        +	for _, tt := range tests {

      
        
        92
        +		httpResp := e.httpRequest(http.MethodPost, "/api/v1/note", e.jsonify(tt.inp))

      
        
        93
        +		tt.assert(httpResp, tt.inp)

      
        
        94
        +	}

      
        
        95
        +}

      
        
        96
        +

      
        
        97
        +func (e *AppTestSuite) TestNoteV1_Create_authorized() {

      
        
        98
        +	uid, toks := e.createAndSingIn(e.uuid()+"@test.com", e.uuid(), "password")

      
        
        99
        +	httpResp := e.httpRequest(http.MethodPost, "/api/v1/note", e.jsonify(apiv1NoteCreateRequest{

      
        
        100
        +		Content: "some random ass content for the test",

      
        
        101
        +	}), toks.AccessToken)

      
        
        102
        +

      
        
        103
        +	var body apiv1NoteCreateResponse

      
        
        104
        +	e.readBodyAndUnjsonify(httpResp.Body, &body)

      
        
        105
        +

      
        
        106
        +	dbNote := e.getNoteFromDBbySlug(body.Slug)

      
        
        107
        +	dbNoteAuthor := e.getLastNoteAuthorsRecordByAuthorID(uid)

      
        
        108
        +

      
        
        109
        +	e.Equal(http.StatusCreated, httpResp.Code)

      
        
        110
        +	e.Equal(dbNote.ID.String(), dbNoteAuthor.noteID.String())

      
        
        111
        +}

      
        
        112
        +

      
        
        113
        +type apiv1NoteGetResponse struct {

      
        
        114
        +	Content   string    `json:"content"`

      
        
        115
        +	CreatedAt time.Time `json:"created_at"`

      
        
        116
        +	ExpiresAt time.Time `json:"expires_at"`

      
        
        117
        +}

      
        
        118
        +

      
        
        119
        +func (e *AppTestSuite) TestNoteV1_Get() {

      
        
        120
        +	content := e.uuid()

      
        
        121
        +	httpResp := e.httpRequest(http.MethodPost, "/api/v1/note", e.jsonify(apiv1NoteCreateRequest{

      
        
        122
        +		Content: content,

      
        
        123
        +	}))

      
        
        124
        +	e.Equal(http.StatusCreated, httpResp.Code)

      
        
        125
        +

      
        
        126
        +	var bodyCreated apiv1NoteCreateResponse

      
        
        127
        +	e.readBodyAndUnjsonify(httpResp.Body, &bodyCreated)

      
        
        128
        +

      
        
        129
        +	httpResp = e.httpRequest(http.MethodGet, "/api/v1/note/"+bodyCreated.Slug, nil)

      
        
        130
        +	e.Equal(httpResp.Code, http.StatusOK)

      
        
        131
        +

      
        
        132
        +	var body apiv1NoteGetResponse

      
        
        133
        +	e.readBodyAndUnjsonify(httpResp.Body, &body)

      
        
        134
        +

      
        
        135
        +	e.Equal(content, body.Content)

      
        
        136
        +

      
        
        137
        +	dbNote := e.getNoteFromDBbySlug(bodyCreated.Slug)

      
        
        138
        +	e.Empty(dbNote)

      
        
        139
        +}

      
M e2e/e2e_test.go
···
        13
        13
         	"github.com/jackc/pgx/v5/stdlib"

      
        14
        14
         	"github.com/olexsmir/onasty/internal/hasher"

      
        15
        15
         	"github.com/olexsmir/onasty/internal/jwtutil"

      
        
        16
        +	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        16
        17
         	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        
        18
        +	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

      
        17
        19
         	"github.com/olexsmir/onasty/internal/store/psql/sessionrepo"

      
        18
        20
         	"github.com/olexsmir/onasty/internal/store/psql/userepo"

      
        19
        21
         	"github.com/olexsmir/onasty/internal/store/psqlutil"

      ···
        83
        85
         	userepo := userepo.New(e.postgresDB)

      
        84
        86
         	usersrv := usersrv.New(userepo, sessionrepo, e.hasher, e.jwtTokenizer)

      
        85
        87
         

      
        86
        
        -	handler := httptransport.NewTransport(usersrv)

      
        
        88
        +	noterepo := noterepo.New(e.postgresDB)

      
        
        89
        +	notesrv := notesrv.New(noterepo)

      
        
        90
        +

      
        
        91
        +	handler := httptransport.NewTransport(usersrv, notesrv)

      
        87
        92
         	e.router = handler.Handler()

      
        88
        93
         }

      
        89
        94
         

      
M e2e/e2e_utils_db_test.go
···
        66
        66
         	e.require.NoError(err)

      
        67
        67
         	return session

      
        68
        68
         }

      
        
        69
        +

      
        
        70
        +func (e *AppTestSuite) getNoteFromDBbySlug(slug string) models.Note {

      
        
        71
        +	query, args, err := pgq.

      
        
        72
        +		Select("id", "content", "slug", "burn_before_expiration", "created_at", "expires_at").

      
        
        73
        +		From("notes").

      
        
        74
        +		Where("slug = ?", slug).

      
        
        75
        +		SQL()

      
        
        76
        +	e.require.NoError(err)

      
        
        77
        +

      
        
        78
        +	var note models.Note

      
        
        79
        +	err = e.postgresDB.QueryRow(e.ctx, query, args...).

      
        
        80
        +		Scan(&note.ID, &note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.CreatedAt, &note.ExpiresAt)

      
        
        81
        +	if errors.Is(err, pgx.ErrNoRows) {

      
        
        82
        +		return models.Note{}

      
        
        83
        +	}

      
        
        84
        +

      
        
        85
        +	e.require.NoError(err)

      
        
        86
        +	return note

      
        
        87
        +}

      
        
        88
        +

      
        
        89
        +type noteAuthorModel struct {

      
        
        90
        +	noteID uuid.UUID

      
        
        91
        +	userID uuid.UUID

      
        
        92
        +}

      
        
        93
        +

      
        
        94
        +func (e *AppTestSuite) getLastNoteAuthorsRecordByAuthorID(uid uuid.UUID) noteAuthorModel {

      
        
        95
        +	qeuery, args, err := pgq.

      
        
        96
        +		Select("note_id", "user_id").

      
        
        97
        +		From("notes_authors").

      
        
        98
        +		Where(pgq.Eq{"user_id": uid.String()}).

      
        
        99
        +		OrderBy("created_at DESC").

      
        
        100
        +		Limit(1).

      
        
        101
        +		SQL()

      
        
        102
        +	e.require.NoError(err)

      
        
        103
        +

      
        
        104
        +	var na noteAuthorModel

      
        
        105
        +	err = e.postgresDB.QueryRow(e.ctx, qeuery, args...).Scan(&na.noteID, &na.userID)

      
        
        106
        +	if errors.Is(err, pgx.ErrNoRows) {

      
        
        107
        +		return noteAuthorModel{}

      
        
        108
        +	}

      
        
        109
        +

      
        
        110
        +	e.require.NoError(err)

      
        
        111
        +	return na

      
        
        112
        +}

      
M e2e/e2e_utils_test.go
···
        35
        35
         // httpRequest sends http request to the server and returns `httptest.ResponseRecorder`

      
        36
        36
         // conteny-type always set to application/json

      
        37
        37
         func (e *AppTestSuite) httpRequest(

      
        38
        
        -	method, url string, //nolint:unparam // TODO: fix me later

      
        
        38
        +	method, url string,

      
        39
        39
         	body []byte,

      
        40
        40
         	accessToken ...string,

      
        41
        41
         ) *httptest.ResponseRecorder {

      
A internal/dtos/note.go
···
        
        1
        +package dtos

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"time"

      
        
        5
        +

      
        
        6
        +	"github.com/gofrs/uuid/v5"

      
        
        7
        +)

      
        
        8
        +

      
        
        9
        +type NoteSlugDTO string

      
        
        10
        +

      
        
        11
        +func (n NoteSlugDTO) String() string { return string(n) }

      
        
        12
        +

      
        
        13
        +type NoteDTO struct {

      
        
        14
        +	Content              string

      
        
        15
        +	Slug                 string

      
        
        16
        +	BurnBeforeExpiration bool

      
        
        17
        +	CreatedAt            time.Time

      
        
        18
        +	ExpiresAt            time.Time

      
        
        19
        +}

      
        
        20
        +

      
        
        21
        +type CreateNoteDTO struct {

      
        
        22
        +	Content              string

      
        
        23
        +	UserID               uuid.UUID

      
        
        24
        +	Slug                 string

      
        
        25
        +	BurnBeforeExpiration bool

      
        
        26
        +	CreatedAt            time.Time

      
        
        27
        +	ExpiresAt            time.Time

      
        
        28
        +}

      
A internal/models/notes.go
···
        
        1
        +package models

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"errors"

      
        
        5
        +	"time"

      
        
        6
        +

      
        
        7
        +	"github.com/gofrs/uuid/v5"

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +var (

      
        
        11
        +	ErrNoteContentIsEmpty     = errors.New("note: content is empty")

      
        
        12
        +	ErrNoteSlugIsAlreadyInUse = errors.New("note: slug is already in use")

      
        
        13
        +	ErrNoteExpired            = errors.New("note: expired")

      
        
        14
        +	ErrNoteNotFound           = errors.New("note: not found")

      
        
        15
        +)

      
        
        16
        +

      
        
        17
        +type Note struct {

      
        
        18
        +	ID                   uuid.UUID

      
        
        19
        +	Content              string

      
        
        20
        +	Slug                 string

      
        
        21
        +	BurnBeforeExpiration bool

      
        
        22
        +	CreatedAt            time.Time

      
        
        23
        +	ExpiresAt            time.Time

      
        
        24
        +}

      
        
        25
        +

      
        
        26
        +func (n Note) Validate() error {

      
        
        27
        +	if n.Content == "" {

      
        
        28
        +		return ErrNoteContentIsEmpty

      
        
        29
        +	}

      
        
        30
        +

      
        
        31
        +	if n.IsExpired() {

      
        
        32
        +		return ErrNoteExpired

      
        
        33
        +	}

      
        
        34
        +

      
        
        35
        +	return nil

      
        
        36
        +}

      
        
        37
        +

      
        
        38
        +func (n Note) IsExpired() bool {

      
        
        39
        +	return !n.ExpiresAt.IsZero() &&

      
        
        40
        +		n.ExpiresAt.Before(time.Now())

      
        
        41
        +}

      
        
        42
        +

      
        
        43
        +func (n Note) ShouldBeBurnt() bool {

      
        
        44
        +	return !n.ExpiresAt.IsZero() &&

      
        
        45
        +		n.BurnBeforeExpiration

      
        
        46
        +}

      
A internal/models/notes_test.go
···
        
        1
        +package models

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"testing"

      
        
        5
        +	"time"

      
        
        6
        +

      
        
        7
        +	assert "github.com/stretchr/testify/require"

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +func TestNote_Validate(t *testing.T) {

      
        
        11
        +	tests := []struct {

      
        
        12
        +		name      string

      
        
        13
        +		note      Note

      
        
        14
        +		willError bool

      
        
        15
        +		error     error

      
        
        16
        +	}{

      
        
        17
        +		// NOTE: there no need to test if note is expired since it tested in IsExpired test

      
        
        18
        +		{

      
        
        19
        +			name: "ok",

      
        
        20
        +			note: Note{

      
        
        21
        +				Content:   "some wired ass content",

      
        
        22
        +				ExpiresAt: time.Now().Add(time.Hour),

      
        
        23
        +			},

      
        
        24
        +			willError: false,

      
        
        25
        +		},

      
        
        26
        +		{

      
        
        27
        +			name:      "content missing",

      
        
        28
        +			note:      Note{Content: ""},

      
        
        29
        +			willError: true,

      
        
        30
        +			error:     ErrNoteContentIsEmpty,

      
        
        31
        +		},

      
        
        32
        +	}

      
        
        33
        +

      
        
        34
        +	for _, tt := range tests {

      
        
        35
        +		t.Run(tt.name, func(t *testing.T) {

      
        
        36
        +			err := tt.note.Validate()

      
        
        37
        +			if tt.willError {

      
        
        38
        +				assert.EqualError(t, err, tt.error.Error())

      
        
        39
        +			} else {

      
        
        40
        +				assert.NoError(t, err)

      
        
        41
        +			}

      
        
        42
        +		})

      
        
        43
        +	}

      
        
        44
        +}

      
        
        45
        +

      
        
        46
        +func TestNote_IsExpired(t *testing.T) {

      
        
        47
        +	tests := []struct {

      
        
        48
        +		name     string

      
        
        49
        +		note     Note

      
        
        50
        +		expected bool

      
        
        51
        +	}{

      
        
        52
        +		{

      
        
        53
        +			name:     "expired",

      
        
        54
        +			note:     Note{ExpiresAt: time.Now().Add(-time.Hour)},

      
        
        55
        +			expected: true,

      
        
        56
        +		},

      
        
        57
        +		{

      
        
        58
        +			name:     "not expired",

      
        
        59
        +			note:     Note{ExpiresAt: time.Now().Add(time.Hour)},

      
        
        60
        +			expected: false,

      
        
        61
        +		},

      
        
        62
        +		{

      
        
        63
        +			name:     "zero expiration",

      
        
        64
        +			note:     Note{ExpiresAt: time.Time{}},

      
        
        65
        +			expected: false,

      
        
        66
        +		},

      
        
        67
        +	}

      
        
        68
        +

      
        
        69
        +	for _, tt := range tests {

      
        
        70
        +		t.Run(tt.name, func(t *testing.T) {

      
        
        71
        +			assert.Equal(t, tt.expected, tt.note.IsExpired())

      
        
        72
        +		})

      
        
        73
        +	}

      
        
        74
        +}

      
        
        75
        +

      
        
        76
        +func TestNote_ShouldBeBurnt(t *testing.T) {

      
        
        77
        +	tests := []struct {

      
        
        78
        +		name     string

      
        
        79
        +		note     Note

      
        
        80
        +		expected bool

      
        
        81
        +	}{

      
        
        82
        +		{

      
        
        83
        +			name: "should be burnt",

      
        
        84
        +			note: Note{

      
        
        85
        +				BurnBeforeExpiration: true,

      
        
        86
        +				ExpiresAt:            time.Now().Add(time.Hour),

      
        
        87
        +			},

      
        
        88
        +			expected: true,

      
        
        89
        +		},

      
        
        90
        +		{

      
        
        91
        +			name: "could not be burnt, no expiration time",

      
        
        92
        +			note: Note{

      
        
        93
        +				BurnBeforeExpiration: true,

      
        
        94
        +				ExpiresAt:            time.Time{},

      
        
        95
        +			},

      
        
        96
        +			expected: false,

      
        
        97
        +		},

      
        
        98
        +		{

      
        
        99
        +			name: "could not be burnt, burn when expiration and burn is false",

      
        
        100
        +			note: Note{

      
        
        101
        +				BurnBeforeExpiration: false,

      
        
        102
        +				ExpiresAt:            time.Time{},

      
        
        103
        +			},

      
        
        104
        +			expected: false,

      
        
        105
        +		},

      
        
        106
        +	}

      
        
        107
        +

      
        
        108
        +	for _, tt := range tests {

      
        
        109
        +		t.Run(tt.name, func(t *testing.T) {

      
        
        110
        +			assert.Equal(t, tt.expected, tt.note.ShouldBeBurnt())

      
        
        111
        +		})

      
        
        112
        +	}

      
        
        113
        +}

      
A internal/service/notesrv/notesrv.go
···
        
        1
        +package notesrv

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +

      
        
        6
        +	"github.com/gofrs/uuid/v5"

      
        
        7
        +	"github.com/olexsmir/onasty/internal/dtos"

      
        
        8
        +	"github.com/olexsmir/onasty/internal/models"

      
        
        9
        +	"github.com/olexsmir/onasty/internal/store/psql/noterepo"

      
        
        10
        +)

      
        
        11
        +

      
        
        12
        +type NoteServicer interface {

      
        
        13
        +	// Create create note

      
        
        14
        +	// if slug is empty it will be generated, otherwise used as is

      
        
        15
        +	// if userID is empty it means user isn't authorized so it will be used

      
        
        16
        +	Create(ctx context.Context, note dtos.CreateNoteDTO, userID uuid.UUID) (dtos.NoteSlugDTO, error)

      
        
        17
        +	GetBySlugAndRemoveIfNeeded(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error)

      
        
        18
        +}

      
        
        19
        +

      
        
        20
        +var _ NoteServicer = (*NoteSrv)(nil)

      
        
        21
        +

      
        
        22
        +type NoteSrv struct {

      
        
        23
        +	noterepo noterepo.NoteStorer

      
        
        24
        +}

      
        
        25
        +

      
        
        26
        +func New(noterepo noterepo.NoteStorer) NoteServicer {

      
        
        27
        +	return &NoteSrv{

      
        
        28
        +		noterepo: noterepo,

      
        
        29
        +	}

      
        
        30
        +}

      
        
        31
        +

      
        
        32
        +func (n *NoteSrv) Create(

      
        
        33
        +	ctx context.Context,

      
        
        34
        +	inp dtos.CreateNoteDTO,

      
        
        35
        +	userID uuid.UUID,

      
        
        36
        +) (dtos.NoteSlugDTO, error) {

      
        
        37
        +	if inp.Slug == "" {

      
        
        38
        +		inp.Slug = uuid.Must(uuid.NewV4()).String()

      
        
        39
        +	}

      
        
        40
        +

      
        
        41
        +	err := n.noterepo.Create(ctx, inp)

      
        
        42
        +	if err != nil {

      
        
        43
        +		return "", err

      
        
        44
        +	}

      
        
        45
        +

      
        
        46
        +	if !userID.IsNil() {

      
        
        47
        +		if err := n.noterepo.SetAuthorIDBySlug(ctx, dtos.NoteSlugDTO(inp.Slug), userID); err != nil {

      
        
        48
        +			return "", err

      
        
        49
        +		}

      
        
        50
        +	}

      
        
        51
        +

      
        
        52
        +	return dtos.NoteSlugDTO(inp.Slug), nil

      
        
        53
        +}

      
        
        54
        +

      
        
        55
        +func (n *NoteSrv) GetBySlugAndRemoveIfNeeded(

      
        
        56
        +	ctx context.Context,

      
        
        57
        +	slug dtos.NoteSlugDTO,

      
        
        58
        +) (dtos.NoteDTO, error) {

      
        
        59
        +	note, err := n.noterepo.GetBySlug(ctx, slug)

      
        
        60
        +	if err != nil {

      
        
        61
        +		return dtos.NoteDTO{}, err

      
        
        62
        +	}

      
        
        63
        +

      
        
        64
        +	// TODO: there should be a better way to do it

      
        
        65
        +	m := models.Note{

      
        
        66
        +		ExpiresAt:            note.ExpiresAt,

      
        
        67
        +		BurnBeforeExpiration: note.BurnBeforeExpiration,

      
        
        68
        +	}

      
        
        69
        +

      
        
        70
        +	if m.IsExpired() {

      
        
        71
        +		return dtos.NoteDTO{}, models.ErrNoteExpired

      
        
        72
        +	}

      
        
        73
        +

      
        
        74
        +	// since not every note should be burn before expiration

      
        
        75
        +	// we return early if it's not

      
        
        76
        +	if m.ShouldBeBurnt() {

      
        
        77
        +		return note, nil

      
        
        78
        +	}

      
        
        79
        +

      
        
        80
        +	// TODO: in future not remove, leave some metadata

      
        
        81
        +	// to shot user that note was alreasy seen

      
        
        82
        +	return note, n.noterepo.DeleteBySlug(ctx, dtos.NoteSlugDTO(note.Slug))

      
        
        83
        +}

      
A internal/store/psql/noterepo/noterepo.go
···
        
        1
        +package noterepo

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"errors"

      
        
        6
        +

      
        
        7
        +	"github.com/gofrs/uuid/v5"

      
        
        8
        +	"github.com/henvic/pgq"

      
        
        9
        +	"github.com/jackc/pgx/v5"

      
        
        10
        +	"github.com/olexsmir/onasty/internal/dtos"

      
        
        11
        +	"github.com/olexsmir/onasty/internal/models"

      
        
        12
        +	"github.com/olexsmir/onasty/internal/store/psqlutil"

      
        
        13
        +)

      
        
        14
        +

      
        
        15
        +type NoteStorer interface {

      
        
        16
        +	Create(ctx context.Context, inp dtos.CreateNoteDTO) error

      
        
        17
        +	GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error)

      
        
        18
        +	DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error

      
        
        19
        +

      
        
        20
        +	SetAuthorIDBySlug(ctx context.Context, slug dtos.NoteSlugDTO, authorID uuid.UUID) error

      
        
        21
        +}

      
        
        22
        +

      
        
        23
        +var _ NoteStorer = (*NoteRepo)(nil)

      
        
        24
        +

      
        
        25
        +type NoteRepo struct {

      
        
        26
        +	db *psqlutil.DB

      
        
        27
        +}

      
        
        28
        +

      
        
        29
        +func New(db *psqlutil.DB) NoteStorer {

      
        
        30
        +	return &NoteRepo{db}

      
        
        31
        +}

      
        
        32
        +

      
        
        33
        +func (s *NoteRepo) Create(ctx context.Context, inp dtos.CreateNoteDTO) error {

      
        
        34
        +	query, args, err := pgq.

      
        
        35
        +		Insert("notes").

      
        
        36
        +		Columns("content", "slug", "burn_before_expiration ", "created_at", "expires_at").

      
        
        37
        +		Values(inp.Content, inp.Slug, inp.BurnBeforeExpiration, inp.CreatedAt, inp.ExpiresAt).

      
        
        38
        +		SQL()

      
        
        39
        +	if err != nil {

      
        
        40
        +		return err

      
        
        41
        +	}

      
        
        42
        +

      
        
        43
        +	_, err = s.db.Exec(ctx, query, args...)

      
        
        44
        +	if psqlutil.IsDuplicateErr(err) {

      
        
        45
        +		return models.ErrNoteSlugIsAlreadyInUse

      
        
        46
        +	}

      
        
        47
        +

      
        
        48
        +	return err

      
        
        49
        +}

      
        
        50
        +

      
        
        51
        +func (s *NoteRepo) GetBySlug(ctx context.Context, slug dtos.NoteSlugDTO) (dtos.NoteDTO, error) {

      
        
        52
        +	query, args, err := pgq.

      
        
        53
        +		Select("content", "slug", "burn_before_expiration", "created_at", "expires_at").

      
        
        54
        +		From("notes").

      
        
        55
        +		Where("slug = ?", slug).

      
        
        56
        +		SQL()

      
        
        57
        +	if err != nil {

      
        
        58
        +		return dtos.NoteDTO{}, err

      
        
        59
        +	}

      
        
        60
        +

      
        
        61
        +	var note dtos.NoteDTO

      
        
        62
        +	err = s.db.QueryRow(ctx, query, args...).

      
        
        63
        +		Scan(&note.Content, &note.Slug, &note.BurnBeforeExpiration, &note.CreatedAt, &note.ExpiresAt)

      
        
        64
        +

      
        
        65
        +	if errors.Is(err, pgx.ErrNoRows) {

      
        
        66
        +		return dtos.NoteDTO{}, models.ErrNoteNotFound

      
        
        67
        +	}

      
        
        68
        +

      
        
        69
        +	return note, err

      
        
        70
        +}

      
        
        71
        +

      
        
        72
        +func (s *NoteRepo) DeleteBySlug(ctx context.Context, slug dtos.NoteSlugDTO) error {

      
        
        73
        +	query, args, err := pgq.

      
        
        74
        +		Delete("notes").

      
        
        75
        +		Where(pgq.Eq{"slug": slug}).

      
        
        76
        +		SQL()

      
        
        77
        +	if err != nil {

      
        
        78
        +		return err

      
        
        79
        +	}

      
        
        80
        +

      
        
        81
        +	_, err = s.db.Exec(ctx, query, args...)

      
        
        82
        +	if errors.Is(err, pgx.ErrNoRows) {

      
        
        83
        +		return models.ErrNoteNotFound

      
        
        84
        +	}

      
        
        85
        +

      
        
        86
        +	return err

      
        
        87
        +}

      
        
        88
        +

      
        
        89
        +func (s *NoteRepo) SetAuthorIDBySlug(

      
        
        90
        +	ctx context.Context,

      
        
        91
        +	slug dtos.NoteSlugDTO,

      
        
        92
        +	authorID uuid.UUID,

      
        
        93
        +) error {

      
        
        94
        +	tx, err := s.db.Begin(ctx)

      
        
        95
        +	if err != nil {

      
        
        96
        +		return err

      
        
        97
        +	}

      
        
        98
        +	defer tx.Rollback(ctx) //nolint:errcheck

      
        
        99
        +

      
        
        100
        +	var noteID uuid.UUID

      
        
        101
        +	err = tx.QueryRow(ctx, "select id from notes where slug = $1", slug.String()).Scan(&noteID)

      
        
        102
        +	if err != nil {

      
        
        103
        +		if errors.Is(err, pgx.ErrNoRows) {

      
        
        104
        +			return models.ErrNoteNotFound

      
        
        105
        +		}

      
        
        106
        +		return err

      
        
        107
        +	}

      
        
        108
        +

      
        
        109
        +	_, err = tx.Exec(

      
        
        110
        +		ctx,

      
        
        111
        +		"insert into notes_authors (note_id, user_id) values ($1, $2)",

      
        
        112
        +		noteID, authorID,

      
        
        113
        +	)

      
        
        114
        +	if err != nil {

      
        
        115
        +		return err

      
        
        116
        +	}

      
        
        117
        +

      
        
        118
        +	return tx.Commit(ctx)

      
        
        119
        +}

      
M internal/transport/http/apiv1/apiv1.go
···
        2
        2
         

      
        3
        3
         import (

      
        4
        4
         	"github.com/gin-gonic/gin"

      
        
        5
        +	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        5
        6
         	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        6
        7
         )

      
        7
        8
         

      
        8
        9
         type APIV1 struct {

      
        9
        10
         	usersrv usersrv.UserServicer

      
        
        11
        +	notesrv notesrv.NoteServicer

      
        10
        12
         }

      
        11
        13
         

      
        12
        
        -func NewAPIV1(us usersrv.UserServicer) *APIV1 {

      
        
        14
        +func NewAPIV1(

      
        
        15
        +	us usersrv.UserServicer,

      
        
        16
        +	ns notesrv.NoteServicer,

      
        
        17
        +) *APIV1 {

      
        13
        18
         	return &APIV1{

      
        14
        19
         		usersrv: us,

      
        
        20
        +		notesrv: ns,

      
        15
        21
         	}

      
        16
        22
         }

      
        17
        23
         

      ···
        26
        32
         		{

      
        27
        33
         			authorized.POST("/logout", a.logOutHandler)

      
        28
        34
         		}

      
        
        35
        +	}

      
        
        36
        +

      
        
        37
        +	note := r.Group("/note", a.couldBeAuthorizedMiddleware)

      
        
        38
        +	{

      
        
        39
        +		note.GET("/:slug", a.getNoteBySlugHandler)

      
        
        40
        +		note.POST("", a.createNoteHandler)

      
        29
        41
         	}

      
        30
        42
         }

      
M internal/transport/http/apiv1/auth.go
···
        105
        105
         }

      
        106
        106
         

      
        107
        107
         func (a *APIV1) logOutHandler(c *gin.Context) {

      
        108
        
        -	if err := a.usersrv.Logout(c.Request.Context(), getUserID(c)); err != nil {

      
        
        108
        +	if err := a.usersrv.Logout(c.Request.Context(), a.getUserID(c)); err != nil {

      
        109
        109
         		errorResponse(c, err)

      
        110
        110
         		return

      
        111
        111
         	}

      
M internal/transport/http/apiv1/middleware.go
···
        40
        40
         	c.Next()

      
        41
        41
         }

      
        42
        42
         

      
        43
        
        -//nolint:unused // TODO: remove me later

      
        44
        43
         func (a *APIV1) couldBeAuthorizedMiddleware(c *gin.Context) {

      
        45
        44
         	token, ok := getTokenFromAuthHeaders(c)

      
        46
        45
         	if ok {

      ···
        66
        65
         

      
        67
        66
         //nolint:unused // TODO: remove me later

      
        68
        67
         func (a *APIV1) isUserAuthorized(c *gin.Context) bool {

      
        69
        
        -	return !getUserID(c).IsNil()

      
        
        68
        +	return !a.getUserID(c).IsNil()

      
        70
        69
         }

      
        71
        70
         

      
        72
        71
         func getTokenFromAuthHeaders(c *gin.Context) (token string, ok bool) { //nolint:nonamedreturns

      ···
        100
        99
         

      
        101
        100
         // getUserId returns userId from the context

      
        102
        101
         // getting user id is only possible if user is authorized

      
        103
        
        -func getUserID(c *gin.Context) uuid.UUID {

      
        
        102
        +func (a *APIV1) getUserID(c *gin.Context) uuid.UUID {

      
        104
        103
         	userID, exists := c.Get(userIDCtxKey)

      
        105
        104
         	if !exists {

      
        106
        105
         		return uuid.Nil

      
A internal/transport/http/apiv1/note.go
···
        
        1
        +package apiv1

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"log/slog"

      
        
        5
        +	"net/http"

      
        
        6
        +	"time"

      
        
        7
        +

      
        
        8
        +	"github.com/gin-gonic/gin"

      
        
        9
        +	"github.com/olexsmir/onasty/internal/dtos"

      
        
        10
        +	"github.com/olexsmir/onasty/internal/models"

      
        
        11
        +)

      
        
        12
        +

      
        
        13
        +type createNoteRequest struct {

      
        
        14
        +	Content              string    `json:"content"`

      
        
        15
        +	Slug                 string    `json:"slug"`

      
        
        16
        +	BurnBeforeExpiration bool      `json:"burn_before_expiration"`

      
        
        17
        +	ExpiresAt            time.Time `json:"expires_at"`

      
        
        18
        +}

      
        
        19
        +

      
        
        20
        +type createNoteResponse struct {

      
        
        21
        +	Slug string `json:"slug"`

      
        
        22
        +}

      
        
        23
        +

      
        
        24
        +func (a *APIV1) createNoteHandler(c *gin.Context) {

      
        
        25
        +	var req createNoteRequest

      
        
        26
        +	if err := c.ShouldBindJSON(&req); err != nil {

      
        
        27
        +		newError(c, http.StatusBadRequest, "invalid request")

      
        
        28
        +		return

      
        
        29
        +	}

      
        
        30
        +

      
        
        31
        +	note := models.Note{

      
        
        32
        +		Content:              req.Content,

      
        
        33
        +		Slug:                 req.Slug,

      
        
        34
        +		BurnBeforeExpiration: req.BurnBeforeExpiration,

      
        
        35
        +		CreatedAt:            time.Now(),

      
        
        36
        +		ExpiresAt:            req.ExpiresAt,

      
        
        37
        +	}

      
        
        38
        +

      
        
        39
        +	if err := note.Validate(); err != nil {

      
        
        40
        +		newErrorStatus(c, http.StatusBadRequest, err.Error())

      
        
        41
        +		return

      
        
        42
        +	}

      
        
        43
        +

      
        
        44
        +	slog.Debug("userid", "a", a.getUserID(c))

      
        
        45
        +

      
        
        46
        +	slug, err := a.notesrv.Create(c.Request.Context(), dtos.CreateNoteDTO{

      
        
        47
        +		Content:              note.Content,

      
        
        48
        +		UserID:               a.getUserID(c),

      
        
        49
        +		Slug:                 note.Slug,

      
        
        50
        +		BurnBeforeExpiration: note.BurnBeforeExpiration,

      
        
        51
        +		CreatedAt:            note.CreatedAt,

      
        
        52
        +		ExpiresAt:            note.ExpiresAt,

      
        
        53
        +	}, a.getUserID(c))

      
        
        54
        +	if err != nil {

      
        
        55
        +		errorResponse(c, err)

      
        
        56
        +		return

      
        
        57
        +	}

      
        
        58
        +

      
        
        59
        +	c.JSON(http.StatusCreated, createNoteResponse{slug.String()})

      
        
        60
        +}

      
        
        61
        +

      
        
        62
        +type getNoteBySlugResponse struct {

      
        
        63
        +	Content   string    `json:"content"`

      
        
        64
        +	CratedAt  time.Time `json:"crated_at"`

      
        
        65
        +	ExpiresAt time.Time `json:"expires_at"`

      
        
        66
        +}

      
        
        67
        +

      
        
        68
        +func (a *APIV1) getNoteBySlugHandler(c *gin.Context) {

      
        
        69
        +	slug := c.Param("slug")

      
        
        70
        +	note, err := a.notesrv.GetBySlugAndRemoveIfNeeded(c.Request.Context(), dtos.NoteSlugDTO(slug))

      
        
        71
        +	if err != nil {

      
        
        72
        +		errorResponse(c, err)

      
        
        73
        +		return

      
        
        74
        +	}

      
        
        75
        +

      
        
        76
        +	c.JSON(http.StatusOK, getNoteBySlugResponse{

      
        
        77
        +		Content:   note.Content,

      
        
        78
        +		CratedAt:  note.CreatedAt,

      
        
        79
        +		ExpiresAt: note.ExpiresAt,

      
        
        80
        +	})

      
        
        81
        +}

      
M internal/transport/http/apiv1/response.go
···
        15
        15
         

      
        16
        16
         func errorResponse(c *gin.Context, err error) {

      
        17
        17
         	if errors.Is(err, models.ErrUserEmailIsAlreadyInUse) ||

      
        18
        
        -		errors.Is(err, models.ErrUsernameIsAlreadyInUse) {

      
        
        18
        +		errors.Is(err, models.ErrUsernameIsAlreadyInUse) ||

      
        
        19
        +		errors.Is(err, models.ErrNoteContentIsEmpty) ||

      
        
        20
        +		errors.Is(err, models.ErrNoteSlugIsAlreadyInUse) {

      
        19
        21
         		newError(c, http.StatusBadRequest, err.Error())

      
        
        22
        +		return

      
        
        23
        +	}

      
        
        24
        +

      
        
        25
        +	if errors.Is(err, models.ErrNoteExpired) {

      
        
        26
        +		newError(c, http.StatusGone, err.Error())

      
        
        27
        +		return

      
        
        28
        +	}

      
        
        29
        +

      
        
        30
        +	if errors.Is(err, models.ErrNoteNotFound) {

      
        
        31
        +		newErrorStatus(c, http.StatusNotFound, err.Error())

      
        20
        32
         		return

      
        21
        33
         	}

      
        22
        34
         

      ···
        34
        46
         	newInternalError(c, err)

      
        35
        47
         }

      
        36
        48
         

      
        37
        
        -func newError(c *gin.Context, status int, msg string) { //nolint:unparam // TODO: remove me later

      
        
        49
        +func newError(c *gin.Context, status int, msg string) {

      
        38
        50
         	slog.Error(msg, "status", status)

      
        39
        51
         	c.AbortWithStatusJSON(status, response{msg})

      
        40
        52
         }

      
M internal/transport/http/http.go
···
        4
        4
         	"net/http"

      
        5
        5
         

      
        6
        6
         	"github.com/gin-gonic/gin"

      
        
        7
        +	"github.com/olexsmir/onasty/internal/service/notesrv"

      
        7
        8
         	"github.com/olexsmir/onasty/internal/service/usersrv"

      
        8
        9
         	"github.com/olexsmir/onasty/internal/transport/http/apiv1"

      
        9
        10
         )

      
        10
        11
         

      
        11
        12
         type Transport struct {

      
        12
        13
         	usersrv usersrv.UserServicer

      
        
        14
        +	notesrv notesrv.NoteServicer

      
        13
        15
         }

      
        14
        16
         

      
        15
        
        -func NewTransport(us usersrv.UserServicer) *Transport {

      
        
        17
        +func NewTransport(

      
        
        18
        +	us usersrv.UserServicer,

      
        
        19
        +	ns notesrv.NoteServicer,

      
        
        20
        +) *Transport {

      
        16
        21
         	return &Transport{

      
        17
        22
         		usersrv: us,

      
        
        23
        +		notesrv: ns,

      
        18
        24
         	}

      
        19
        25
         }

      
        20
        26
         

      ···
        27
        33
         

      
        28
        34
         	api := r.Group("/api")

      
        29
        35
         	api.GET("/ping", t.pingHandler)

      
        30
        
        -	apiv1.NewAPIV1(t.usersrv).Routes(api.Group("/v1"))

      
        
        36
        +	apiv1.NewAPIV1(t.usersrv, t.notesrv).Routes(api.Group("/v1"))

      
        31
        37
         

      
        32
        38
         	return r.Handler()

      
        33
        39
         }

      
A migrations/20240716235210_notes.down.sql
···
        
        1
        +drop table if exists notes;

      
A migrations/20240716235210_notes.up.sql
···
        
        1
        +create table notes (

      
        
        2
        +  id uuid primary key default uuid_generate_v4(),

      
        
        3
        +  content text not null,

      
        
        4
        +  slug varchar(255) not null unique,

      
        
        5
        +  burn_before_expiration boolean default false,

      
        
        6
        +  created_at timestamptz not null default now(),

      
        
        7
        +  expires_at timestamptz

      
        
        8
        +);

      
A migrations/20240724122920_notes_authors.up.sql
···
        
        1
        +create table notes_authors (

      
        
        2
        +  id uuid primary key default uuid_generate_v4(),

      
        
        3
        +  note_id uuid references notes (id) on delete cascade,

      
        
        4
        +  user_id uuid references users (id) on delete cascade,

      
        
        5
        +  created_at timestamptz not null default now()

      
        
        6
        +);

      
M migrations/Taskfile.yml
···
        17
        17
         

      
        18
        18
           drop:

      
        19
        19
             - migrate -database $POSTGRESQL_DSN -path {{.MIGRATIONS_DIR}} drop

      
        
        20
        +

      
        
        21
        +  current-version:

      
        
        22
        +    - migrate -database $POSTGRESQL_DSN -path {{.MIGRATIONS_DIR}} version