Compare commits
No commits in common. "2b9b14210cd6dd5e9cc82378586b5e3b3dba62dd" and "869f0887d8634da20c2bfd8b48e406ad068fdb51" have entirely different histories.
2b9b14210c
...
869f0887d8
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,4 @@
|
||||
data/
|
||||
!server/internal/data
|
||||
TASKS.md
|
||||
.env
|
||||
.DS_Store
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// sqlc v1.28.0
|
||||
|
||||
package data
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// sqlc v1.28.0
|
||||
|
||||
package data
|
||||
|
||||
@ -15,7 +15,6 @@ type Note struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
CurrentVersion int32 `json:"current_version"`
|
||||
LatestVersion int32 `json:"latest_version"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
CreatedAt *time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// sqlc v1.28.0
|
||||
// source: note_versions.sql
|
||||
|
||||
package data
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// sqlc v1.28.0
|
||||
// source: notes.sql
|
||||
|
||||
package data
|
||||
@ -15,7 +15,7 @@ import (
|
||||
const createNote = `-- name: CreateNote :one
|
||||
INSERT INTO notes (user_id)
|
||||
VALUES ($1)
|
||||
RETURNING id, user_id, current_version, latest_version, expires_at, created_at, updated_at
|
||||
RETURNING id, user_id, current_version, latest_version, created_at, updated_at
|
||||
`
|
||||
|
||||
func (q *Queries) CreateNote(ctx context.Context, userID uuid.UUID) (Note, error) {
|
||||
@ -26,23 +26,12 @@ func (q *Queries) CreateNote(ctx context.Context, userID uuid.UUID) (Note, error
|
||||
&i.UserID,
|
||||
&i.CurrentVersion,
|
||||
&i.LatestVersion,
|
||||
&i.ExpiresAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteExpiredNotes = `-- name: DeleteExpiredNotes :exec
|
||||
DELETE FROM notes
|
||||
WHERE expires_at < NOW()
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredNotes(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, deleteExpiredNotes)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteNote = `-- name: DeleteNote :exec
|
||||
DELETE FROM notes
|
||||
WHERE id = $1 AND user_id = $2
|
||||
@ -66,7 +55,6 @@ SELECT
|
||||
nv.content,
|
||||
nv.version_number,
|
||||
nv.created_at AS version_created_at,
|
||||
n.expires_at AS note_expires_at,
|
||||
n.created_at AS note_created_at,
|
||||
n.updated_at AS note_updated_at
|
||||
FROM notes n
|
||||
@ -82,7 +70,6 @@ type GetFullNoteRow struct {
|
||||
Content string `json:"content"`
|
||||
VersionNumber int32 `json:"version_number"`
|
||||
VersionCreatedAt *time.Time `json:"version_created_at"`
|
||||
NoteExpiresAt *time.Time `json:"note_expires_at"`
|
||||
NoteCreatedAt *time.Time `json:"note_created_at"`
|
||||
NoteUpdatedAt *time.Time `json:"note_updated_at"`
|
||||
}
|
||||
@ -97,64 +84,17 @@ func (q *Queries) GetFullNote(ctx context.Context, id uuid.UUID) (GetFullNoteRow
|
||||
&i.Content,
|
||||
&i.VersionNumber,
|
||||
&i.VersionCreatedAt,
|
||||
&i.NoteExpiresAt,
|
||||
&i.NoteCreatedAt,
|
||||
&i.NoteUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listExpiredNotes = `-- name: ListExpiredNotes :many
|
||||
SELECT
|
||||
n.id AS note_id,
|
||||
n.user_id AS owner_id,
|
||||
nv.title,
|
||||
n.expires_at
|
||||
FROM notes n
|
||||
JOIN note_versions nv
|
||||
ON n.id = nv.note_id AND n.current_version = nv.version_number
|
||||
WHERE n.expires_at <= NOW()
|
||||
ORDER BY n.expires_at
|
||||
`
|
||||
|
||||
type ListExpiredNotesRow struct {
|
||||
NoteID uuid.UUID `json:"note_id"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
Title string `json:"title"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListExpiredNotes(ctx context.Context) ([]ListExpiredNotesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listExpiredNotes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListExpiredNotesRow
|
||||
for rows.Next() {
|
||||
var i ListExpiredNotesRow
|
||||
if err := rows.Scan(
|
||||
&i.NoteID,
|
||||
&i.OwnerID,
|
||||
&i.Title,
|
||||
&i.ExpiresAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listNotes = `-- name: ListNotes :many
|
||||
SELECT
|
||||
n.id AS note_id,
|
||||
n.user_id AS owner_id,
|
||||
nv.title,
|
||||
n.expires_at,
|
||||
n.updated_at
|
||||
FROM notes n
|
||||
JOIN note_versions nv
|
||||
@ -174,7 +114,6 @@ type ListNotesRow struct {
|
||||
NoteID uuid.UUID `json:"note_id"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
Title string `json:"title"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@ -191,7 +130,6 @@ func (q *Queries) ListNotes(ctx context.Context, arg ListNotesParams) ([]ListNot
|
||||
&i.NoteID,
|
||||
&i.OwnerID,
|
||||
&i.Title,
|
||||
&i.ExpiresAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@ -203,20 +141,3 @@ func (q *Queries) ListNotes(ctx context.Context, arg ListNotesParams) ([]ListNot
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setNoteExpiration = `-- name: SetNoteExpiration :exec
|
||||
UPDATE notes
|
||||
SET expires_at = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND user_id = $3
|
||||
`
|
||||
|
||||
type SetNoteExpirationParams struct {
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetNoteExpiration(ctx context.Context, arg SetNoteExpirationParams) error {
|
||||
_, err := q.db.Exec(ctx, setNoteExpiration, arg.ExpiresAt, arg.ID, arg.UserID)
|
||||
return err
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// sqlc v1.28.0
|
||||
// source: refresh_tokens.sql
|
||||
|
||||
package data
|
||||
@ -41,17 +41,14 @@ func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshToken
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteExpiredRefreshTokens = `-- name: DeleteExpiredRefreshTokens :execrows
|
||||
const deleteExpiredRefreshTokens = `-- name: DeleteExpiredRefreshTokens :exec
|
||||
DELETE FROM refresh_tokens
|
||||
WHERE expires_at < NOW() OR revoked = TRUE
|
||||
WHERE expires_at < NOW()
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredRefreshTokens(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteExpiredRefreshTokens)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
func (q *Queries) DeleteExpiredRefreshTokens(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, deleteExpiredRefreshTokens)
|
||||
return err
|
||||
}
|
||||
|
||||
const getRefreshTokenByHash = `-- name: GetRefreshTokenByHash :one
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// sqlc v1.28.0
|
||||
// source: users.sql
|
||||
|
||||
package data
|
||||
|
@ -9,8 +9,6 @@ import (
|
||||
"git.umbrella.haus/ae/qnote/internal/data"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -40,10 +38,8 @@ type NoteStore interface {
|
||||
|
||||
// Chi HTTP router for notes related CRUD actions.
|
||||
type notesResource struct {
|
||||
Config SvcConfig
|
||||
Notes NoteStore
|
||||
RawQueries *data.Queries
|
||||
DB *pgx.Conn
|
||||
Config SvcConfig
|
||||
Notes NoteStore
|
||||
}
|
||||
|
||||
func (rs notesResource) Routes() chi.Router {
|
||||
@ -254,22 +250,6 @@ func (rs *notesResource) CreateVersion(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt to parse the expiration date from the title
|
||||
expiresAt, err := parseTitleExpiration(req.Title)
|
||||
if err != nil && err != ErrNoExpirationDateFound {
|
||||
log.Error().Err(err).Msg("Failed parsing expiration date from note title")
|
||||
}
|
||||
|
||||
tx, err := rs.DB.Begin(r.Context())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to begin transaction")
|
||||
respondError(w, http.StatusInternalServerError, "Database error")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
qtx := rs.RawQueries.WithTx(tx)
|
||||
|
||||
/*
|
||||
The SQL query handles de-duplication checks and "intelligent" versioning increments, so we
|
||||
don't have to worry about them here (`latest_version` = highest version number that exists
|
||||
@ -283,37 +263,17 @@ func (rs *notesResource) CreateVersion(w http.ResponseWriter, r *http.Request) {
|
||||
- Sync `current_version` with `latest_version`
|
||||
*/
|
||||
|
||||
if expiresAt != nil {
|
||||
err = qtx.SetNoteExpiration(r.Context(), data.SetNoteExpirationParams{
|
||||
ID: fullNote.NoteID,
|
||||
UserID: fullNote.OwnerID,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to set note expiration")
|
||||
respondError(w, http.StatusInternalServerError, "Failed to set note expiration")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = qtx.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{
|
||||
err := rs.Notes.CreateNoteVersion(r.Context(), data.CreateNoteVersionParams{
|
||||
NoteID: fullNote.NoteID,
|
||||
Title: *req.Title,
|
||||
Content: *req.Content,
|
||||
ContentHash: sha1ContentHash(*req.Title, *req.Content),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create new note version")
|
||||
respondError(w, http.StatusInternalServerError, "Failed to create note version")
|
||||
return
|
||||
}
|
||||
|
||||
if err = tx.Commit(r.Context()); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to commit transaction")
|
||||
respondError(w, http.StatusInternalServerError, "Database error")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -44,10 +43,8 @@ func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error {
|
||||
Tokens: q,
|
||||
}
|
||||
notesRouter := notesResource{
|
||||
Config: config,
|
||||
Notes: q, // Wrapped (to be unit testable with mock DB)
|
||||
RawQueries: q, // Passed separately to allow tx. usage
|
||||
DB: conn,
|
||||
Config: config,
|
||||
Notes: q,
|
||||
}
|
||||
|
||||
// Global middlewares
|
||||
@ -66,38 +63,20 @@ func Run(conn *pgx.Conn, q *data.Queries, config SvcConfig) error {
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.AllowContentType("application/json"))
|
||||
|
||||
// Cleanup workers
|
||||
scheduleTokenCleanup(context.Background(), q)
|
||||
|
||||
// Routes grouped by functionality (we must prefix the API routes with `/api`
|
||||
// as the domain will be the same for the front and back ends)
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Mount("/auth", authRouter.Routes())
|
||||
r.Mount("/notes", notesRouter.Routes())
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "pong",
|
||||
})
|
||||
})
|
||||
r.Get("/ping", ping)
|
||||
})
|
||||
|
||||
log.Info().Msg("Starting server on :8080")
|
||||
return http.ListenAndServe(":8080", r)
|
||||
}
|
||||
|
||||
// Start worker that automatically cleans up the `notes` (cascading to `note_versions`) and
|
||||
// `refresh_tokens` tables from expired (or revoked) entries. The tasks run once during
|
||||
// initialization and then once an hour until the backend is shutdown.
|
||||
func scheduleTokenCleanup(ctx context.Context, q *data.Queries) {
|
||||
cleanupNotes(ctx, q)
|
||||
cleanupRefreshTokens(ctx, q)
|
||||
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
cleanupCtx := context.Background()
|
||||
cleanupNotes(cleanupCtx, q)
|
||||
cleanupRefreshTokens(cleanupCtx, q)
|
||||
}
|
||||
}()
|
||||
func ping(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "pong",
|
||||
})
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@ -13,12 +12,8 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.umbrella.haus/ae/qnote/internal/data"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -29,25 +24,11 @@ const (
|
||||
minUsernameLength = 3
|
||||
maxUsernameLength = 20
|
||||
|
||||
maxFutureExpirationYears = 10
|
||||
|
||||
hibpAPI = "https://api.pwnedpasswords.com/range" // Doesn't require an API key
|
||||
)
|
||||
|
||||
var (
|
||||
usernameRegex = regexp.MustCompile("^[a-z0-9_]+$")
|
||||
|
||||
// Format: @exp:2025-06-15 or @expires:2025-06-15
|
||||
dateFormatRegex = regexp.MustCompile(`^@(?:exp|expires):(\d{4}-\d{2}-\d{2})`)
|
||||
|
||||
// Format: @exp:+7d or @expires:+7d (7 days from now),
|
||||
// supports d (days), w (weeks), m (months), y (years)
|
||||
relativeFormatRegex = regexp.MustCompile(`^@(?:exp|expires):\+(\d+)([dwmy])`)
|
||||
|
||||
ErrNoExpirationDateFound = errors.New("no expiration date found")
|
||||
ErrInvalidExpirationDate = errors.New("invalid expiration date format")
|
||||
ErrPastExpirationDate = errors.New("expiration date cannot be in the past")
|
||||
ErrExpirationTooFar = fmt.Errorf("expiration date too far in the future (max. %d years)", maxFutureExpirationYears)
|
||||
)
|
||||
|
||||
func respondJSON(w http.ResponseWriter, status int, data any) {
|
||||
@ -205,114 +186,3 @@ func sha1ContentHash(title, content string) string {
|
||||
|
||||
return hashStr
|
||||
}
|
||||
|
||||
func parseTitleExpiration(title *string) (*time.Time, error) {
|
||||
// Absolute date format: '@exp:YYYY-MM-DD' (or '@expires:')
|
||||
if match := dateFormatRegex.FindStringSubmatch(*title); match != nil {
|
||||
dateStr := match[1]
|
||||
|
||||
expiresAt, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidExpirationDate
|
||||
}
|
||||
|
||||
if err := validateExpirationDate(expiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set midnight at the end of the specified day (+0000 UTC)
|
||||
expiresAt = time.Date(expiresAt.Year(), expiresAt.Month(), expiresAt.Day(), 23, 59, 59, 0, time.UTC)
|
||||
|
||||
return &expiresAt, nil
|
||||
}
|
||||
|
||||
if match := relativeFormatRegex.FindStringSubmatch(*title); match != nil {
|
||||
amount := match[1]
|
||||
unit := match[2]
|
||||
|
||||
var amountInt int
|
||||
_, err := fmt.Sscanf(amount, "%d", &amountInt)
|
||||
if err != nil || amountInt <= 0 {
|
||||
return nil, ErrInvalidExpirationDate
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var expiresAt time.Time
|
||||
|
||||
switch unit {
|
||||
case "d":
|
||||
expiresAt = now.AddDate(0, 0, amountInt)
|
||||
case "w":
|
||||
expiresAt = now.AddDate(0, 0, amountInt*7)
|
||||
case "m":
|
||||
expiresAt = now.AddDate(0, amountInt, 0)
|
||||
case "y":
|
||||
expiresAt = now.AddDate(amountInt, 0, 0)
|
||||
default:
|
||||
return nil, ErrInvalidExpirationDate
|
||||
}
|
||||
|
||||
if err := validateExpirationDate(expiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set midnight at the end of the specified day (+0000 UTC)
|
||||
expiresAt = time.Date(expiresAt.Year(), expiresAt.Month(), expiresAt.Day(), 23, 59, 59, 0, time.UTC)
|
||||
|
||||
return &expiresAt, nil
|
||||
}
|
||||
|
||||
return nil, ErrNoExpirationDateFound
|
||||
}
|
||||
|
||||
func validateExpirationDate(date time.Time) error {
|
||||
now := time.Now()
|
||||
|
||||
if date.Before(now) {
|
||||
return ErrPastExpirationDate
|
||||
}
|
||||
|
||||
maxDate := now.AddDate(maxFutureExpirationYears, 0, 0)
|
||||
if date.After(maxDate) {
|
||||
return ErrExpirationTooFar
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupNotes(ctx context.Context, q *data.Queries) {
|
||||
expiredNotes, err := q.ListExpiredNotes(ctx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed querying expired notes")
|
||||
return
|
||||
}
|
||||
|
||||
if len(expiredNotes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Log what we're about to delete to be able to track potential bugs in the expiration implementation
|
||||
for _, note := range expiredNotes {
|
||||
log.Debug().Msgf("Deleting expired note: %s (ID: %s, UID: %s), expired at %s",
|
||||
note.Title, note.NoteID, note.OwnerID, note.ExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
if err = q.DeleteExpiredNotes(ctx); err != nil {
|
||||
log.Error().Err(err).Msg("Failed deleting expired notes")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Msgf("Successfully deleted %d expired notes during scheduled cleanup", len(expiredNotes))
|
||||
}
|
||||
|
||||
func cleanupRefreshTokens(ctx context.Context, q *data.Queries) {
|
||||
rowsAffected, err := q.DeleteExpiredRefreshTokens(ctx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed cleaning up refresh tokens")
|
||||
return
|
||||
}
|
||||
|
||||
if rowsAffected > 0 {
|
||||
log.Info().Msgf("Cleaned up %d expired/revoked refresh tokens during scheduled cleanup", rowsAffected)
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
@ -291,170 +290,6 @@ func TestSHA1ContentHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTitleAbsoluteExpiration(t *testing.T) {
|
||||
threeDaysLater := time.Now().AddDate(0, 0, 3)
|
||||
threeDaysInPast := time.Now().AddDate(0, 0, -3)
|
||||
overMaxYearsLater := time.Now().AddDate(maxFutureExpirationYears+1, 0, 0)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
expected *time.Time
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "Valid absolute date",
|
||||
title: fmt.Sprintf("@exp:%s Task", formatAbsDate(t, threeDaysLater)),
|
||||
expected: timePtr(t, createEndOfDay(t, threeDaysLater)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "Valid absolute date with expires keyword",
|
||||
title: fmt.Sprintf("@expires:%s Task", formatAbsDate(t, threeDaysLater)),
|
||||
expected: timePtr(t, createEndOfDay(t, threeDaysLater)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "Absolute date in the past",
|
||||
title: fmt.Sprintf("@exp:%s Task", formatAbsDate(t, threeDaysInPast)),
|
||||
expected: nil,
|
||||
err: ErrPastExpirationDate,
|
||||
},
|
||||
{
|
||||
name: "Absolute date too far in the future",
|
||||
title: fmt.Sprintf("@exp:%s Task", formatAbsDate(t, overMaxYearsLater)),
|
||||
expected: nil,
|
||||
err: ErrExpirationTooFar,
|
||||
},
|
||||
{
|
||||
name: "Invalid absolute date format",
|
||||
title: "@exp:2028-13-31 Task", // Invalid month
|
||||
expected: nil,
|
||||
err: ErrInvalidExpirationDate,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := parseTitleExpiration(&tc.title)
|
||||
if tc.err != nil {
|
||||
if !errors.Is(err, tc.err) {
|
||||
t.Errorf("Expected error %s, got %s", tc.err, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("Unexpected error: %s", err)
|
||||
}
|
||||
|
||||
if tc.expected == nil && result != nil {
|
||||
t.Errorf("Expected nil result, got %s", *result)
|
||||
} else if tc.expected != nil && result != nil {
|
||||
if !tc.expected.Equal(*result) {
|
||||
t.Errorf("Expected %s, got %s", *tc.expected, *result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTitleRelativeExpiration(t *testing.T) {
|
||||
threeDaysLater := time.Now().AddDate(0, 0, 3)
|
||||
threeWeeksLater := time.Now().AddDate(0, 0, 3*7)
|
||||
threeMonthsLater := time.Now().AddDate(0, 3, 0)
|
||||
threeYearsLater := time.Now().AddDate(3, 0, 0)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
expected *time.Time
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "Valid relative date format with days",
|
||||
title: "@exp:+3d Task",
|
||||
expected: timePtr(t, createEndOfDay(t, threeDaysLater)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "Valid relative date format with weeks",
|
||||
title: "@exp:+3w Task",
|
||||
expected: timePtr(t, createEndOfDay(t, threeWeeksLater)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "Valid relative date format with months",
|
||||
title: "@exp:+3m Task",
|
||||
expected: timePtr(t, createEndOfDay(t, threeMonthsLater)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "Valid relative date format with years",
|
||||
title: "@exp:+3y Task",
|
||||
expected: timePtr(t, createEndOfDay(t, threeYearsLater)),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "Invalid relative amount (zero)",
|
||||
title: "@exp:+0d Task",
|
||||
expected: nil,
|
||||
err: ErrInvalidExpirationDate,
|
||||
},
|
||||
{
|
||||
name: "Invalid relative amount (negative)",
|
||||
title: "@exp:-1d Task",
|
||||
expected: nil,
|
||||
err: ErrNoExpirationDateFound, // Doesn't match either of the RegExs
|
||||
},
|
||||
{
|
||||
name: "Invalid relative unit",
|
||||
title: "@exp:+30a Task",
|
||||
expected: nil,
|
||||
err: ErrNoExpirationDateFound, // Doesn't match either of the RegExs
|
||||
},
|
||||
{
|
||||
name: "Relative date too far in the future",
|
||||
title: fmt.Sprintf("@exp:+%dy Task", maxFutureExpirationYears+1),
|
||||
expected: nil,
|
||||
err: ErrExpirationTooFar,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := parseTitleExpiration(&tc.title)
|
||||
if tc.err != nil {
|
||||
if !errors.Is(err, tc.err) {
|
||||
t.Errorf("Expected error %s, got %s", tc.err, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("Unexpected error: %s", err)
|
||||
}
|
||||
|
||||
if tc.expected == nil && result != nil {
|
||||
t.Errorf("Expected nil result, got %s", *result)
|
||||
} else if tc.expected != nil && result != nil {
|
||||
if !tc.expected.Equal(*result) {
|
||||
t.Errorf("Expected %s, got %s", *tc.expected, *result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createEndOfDay(t *testing.T, tm time.Time) time.Time {
|
||||
t.Helper()
|
||||
return time.Date(tm.Year(), tm.Month(), tm.Day(), 23, 59, 59, 0, time.UTC)
|
||||
}
|
||||
|
||||
func timePtr(t *testing.T, tm time.Time) *time.Time {
|
||||
t.Helper()
|
||||
return &tm
|
||||
}
|
||||
|
||||
func formatAbsDate(t *testing.T, tm time.Time) string {
|
||||
t.Helper()
|
||||
return tm.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func genRandomString(t *testing.T, length int) string {
|
||||
t.Helper()
|
||||
|
||||
@ -464,6 +299,5 @@ func genRandomString(t *testing.T, length int) string {
|
||||
for i := range b {
|
||||
b[i] = charset[seededRand.Intn(len(charset))]
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
@ -23,7 +23,6 @@ CREATE TABLE IF NOT EXISTS notes (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
current_version INT NOT NULL DEFAULT 1, -- active version (can be historical)
|
||||
latest_version INT NOT NULL DEFAULT 1, -- highest version number
|
||||
expires_at TIMESTAMPTZ DEFAULT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@ -46,6 +45,5 @@ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens(expir
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user_updated ON notes(user_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_current_version ON notes(current_version);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_expires_at ON notes(expires_at) WHERE expires_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_note_versions_content_hash ON note_versions(note_id, content_hash);
|
||||
|
@ -8,7 +8,6 @@ SELECT
|
||||
n.id AS note_id,
|
||||
n.user_id AS owner_id,
|
||||
nv.title,
|
||||
n.expires_at,
|
||||
n.updated_at
|
||||
FROM notes n
|
||||
JOIN note_versions nv
|
||||
@ -25,7 +24,6 @@ SELECT
|
||||
nv.content,
|
||||
nv.version_number,
|
||||
nv.created_at AS version_created_at,
|
||||
n.expires_at AS note_expires_at,
|
||||
n.created_at AS note_created_at,
|
||||
n.updated_at AS note_updated_at
|
||||
FROM notes n
|
||||
@ -36,24 +34,3 @@ WHERE n.id = $1;
|
||||
-- name: DeleteNote :exec
|
||||
DELETE FROM notes
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: SetNoteExpiration :exec
|
||||
UPDATE notes
|
||||
SET expires_at = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND user_id = $3;
|
||||
|
||||
-- name: ListExpiredNotes :many
|
||||
SELECT
|
||||
n.id AS note_id,
|
||||
n.user_id AS owner_id,
|
||||
nv.title,
|
||||
n.expires_at
|
||||
FROM notes n
|
||||
JOIN note_versions nv
|
||||
ON n.id = nv.note_id AND n.current_version = nv.version_number
|
||||
WHERE n.expires_at <= NOW()
|
||||
ORDER BY n.expires_at;
|
||||
|
||||
-- name: DeleteExpiredNotes :exec
|
||||
DELETE FROM notes
|
||||
WHERE expires_at < NOW();
|
||||
|
@ -20,6 +20,6 @@ UPDATE refresh_tokens
|
||||
SET revoked = TRUE
|
||||
WHERE user_id = $1;
|
||||
|
||||
-- name: DeleteExpiredRefreshTokens :execrows
|
||||
-- name: DeleteExpiredRefreshTokens :exec
|
||||
DELETE FROM refresh_tokens
|
||||
WHERE expires_at < NOW() OR revoked = TRUE;
|
||||
WHERE expires_at < NOW();
|
Loading…
x
Reference in New Issue
Block a user