feat: pagination support

This commit is contained in:
ae 2025-04-09 18:10:33 +03:00
parent 47fa47bdc7
commit ebd85c2e6a
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
6 changed files with 179 additions and 62 deletions

View File

@ -143,10 +143,16 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) {
const listUsers = `-- name: ListUsers :many
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) {
rows, err := q.db.Query(ctx, listUsers)
type ListUsersParams struct {
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 {
return nil, err
}

View File

@ -67,7 +67,7 @@ type TokenStore interface {
// Mockable user related database operations interface.
type UserStore interface {
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)
GetUserByUsername(ctx context.Context, username string) (data.User, 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
// administrator level users.
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 {
respondError(w, http.StatusInternalServerError, "Failed to retrieve users")
return

View File

@ -2,13 +2,9 @@ package service
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"git.umbrella.haus/ae/notatest/internal/data"
"github.com/go-chi/chi/v5"
@ -292,36 +288,3 @@ func (rs *notesResource) GetFullVersion(w http.ResponseWriter, r *http.Request)
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"
"net/http"
"regexp"
"strconv"
"strings"
"unicode/utf8"
@ -131,3 +132,36 @@ func validateUsername(username string) error {
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 {
t.Run(tt.name, func(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Mock HTTP client if needed
if tt.mockHTTP != nil {
if tc.mockHTTP != nil {
mockClient := new(MockHTTPClient)
tt.mockHTTP(mockClient)
tc.mockHTTP(mockClient)
}
err := validatePassword(tt.password)
if tt.wantErr == "" {
err := validatePassword(tc.password)
if tc.wantErr == "" {
assert.NoError(t, err)
} 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 {
t.Run(tt.password, func(t *testing.T) {
entropy := passwordvalidator.GetEntropy(tt.password)
assert.InDelta(t, tt.entropy, entropy, 1.0)
for _, tc := range tests {
t.Run(tc.password, func(t *testing.T) {
entropy := passwordvalidator.GetEntropy(tc.password)
assert.InDelta(t, tc.entropy, entropy, 1.0)
})
}
}
@ -139,13 +139,13 @@ func TestValidateUsername(t *testing.T) {
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateUsername(tt.input)
if tt.wantErr == "" {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateUsername(tc.input)
if tc.wantErr == "" {
assert.NoError(t, err)
} 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 {
t.Run(tt.name, func(t *testing.T) {
got := normalizeUsername(tt.input)
assert.Equal(t, tt.want, got)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := normalizeUsername(tc.input)
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 *;
-- name: ListUsers :many
SELECT * FROM users;
SELECT * FROM users
LIMIT $1 OFFSET $2;
-- name: ListAdmins :many
SELECT * FROM users