Compare commits

..

3 Commits

Author SHA1 Message Date
ae
ebd85c2e6a
feat: pagination support 2025-04-09 18:10:33 +03:00
ae
47fa47bdc7
feat: remove unnecessary DTO type 2025-04-09 17:44:35 +03:00
ae
e7ba54a992
docs: fix missing comments 2025-04-09 14:07:14 +03:00
6 changed files with 187 additions and 74 deletions

View File

@ -143,10 +143,16 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) {
const listUsers = `-- name: ListUsers :many const listUsers = `-- name: ListUsers :many
SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users SELECT id, username, password_hash, is_admin, created_at, updated_at FROM users
LIMIT $1 OFFSET $2
` `
func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { type ListUsersParams struct {
rows, err := q.db.Query(ctx, listUsers) Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]User, error) {
rows, err := q.db.Query(ctx, listUsers, arg.Limit, arg.Offset)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -67,7 +67,7 @@ type TokenStore interface {
// Mockable user related database operations interface. // Mockable user related database operations interface.
type UserStore interface { type UserStore interface {
CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error) CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error)
ListUsers(ctx context.Context) ([]data.User, error) ListUsers(ctx context.Context, arg data.ListUsersParams) ([]data.User, error)
GetUserByID(ctx context.Context, id uuid.UUID) (data.User, error) GetUserByID(ctx context.Context, id uuid.UUID) (data.User, error)
GetUserByUsername(ctx context.Context, username string) (data.User, error) GetUserByUsername(ctx context.Context, username string) (data.User, error)
UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error
@ -340,7 +340,11 @@ func (rs authResource) OwnerDelete(w http.ResponseWriter, r *http.Request) {
// Handler for listing all users stored in the database. Should only be allowed to be called by // Handler for listing all users stored in the database. Should only be allowed to be called by
// administrator level users. // administrator level users.
func (rs authResource) List(w http.ResponseWriter, r *http.Request) { func (rs authResource) List(w http.ResponseWriter, r *http.Request) {
users, err := rs.Users.ListUsers(r.Context()) limit, offset := getPaginationParams(r)
users, err := rs.Users.ListUsers(r.Context(), data.ListUsersParams{
Limit: limit,
Offset: offset,
})
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to retrieve users") respondError(w, http.StatusInternalServerError, "Failed to retrieve users")
return return

View File

@ -2,13 +2,9 @@ package service
import ( import (
"context" "context"
"crypto/sha1"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings"
"git.umbrella.haus/ae/notatest/internal/data" "git.umbrella.haus/ae/notatest/internal/data"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -73,7 +69,7 @@ func (rs notesResource) Routes() chi.Router {
r.Route(fmt.Sprintf("/{%s}", versionUUIDCtxParameter), func(r chi.Router) { r.Route(fmt.Sprintf("/{%s}", versionUUIDCtxParameter), func(r chi.Router) {
r.Use(uuidCtx(versionUUIDCtxParameter)) r.Use(uuidCtx(versionUUIDCtxParameter))
r.Use(versionCtx(rs.Notes)) // DB -> req. context (scoped version) r.Use(versionCtx(rs.Notes)) // DB -> req. context (scoped version)
r.Get("/", rs.GetFullVersion) // GET /notes/{id}/{id} - get r.Get("/", rs.GetFullVersion) // GET /notes/{id}/{id} - get specific version's contents
}) })
}) })
}) })
@ -118,19 +114,14 @@ func (rs *notesResource) Create(w http.ResponseWriter, r *http.Request) {
// Placeholder contents are decided server-side, so we need to inform the client of them via a // Placeholder contents are decided server-side, so we need to inform the client of them via a
// one-time-use DTO // one-time-use DTO
type response struct { respondJSON(w, http.StatusCreated, map[string]string{
Title string `json:"title"` "title": initVersionTitle,
Content string `json:"content"` "content": initVersionContent,
} })
res := response{
Title: initVersionTitle,
Content: initVersionContent,
}
respondJSON(w, http.StatusCreated, res)
} }
// Handler for listing the metadata of all user's available notes. This metadata contains IDs of
// the note and its owner, its title, and the time it was last updated.
func (rs *notesResource) ListMetadata(w http.ResponseWriter, r *http.Request) { func (rs *notesResource) ListMetadata(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(userCtxKey{}).(*userClaims) user, ok := r.Context().Value(userCtxKey{}).(*userClaims)
if !ok { if !ok {
@ -271,6 +262,7 @@ func (rs *notesResource) CreateVersion(w http.ResponseWriter, r *http.Request) {
- Increment `latest_version` - Increment `latest_version`
- Sync `current_version` with `latest_version` - Sync `current_version` with `latest_version`
*/ */
err := rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{ err := rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{
NoteID: fullNote.NoteID, NoteID: fullNote.NoteID,
Title: *req.Title, Title: *req.Title,
@ -296,36 +288,3 @@ func (rs *notesResource) GetFullVersion(w http.ResponseWriter, r *http.Request)
respondJSON(w, http.StatusOK, fullVersion) respondJSON(w, http.StatusOK, fullVersion)
} }
// Parse `limit` and `offset` 32-bit integer URL parameters from the given request. Defaults to
// limit of 50 and offset 0 if parameters are missing/invalid.
func getPaginationParams(r *http.Request) (limit int32, offset int32) {
defaultLimit := 50
defaultOffset := 0
limitStr := r.URL.Query().Get("limit")
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
defaultLimit = l
}
}
offsetStr := r.URL.Query().Get("offset")
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
defaultOffset = o
}
}
return int32(defaultLimit), int32(defaultOffset)
}
// Concatenate the title and content strings, calculate a SHA-1 hash of the resulting string, and
// return the resulting hash as a string.
func sha1ContentHash(title, content string) string {
hashContent := title + content
hash := sha1.Sum([]byte(hashContent))
hashStr := strings.ToUpper(hex.EncodeToString(hash[:]))
return hashStr
}

View File

@ -9,6 +9,7 @@ import (
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@ -131,3 +132,36 @@ func validateUsername(username string) error {
return nil return nil
} }
// Parse `limit` and `offset` 32-bit integer URL parameters from the given request. Defaults to
// limit of 50 and offset 0 if parameters are missing/invalid.
func getPaginationParams(r *http.Request) (limit int32, offset int32) {
defaultLimit := 50
defaultOffset := 0
limitStr := r.URL.Query().Get("limit")
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
defaultLimit = l
}
}
offsetStr := r.URL.Query().Get("offset")
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
defaultOffset = o
}
}
return int32(defaultLimit), int32(defaultOffset)
}
// Concatenate the title and content strings, calculate a SHA-1 hash of the resulting string, and
// return the resulting hash as a string.
func sha1ContentHash(title, content string) string {
hashContent := title + content
hash := sha1.Sum([]byte(hashContent))
hashStr := strings.ToUpper(hex.EncodeToString(hash[:]))
return hashStr
}

View File

@ -51,19 +51,19 @@ func TestValidatePassword(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tc := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Mock HTTP client if needed // Mock HTTP client if needed
if tt.mockHTTP != nil { if tc.mockHTTP != nil {
mockClient := new(MockHTTPClient) mockClient := new(MockHTTPClient)
tt.mockHTTP(mockClient) tc.mockHTTP(mockClient)
} }
err := validatePassword(tt.password) err := validatePassword(tc.password)
if tt.wantErr == "" { if tc.wantErr == "" {
assert.NoError(t, err) assert.NoError(t, err)
} else { } else {
assert.ErrorContains(t, err, tt.wantErr) assert.ErrorContains(t, err, tc.wantErr)
} }
}) })
} }
@ -103,10 +103,10 @@ func TestPasswordEntropyCalculation(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tc := range tests {
t.Run(tt.password, func(t *testing.T) { t.Run(tc.password, func(t *testing.T) {
entropy := passwordvalidator.GetEntropy(tt.password) entropy := passwordvalidator.GetEntropy(tc.password)
assert.InDelta(t, tt.entropy, entropy, 1.0) assert.InDelta(t, tc.entropy, entropy, 1.0)
}) })
} }
} }
@ -139,13 +139,13 @@ func TestValidateUsername(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tc := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
err := validateUsername(tt.input) err := validateUsername(tc.input)
if tt.wantErr == "" { if tc.wantErr == "" {
assert.NoError(t, err) assert.NoError(t, err)
} else { } else {
assert.ErrorContains(t, err, tt.wantErr) assert.ErrorContains(t, err, tc.wantErr)
} }
}) })
} }
@ -174,10 +174,119 @@ func TestNormalizeUsername(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tc := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
got := normalizeUsername(tt.input) got := normalizeUsername(tc.input)
assert.Equal(t, tt.want, got) assert.Equal(t, tc.want, got)
})
}
}
func TestGetPaginationParams(t *testing.T) {
tests := []struct {
name string
query string
expectedLimit int32
expectedOffset int32
}{
{
name: "defaults",
query: "",
expectedLimit: 50,
expectedOffset: 0,
},
{
name: "valid limit and offset",
query: "limit=25&offset=30",
expectedLimit: 25,
expectedOffset: 30,
},
{
name: "invalid limit",
query: "limit=abc&offset=5",
expectedLimit: 50,
expectedOffset: 5,
},
{
name: "limit zero",
query: "limit=0",
expectedLimit: 50,
expectedOffset: 0,
},
{
name: "limit negative",
query: "limit=-5",
expectedLimit: 50,
expectedOffset: 0,
},
{
name: "offset negative",
query: "offset=-10",
expectedLimit: 50,
expectedOffset: 0,
},
{
name: "invalid offset",
query: "offset=xyz",
expectedLimit: 50,
expectedOffset: 0,
},
{
name: "valid offset zero",
query: "offset=0",
expectedLimit: 50,
expectedOffset: 0,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest("GET", "/test?"+tc.query, nil)
assert.Nil(t, err)
limit, offset := getPaginationParams(req)
assert.Equal(t, tc.expectedLimit, limit)
assert.Equal(t, tc.expectedOffset, offset)
})
}
}
func TestSHA1ContentHash(t *testing.T) {
tests := []struct {
name string
title string
content string
expected string
}{
{
name: "empty strings",
title: "",
content: "",
expected: "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709",
},
{
name: "title only",
title: "hello",
content: "",
expected: "AAF4C61DDCC5E8A2DABEDE0F3B482CD9AEA9434D",
},
{
name: "content only",
title: "",
content: "world",
expected: "7C211433F02071597741E6FF5A8EA34789ABBF43",
},
{
name: "both title and content",
title: "hello",
content: "world",
expected: "6ADFB183A4A2C94A2F92DAB5ADE762A47889A5A1",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := sha1ContentHash(tc.title, tc.content)
assert.Equal(t, tc.expected, result)
}) })
} }
} }

View File

@ -9,7 +9,8 @@ VALUES ($1, $2, true)
RETURNING *; RETURNING *;
-- name: ListUsers :many -- name: ListUsers :many
SELECT * FROM users; SELECT * FROM users
LIMIT $1 OFFSET $2;
-- name: ListAdmins :many -- name: ListAdmins :many
SELECT * FROM users SELECT * FROM users