Compare commits
21 Commits
bc291a6941
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9ef9bfba2 | |||
| 6b505a5da8 | |||
| 0e48b345eb | |||
| 649134422e | |||
| 5587583ed3 | |||
| 77c60de4f8 | |||
| e591c5a4ae | |||
| 6428dd2157 | |||
| 5cf0f637f8 | |||
| 6b47951ff8 | |||
| 9527311a12 | |||
| 52405411d3 | |||
| 4e5b535f09 | |||
| de6c9d6ae5 | |||
| a33cedf09e | |||
| 8749ff3c03 | |||
| 0935788b69 | |||
| 45c394cded | |||
|
|
d67ce0d00b | ||
|
|
19fbe8191d | ||
|
|
981142eb60 |
70
.gitea/workflows/test.yml
Normal file
70
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
build_path: _build
|
||||
|
||||
jobs:
|
||||
go-test:
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /cache/tools
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
|
||||
- name: Get go-hashfiles
|
||||
uses: https://gitea.com/actions/go-hashfiles@v0.0.1
|
||||
id: hash-go
|
||||
with:
|
||||
patterns: |-
|
||||
go.mod
|
||||
go.sum
|
||||
|
||||
- name: Echo hash
|
||||
run: echo ${{ steps.hash-go.outputs.hash }}
|
||||
|
||||
- name: Cache go
|
||||
id: cache-go
|
||||
uses: https://github.com/actions/cache@v3 # Action cache
|
||||
with: # specify with your GOMODCACHE and GOCACHE
|
||||
path: |-
|
||||
/root/go/pkg/mod
|
||||
/root/.cache/go-build
|
||||
key: go_cache-${{ steps.hash-go.outputs.hash }}
|
||||
restore-keys: |-
|
||||
go_cache-${{ steps.hash-go.outputs.hash }}
|
||||
|
||||
- name: Check Formatting
|
||||
run: |
|
||||
gofmt -l .
|
||||
exit $(gofmt -l . | wc -l)
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v ./...
|
||||
|
||||
- name: Generate coverage
|
||||
run: |
|
||||
mkdir -p ${build_path}
|
||||
go test -v -coverprofile=${build_path}/coverage.out ./...
|
||||
|
||||
- name: Code Coverage Report
|
||||
uses: irongut/CodeCoverageSummary@v1.3.0
|
||||
with:
|
||||
filename: _build/coverage.out
|
||||
badge: true
|
||||
fail_below_min: true
|
||||
format: markdown
|
||||
hide_branch_rate: true
|
||||
hide_complexity: false
|
||||
indicators: true
|
||||
output: both
|
||||
thresholds: '60 80'
|
||||
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"golang.go",
|
||||
"skellock.just"
|
||||
]
|
||||
}
|
||||
27
CHANGELOG.md
Normal file
27
CHANGELOG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
A `### Breaking` section is used in addition to Keep a Changelog's standard sections to explicitly document changes that are backwards-incompatible but would otherwise appear under `### Changed`. Entries under `### Breaking` trigger a major version bump in automated release recommendation logic.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking
|
||||
|
||||
### Added
|
||||
|
||||
- SQLite based server
|
||||
- Simple CLI for interacting with server
|
||||
|
||||
### Changed
|
||||
|
||||
### Removed
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
|
||||
[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/894c2fb...main
|
||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Gotes
|
||||
|
||||
A simple golang based notes application
|
||||
|
||||
## Current state
|
||||
|
||||
Server application runs independantly & binds to port 8080 storing data in an sqlite table
|
||||
|
||||
Client CLI supports crud operations for notes with arguments consumed from flags
|
||||
|
||||
## TODO
|
||||
- [ ] Implement `title` support in cli
|
||||
- [ ] Refactor cli interactions to use a repository/notes interface wrapped implementation
|
||||
- [ ] General project structure tidy up
|
||||
- [ ] Update simplecli to use positional arguments comined with flags
|
||||
- [ ] Create unified cmd entry point starting server in go routine closing after command completed
|
||||
- [ ] Create config module for controlling server startup config
|
||||
- [ ] Implement filesystem based notestore
|
||||
- [ ] Implement webdav based notestore
|
||||
- [ ] Implement git based notestore
|
||||
- [ ] Create TUI based on charmbracelet eco system
|
||||
|
||||
### Target structure
|
||||
|- config
|
||||
|- cmd
|
||||
| |- CLI (contains unified entrypoint with positional args for starting server / client ops)
|
||||
| |- UnifiedCLI (CLI with auto background server)
|
||||
| |- TUI
|
||||
|- internal
|
||||
| |- models
|
||||
| |- repository
|
||||
| | |- notes
|
||||
| | | |- sqlite
|
||||
| | | |- server
|
||||
| | | |- filesystem ... etc
|
||||
| |- service
|
||||
| | | |- notes
|
||||
| |- config
|
||||
| |- handlers (HTTP server handlers)
|
||||
29
cmd/server/main.go
Normal file
29
cmd/server/main.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.hrafn.xyz/aether/gotes/internal/repository"
|
||||
"git.hrafn.xyz/aether/gotes/internal/store/sqlite"
|
||||
"git.hrafn.xyz/aether/gotes/internal/cli/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
databasefile := flag.String("db", filepath.Join(os.Getenv("HOME"), "notes.db"), "Path to the SQLite database file")
|
||||
dbPath := *databasefile
|
||||
sqliteStore, err := sqlite.NewSQLiteStore(context.Background(), dbPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer sqliteStore.Close()
|
||||
repo := repository.NewNoteRepository(sqliteStore)
|
||||
s := server.GetServer(repo, log.Default())
|
||||
err = s.Start(":8080")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.hrafn.xyz/aether/notes/client"
|
||||
"git.hrafn.xyz/aether/notes/internal/repository"
|
||||
"git.hrafn.xyz/aether/notes/internal/store/sqlite"
|
||||
"git.hrafn.xyz/aether/notes/server"
|
||||
"git.hrafn.xyz/aether/gotes/internal/cli/client"
|
||||
)
|
||||
|
||||
func main() {
|
||||
serverMode := flag.Bool("server", false, "Start the application in server mode")
|
||||
list := flag.Bool("list", false, "List all notes in the database")
|
||||
create := flag.Bool("create", false, "Create a new note")
|
||||
update := flag.Bool("update", false, "Update an existing note")
|
||||
delete := flag.Bool("delete", false, "Delete a note from the database")
|
||||
get := flag.Bool("get", false, "Get a note by ID")
|
||||
|
||||
databasefile := flag.String("db", filepath.Join(os.Getenv("HOME"), "notes.db"), "Path to the SQLite database file (only used in server mode)")
|
||||
id := flag.Int("id", 0, "ID of the note to get, update, or delete (only used with -get, -update, or -delete)")
|
||||
content := flag.String("content", "", "Content of the note to create or update (only used with -create or -update)")
|
||||
|
||||
flag.Parse()
|
||||
modes := map[string]bool{
|
||||
"server": *serverMode,
|
||||
"list": *list,
|
||||
"create": *create,
|
||||
"update": *update,
|
||||
"delete": *delete,
|
||||
"get": *get,
|
||||
}
|
||||
|
||||
enabledCount := 0
|
||||
@@ -41,7 +34,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
if enabledCount == 0 {
|
||||
log.Fatal("No mode specified. Use -server, -list, -create, -update, -delete, or -get.")
|
||||
log.Fatal("No mode specified. Use -list, -create, -update, -delete, or -get.")
|
||||
os.Exit(1)
|
||||
}
|
||||
if enabledCount > 1 {
|
||||
@@ -49,21 +42,6 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *serverMode {
|
||||
dbPath := *databasefile
|
||||
sqliteStore, err := sqlite.NewSQLiteStore(context.Background(), dbPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer sqliteStore.Close()
|
||||
repo := repository.NewNoteRepository(sqliteStore)
|
||||
s := server.GetServer(repo, log.Default())
|
||||
err = s.Start(":8080")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
if *list {
|
||||
client.ListNotes()
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
||||
module git.hrafn.xyz/aether/notes
|
||||
module git.hrafn.xyz/aether/gotes
|
||||
|
||||
go 1.26.1
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -20,14 +21,16 @@ func doRequest(method, url string, body any) []byte {
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal request body: %v", err)
|
||||
fmt.Println("Failed to marshal request body")
|
||||
os.Exit(1)
|
||||
}
|
||||
reqBody = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, reqBody)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create request: %v", err)
|
||||
fmt.Println("Failed to create request")
|
||||
os.Exit(1)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -35,7 +38,9 @@ func doRequest(method, url string, body any) []byte {
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to send request: %v", err)
|
||||
fmt.Println("Failed to send request is the server running?")
|
||||
os.Exit(1)
|
||||
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -79,12 +84,13 @@ func ListNotes() {
|
||||
fmt.Printf("| %-*s | %-*s | %-*s |\n", idW, id, updatedW, updated, contentW, content)
|
||||
}
|
||||
sep := fmt.Sprintf("|-%s-|-%s-|-%s-|", strings.Repeat("-", idW), strings.Repeat("-", updatedW), strings.Repeat("-", contentW))
|
||||
|
||||
fmt.Println(strings.Repeat("-", idW+updatedW+contentW+10))
|
||||
row("ID", "Updated", "Content")
|
||||
fmt.Println(sep)
|
||||
for _, n := range notes {
|
||||
row(fmt.Sprintf("%d", n.ID), n.LastUpdate.Format("2006-01-02 15:04:05"), n.Content)
|
||||
}
|
||||
fmt.Println(strings.Repeat("-", idW+updatedW+contentW+10))
|
||||
}
|
||||
|
||||
func CreateNote(content string) {
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.hrafn.xyz/aether/notes/internal/repository"
|
||||
"git.hrafn.xyz/aether/gotes/internal/repository"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -85,12 +85,13 @@ func (s *Server) getNotes(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) createNote(w http.ResponseWriter, r *http.Request) {
|
||||
note := struct {
|
||||
Content string `json:"content"`
|
||||
Title string `json:"title"`
|
||||
}{}
|
||||
if err := json.NewDecoder(r.Body).Decode(¬e); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
createdNote, err := s.noteRepo.CreateNote(r.Context(), note.Content)
|
||||
createdNote, err := s.noteRepo.CreateNote(r.Context(), note.Content, note.Title)
|
||||
if err != nil {
|
||||
s.getErrorResponse(w, err)
|
||||
return
|
||||
@@ -122,12 +123,13 @@ func (s *Server) updateNoteByID(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
note := struct {
|
||||
Content string `json:"content"`
|
||||
Title string `json:"title"`
|
||||
}{}
|
||||
if err := json.NewDecoder(r.Body).Decode(¬e); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
updatedNote, err := s.noteRepo.UpdateNote(r.Context(), id, note.Content)
|
||||
updatedNote, err := s.noteRepo.UpdateNote(r.Context(), id, repository.NoteUpdate{Content: note.Content, Title: note.Title})
|
||||
if err != nil {
|
||||
s.getErrorResponse(w, err)
|
||||
return
|
||||
@@ -4,12 +4,37 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
NoteTitleMaxLength = 50
|
||||
)
|
||||
|
||||
// Note represents a single note with its metadata.
|
||||
type Note struct {
|
||||
// ID is the unique identifier of the note.
|
||||
ID int `json:"id"`
|
||||
// Title is a short title for human identification of a note
|
||||
Title string `json:"title"`
|
||||
// LastUpdate is the timestamp of when the note was last modified.
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
// Content is the actual text content of the note.
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (n *Note) Validate() error {
|
||||
errs := []error{}
|
||||
if n.Title == "" {
|
||||
errs = append(errs, &ErrEmptyNoteTitle{})
|
||||
}
|
||||
if n.Content == "" {
|
||||
errs = append(errs, &ErrEmptyNoteContent{})
|
||||
}
|
||||
if len(errs) != 0 {
|
||||
return &ErrNoteIncomplete{why: errs}
|
||||
}
|
||||
if titleLen := len(n.Title); titleLen > NoteTitleMaxLength {
|
||||
return &ErrNoteTitleOverflow{
|
||||
length: titleLen,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
42
internal/models/note_errors.go
Normal file
42
internal/models/note_errors.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ErrEmptyNoteContent struct{}
|
||||
type ErrEmptyNoteTitle struct{}
|
||||
type ErrNoteIncomplete struct {
|
||||
why []error
|
||||
}
|
||||
type ErrNoteTitleOverflow struct {
|
||||
length int
|
||||
}
|
||||
|
||||
func (e *ErrEmptyNoteContent) Error() string {
|
||||
return "content cannot be empty"
|
||||
}
|
||||
|
||||
func (e *ErrEmptyNoteTitle) Error() string {
|
||||
return "title cannot be empty"
|
||||
}
|
||||
|
||||
func (e *ErrNoteIncomplete) Error() string {
|
||||
if len(e.why) == 0 {
|
||||
panic("ok so if we use this we need to know why it is incomplete")
|
||||
}
|
||||
s := make([]string, len(e.why))
|
||||
for i, err := range e.why {
|
||||
s[i] = err.Error()
|
||||
}
|
||||
return "note incomplete: " + strings.Join(s, " & ")
|
||||
}
|
||||
|
||||
func (e *ErrNoteIncomplete) Unwrap() []error {
|
||||
return e.why
|
||||
}
|
||||
|
||||
func (e *ErrNoteTitleOverflow) Error() string {
|
||||
return fmt.Sprintf("title max length %d, %d provided", NoteTitleMaxLength, e.length)
|
||||
}
|
||||
146
internal/models/note_test.go
Normal file
146
internal/models/note_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.hrafn.xyz/aether/gotes/internal/models"
|
||||
)
|
||||
|
||||
func TestContentCompletionNoteValidation(t *testing.T) {
|
||||
testcases := []struct {
|
||||
name string
|
||||
note models.Note
|
||||
expectedError bool
|
||||
expectedReasons []error
|
||||
}{
|
||||
{
|
||||
name: "Completely Empty",
|
||||
note: models.Note{
|
||||
Title: "",
|
||||
Content: "",
|
||||
ID: 1,
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
expectedError: true,
|
||||
expectedReasons: []error{&models.ErrEmptyNoteContent{}, &models.ErrEmptyNoteTitle{}},
|
||||
},
|
||||
{
|
||||
name: "Missing title",
|
||||
note: models.Note{
|
||||
Title: "",
|
||||
Content: "Here we go!",
|
||||
ID: 1,
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
expectedError: true,
|
||||
expectedReasons: []error{&models.ErrEmptyNoteTitle{}},
|
||||
},
|
||||
{
|
||||
name: "Missing content",
|
||||
note: models.Note{
|
||||
Title: "I am not content with this content",
|
||||
Content: "",
|
||||
ID: 1,
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
expectedError: true,
|
||||
expectedReasons: []error{&models.ErrEmptyNoteContent{}},
|
||||
},
|
||||
{
|
||||
name: "Good note",
|
||||
note: models.Note{
|
||||
Title: "I am content with this content",
|
||||
Content: "this content",
|
||||
ID: 1,
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
expectedError: false,
|
||||
expectedReasons: []error{},
|
||||
},
|
||||
}
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.note.Validate()
|
||||
|
||||
// Early out when not needing to test because we're not expecting an error and we didn't get one
|
||||
if !tc.expectedError && err == nil {
|
||||
fmt.Printf("Test case '%s' passed.\n", tc.name)
|
||||
return
|
||||
}
|
||||
|
||||
var expected *models.ErrNoteIncomplete
|
||||
if !errors.As(err, &expected) && tc.expectedError {
|
||||
t.Errorf("expected *ErrNoteIncomplete, got %T", err)
|
||||
}
|
||||
for _, reason := range tc.expectedReasons {
|
||||
if !errors.Is(err, reason) {
|
||||
t.Errorf(`expected error to contain "%v" but it was missing`, reason)
|
||||
}
|
||||
}
|
||||
rv := reflect.ValueOf(err).Elem()
|
||||
whyField := rv.FieldByName("why")
|
||||
if whyField.Len() != len(tc.expectedReasons) {
|
||||
t.Errorf("expected %d errors, got %d", len(tc.expectedReasons), whyField.Len())
|
||||
}
|
||||
fmt.Printf("Test case '%s' passed.\n", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleLengthNoteValidation(t *testing.T) {
|
||||
testcases := []struct {
|
||||
name string
|
||||
title string
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "Max valid title",
|
||||
title: strings.Repeat("0", models.NoteTitleMaxLength),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "Just over max valid title",
|
||||
title: strings.Repeat("1", models.NoteTitleMaxLength+1),
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "Double max valid title",
|
||||
title: strings.Repeat("Bd", models.NoteTitleMaxLength),
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "Random overmax valid title length",
|
||||
title: strings.Repeat("1", models.NoteTitleMaxLength+rand.IntN(150)),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
note := models.Note{
|
||||
Title: tc.title,
|
||||
Content: "Perfectly valid content " + tc.title,
|
||||
LastUpdate: time.Now(),
|
||||
ID: 1,
|
||||
}
|
||||
err := note.Validate()
|
||||
|
||||
if !tc.expectedError {
|
||||
if err != nil {
|
||||
t.Errorf("expecting no error, got %v", err)
|
||||
}
|
||||
} else {
|
||||
var expected *models.ErrNoteTitleOverflow
|
||||
if !errors.As(err, &expected) {
|
||||
t.Errorf("expected *ErrNoteTitleOverflow, got %T", err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Test case '%s' passed.\n", tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,9 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.hrafn.xyz/aether/notes/internal/models"
|
||||
"git.hrafn.xyz/aether/gotes/internal/models"
|
||||
)
|
||||
|
||||
// NoteRepository manages operations on notes using a data store.
|
||||
@@ -13,20 +12,26 @@ type NoteRepository struct {
|
||||
store NoteStore
|
||||
}
|
||||
|
||||
type NoteUpdate struct {
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
// NewNoteRepository creates and returns a new NoteRepository instance.
|
||||
func NewNoteRepository(store NoteStore) *NoteRepository {
|
||||
return &NoteRepository{store: store}
|
||||
}
|
||||
|
||||
// CreateNote creates a new note with the given content.
|
||||
func (r *NoteRepository) CreateNote(ctx context.Context, content string) (models.Note, error) {
|
||||
if content == "" {
|
||||
return models.Note{}, fmt.Errorf("content cannot be empty")
|
||||
}
|
||||
func (r *NoteRepository) CreateNote(ctx context.Context, title string, content string) (models.Note, error) {
|
||||
note := models.Note{
|
||||
Title: title,
|
||||
Content: content,
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
if err := note.Validate(); err != nil {
|
||||
return models.Note{}, err
|
||||
}
|
||||
return r.store.SaveNote(ctx, note)
|
||||
}
|
||||
|
||||
@@ -41,15 +46,30 @@ func (r *NoteRepository) ListNotes(ctx context.Context) ([]models.Note, error) {
|
||||
}
|
||||
|
||||
// UpdateNote updates the content of an existing note.
|
||||
func (r *NoteRepository) UpdateNote(ctx context.Context, id int, content string) (models.Note, error) {
|
||||
if content == "" {
|
||||
return models.Note{}, fmt.Errorf("content cannot be empty")
|
||||
func (r *NoteRepository) UpdateNote(ctx context.Context, id int, update NoteUpdate) (models.Note, error) {
|
||||
if update.Content == "" && update.Title == "" {
|
||||
return models.Note{}, &ErrEmptyUpdate{}
|
||||
}
|
||||
note, err := r.store.GetNoteByID(ctx, id)
|
||||
if err != nil {
|
||||
return models.Note{}, err
|
||||
}
|
||||
note.Content = content
|
||||
|
||||
if update.Content == note.Content && update.Title == note.Title {
|
||||
return models.Note{}, &ErrNoOP{}
|
||||
}
|
||||
|
||||
if update.Content != "" {
|
||||
note.Content = update.Content
|
||||
}
|
||||
if update.Title != "" {
|
||||
note.Title = update.Title
|
||||
}
|
||||
|
||||
if err := note.Validate(); err != nil {
|
||||
return models.Note{}, err
|
||||
}
|
||||
|
||||
note.LastUpdate = time.Now()
|
||||
return r.store.SaveNote(ctx, note)
|
||||
}
|
||||
|
||||
@@ -6,51 +6,93 @@ import (
|
||||
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.hrafn.xyz/aether/notes/internal/models"
|
||||
"git.hrafn.xyz/aether/notes/internal/repository"
|
||||
"git.hrafn.xyz/aether/gotes/internal/models"
|
||||
"git.hrafn.xyz/aether/gotes/internal/repository"
|
||||
)
|
||||
|
||||
var fixedTestDate = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) //Date is set by synctest to this specific time
|
||||
|
||||
func TestCreateNote(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
title string
|
||||
content string
|
||||
expectedNote models.Note
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "Good note",
|
||||
title: "Good",
|
||||
content: "This is a good note with valid content.",
|
||||
expectedNote: models.Note{
|
||||
ID: 1,
|
||||
LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), //LastUpdate is set by synctest to this specific time
|
||||
LastUpdate: fixedTestDate,
|
||||
Title: "Good",
|
||||
Content: "This is a good note with valid content.",
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty content",
|
||||
title: "Empty",
|
||||
content: "",
|
||||
expectedNote: models.Note{},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "Long content",
|
||||
title: "Long",
|
||||
content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
expectedNote: models.Note{
|
||||
ID: 2,
|
||||
LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
LastUpdate: fixedTestDate,
|
||||
Title: "Long",
|
||||
Content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty title",
|
||||
title: "",
|
||||
content: "Some content sure",
|
||||
expectedNote: models.Note{},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "Fully empty",
|
||||
title: "",
|
||||
content: "",
|
||||
expectedNote: models.Note{},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "Title too long",
|
||||
title: strings.Repeat("A", models.NoteTitleMaxLength+1),
|
||||
content: "",
|
||||
expectedNote: models.Note{},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "Title accept max",
|
||||
title: strings.Repeat("U", models.NoteTitleMaxLength),
|
||||
content: `That's a whole lot of "U"s`,
|
||||
expectedNote: models.Note{
|
||||
ID: 3,
|
||||
LastUpdate: fixedTestDate,
|
||||
Title: strings.Repeat("U", models.NoteTitleMaxLength),
|
||||
Content: `That's a whole lot of "U"s`,
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
}
|
||||
repo := repository.NewNoteRepository(&mockNoteStore{})
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
note, err := repo.CreateNote(context.Background(), tc.content)
|
||||
note, err := repo.CreateNote(context.Background(), tc.title, tc.content)
|
||||
if tc.expectedError {
|
||||
if err == nil {
|
||||
t.Errorf("expected an error but got none")
|
||||
@@ -63,6 +105,9 @@ func TestCreateNote(t *testing.T) {
|
||||
if note.Content != tc.expectedNote.Content {
|
||||
t.Errorf("expected content %q but got %q", tc.expectedNote.Content, note.Content)
|
||||
}
|
||||
if note.Title != tc.expectedNote.Title {
|
||||
t.Errorf("expected title %q but got %q", tc.expectedNote.Title, note.Title)
|
||||
}
|
||||
if note.ID != tc.expectedNote.ID {
|
||||
t.Errorf("expected ID %d but got %d", tc.expectedNote.ID, note.ID)
|
||||
}
|
||||
@@ -77,11 +122,11 @@ func TestCreateNote(t *testing.T) {
|
||||
|
||||
func TestGetNotes(t *testing.T) {
|
||||
notes := []models.Note{
|
||||
{ID: 1, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "First note"},
|
||||
{ID: 2, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "Second note"},
|
||||
{ID: 3, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "Third note"},
|
||||
{ID: 1, LastUpdate: fixedTestDate, Content: "First note", Title: "One"},
|
||||
{ID: 2, LastUpdate: fixedTestDate, Content: "Second note", Title: "Two"},
|
||||
{ID: 3, LastUpdate: fixedTestDate, Content: "Third note", Title: "Three"},
|
||||
// 4th note was clearly deleted, :sadface:
|
||||
{ID: 5, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "Lorem ipsum note dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."},
|
||||
{ID: 5, LastUpdate: fixedTestDate, Content: "Lorem ipsum note dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", Title: "Lorem"},
|
||||
}
|
||||
store := &mockNoteStore{Notes: notes}
|
||||
repo := repository.NewNoteRepository(store)
|
||||
@@ -157,14 +202,14 @@ func TestListNotes(t *testing.T) {
|
||||
{
|
||||
name: "Multiple notes",
|
||||
notes: []models.Note{
|
||||
{ID: 1, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "First note"},
|
||||
{ID: 2, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "Second note"},
|
||||
{ID: 3, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "Third note"},
|
||||
{ID: 1, LastUpdate: fixedTestDate, Content: "First note", Title: "Seven"},
|
||||
{ID: 2, LastUpdate: fixedTestDate, Content: "Second note", Title: "Nine"},
|
||||
{ID: 3, LastUpdate: fixedTestDate, Content: "Third note", Title: "FourtyThree"},
|
||||
},
|
||||
expectedNotes: []models.Note{
|
||||
{ID: 1, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "First note"},
|
||||
{ID: 2, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "Second note"},
|
||||
{ID: 3, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "Third note"},
|
||||
{ID: 1, LastUpdate: fixedTestDate, Content: "First note", Title: "Seven"},
|
||||
{ID: 2, LastUpdate: fixedTestDate, Content: "Second note", Title: "Nine"},
|
||||
{ID: 3, LastUpdate: fixedTestDate, Content: "Third note", Title: "FourtyThree"},
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
@@ -190,6 +235,9 @@ func TestListNotes(t *testing.T) {
|
||||
if note.Content != tc.expectedNotes[i].Content {
|
||||
t.Errorf("expected content %q but got %q", tc.expectedNotes[i].Content, note.Content)
|
||||
}
|
||||
if note.Title != tc.expectedNotes[i].Title {
|
||||
t.Errorf("expected title %q but got %q", tc.expectedNotes[i].Title, note.Title)
|
||||
}
|
||||
if note.ID != tc.expectedNotes[i].ID {
|
||||
t.Errorf("expected ID %d but got %d", tc.expectedNotes[i].ID, note.ID)
|
||||
}
|
||||
@@ -201,40 +249,91 @@ func TestListNotes(t *testing.T) {
|
||||
|
||||
func TestUpdateNote(t *testing.T) {
|
||||
notes := []models.Note{
|
||||
{ID: 1, LastUpdate: time.Date(1984, 11, 20, 0, 0, 0, 0, time.UTC), Content: "First note"},
|
||||
{ID: 2, LastUpdate: time.Date(2018, 6, 8, 0, 0, 0, 0, time.UTC), Content: "Second note"},
|
||||
{ID: 1, LastUpdate: time.Date(1984, 11, 20, 0, 0, 0, 0, time.UTC), Content: "First note", Title: "Joe"},
|
||||
{ID: 2, LastUpdate: time.Date(2018, 6, 8, 0, 0, 0, 0, time.UTC), Content: "Second note", Title: "Momma"},
|
||||
{ID: 3, LastUpdate: time.Date(2018, 6, 8, 0, 0, 0, 0, time.UTC), Content: "Third note", Title: "So"},
|
||||
{ID: 4, LastUpdate: time.Date(2018, 6, 8, 0, 0, 0, 0, time.UTC), Content: "Four note", Title: "Lovely"},
|
||||
{ID: 5, LastUpdate: time.Date(2018, 6, 8, 0, 0, 0, 0, time.UTC), Content: "Fifth note", Title: "No, really"},
|
||||
{ID: 6, LastUpdate: time.Date(2018, 6, 8, 0, 0, 0, 0, time.UTC), Content: "six note", Title: "She is!"},
|
||||
}
|
||||
store := &mockNoteStore{Notes: notes}
|
||||
repo := repository.NewNoteRepository(store)
|
||||
testcases := []struct {
|
||||
name string
|
||||
id int
|
||||
title string
|
||||
content string
|
||||
expectedNote models.Note
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "Update existing note",
|
||||
name: "Update existing note content only",
|
||||
id: 1,
|
||||
title: "",
|
||||
content: "Updated first note",
|
||||
expectedNote: models.Note{
|
||||
ID: 1,
|
||||
LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
LastUpdate: fixedTestDate,
|
||||
Content: "Updated first note",
|
||||
Title: "Joe",
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "Update non-existing note",
|
||||
id: 999,
|
||||
title: "Does it matter?",
|
||||
content: "This note does not exist",
|
||||
expectedNote: models.Note{},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "Update with empty content",
|
||||
name: "Update with empty content and title",
|
||||
id: 2,
|
||||
content: "",
|
||||
title: "",
|
||||
expectedNote: models.Note{},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "Update with unchanged content",
|
||||
id: 3,
|
||||
content: "Third note",
|
||||
title: "So",
|
||||
expectedNote: models.Note{},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "Update with title only",
|
||||
id: 4,
|
||||
content: "",
|
||||
title: "Davey crocket",
|
||||
expectedNote: models.Note{
|
||||
ID: 4,
|
||||
LastUpdate: fixedTestDate,
|
||||
Content: "Four note",
|
||||
Title: "Davey crocket",
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "Update with content only",
|
||||
id: 5,
|
||||
content: "Just internal screaming",
|
||||
title: "",
|
||||
expectedNote: models.Note{
|
||||
ID: 5,
|
||||
LastUpdate: fixedTestDate,
|
||||
Content: "Just internal screaming",
|
||||
Title: "No, really",
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "Update title too long",
|
||||
id: 6,
|
||||
content: "Kinda irrelavant, right?",
|
||||
title: strings.Repeat("F", models.NoteTitleMaxLength+1),
|
||||
expectedNote: models.Note{},
|
||||
expectedError: true,
|
||||
},
|
||||
@@ -242,7 +341,10 @@ func TestUpdateNote(t *testing.T) {
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
note, err := repo.UpdateNote(context.Background(), tc.id, tc.content)
|
||||
note, err := repo.UpdateNote(context.Background(), tc.id, repository.NoteUpdate{
|
||||
Content: tc.content,
|
||||
Title: tc.title,
|
||||
})
|
||||
if tc.expectedError {
|
||||
if err == nil {
|
||||
t.Errorf("expected an error but got none")
|
||||
@@ -255,6 +357,9 @@ func TestUpdateNote(t *testing.T) {
|
||||
if note.Content != tc.expectedNote.Content {
|
||||
t.Errorf("expected content %q but got %q", tc.expectedNote.Content, note.Content)
|
||||
}
|
||||
if note.Title != tc.expectedNote.Title {
|
||||
t.Errorf("expected title %q but got %q", tc.expectedNote.Title, note.Title)
|
||||
}
|
||||
if note.ID != tc.expectedNote.ID {
|
||||
t.Errorf("expected ID %d but got %d", tc.expectedNote.ID, note.ID)
|
||||
}
|
||||
@@ -279,11 +384,11 @@ func TestDeleteNote(t *testing.T) {
|
||||
name: "Delete existing note",
|
||||
id: 1,
|
||||
setupNotes: []models.Note{
|
||||
{ID: 1, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "First note"},
|
||||
{ID: 2, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "Second note"},
|
||||
{ID: 1, LastUpdate: fixedTestDate, Content: "First note"},
|
||||
{ID: 2, LastUpdate: fixedTestDate, Content: "Second note"},
|
||||
},
|
||||
expectedNotes: []models.Note{
|
||||
{ID: 2, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "Second note"},
|
||||
{ID: 2, LastUpdate: fixedTestDate, Content: "Second note"},
|
||||
},
|
||||
expectedError: false,
|
||||
},
|
||||
@@ -291,12 +396,12 @@ func TestDeleteNote(t *testing.T) {
|
||||
name: "Delete non-existing note",
|
||||
id: 999,
|
||||
setupNotes: []models.Note{
|
||||
{ID: 1, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "First note"},
|
||||
{ID: 2, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "Second note"},
|
||||
{ID: 1, LastUpdate: fixedTestDate, Content: "First note"},
|
||||
{ID: 2, LastUpdate: fixedTestDate, Content: "Second note"},
|
||||
},
|
||||
expectedNotes: []models.Note{
|
||||
{ID: 1, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "First note"},
|
||||
{ID: 2, LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Content: "Second note"},
|
||||
{ID: 1, LastUpdate: fixedTestDate, Content: "First note"},
|
||||
{ID: 2, LastUpdate: fixedTestDate, Content: "Second note"},
|
||||
},
|
||||
expectedError: true,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,8 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.hrafn.xyz/aether/notes/internal/models"
|
||||
|
||||
"git.hrafn.xyz/aether/gotes/internal/models"
|
||||
)
|
||||
|
||||
// NoteStore defines the interface for persisting and retrieving notes.
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.hrafn.xyz/aether/notes/internal/models"
|
||||
"git.hrafn.xyz/aether/gotes/internal/models"
|
||||
)
|
||||
|
||||
type mockNoteStore struct {
|
||||
|
||||
12
internal/repository/repository_errors.go
Normal file
12
internal/repository/repository_errors.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package repository
|
||||
|
||||
type ErrNoOP struct{}
|
||||
type ErrEmptyUpdate struct{}
|
||||
|
||||
func (e *ErrNoOP) Error() string {
|
||||
return "no operation required"
|
||||
}
|
||||
|
||||
func (e *ErrEmptyUpdate) Error() string {
|
||||
return "update cannot be empty"
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
|
||||
"modernc.org/sqlite"
|
||||
|
||||
"git.hrafn.xyz/aether/notes/internal/models"
|
||||
"git.hrafn.xyz/aether/gotes/internal/models"
|
||||
"git.hrafn.xyz/aether/gotes/internal/repository"
|
||||
)
|
||||
|
||||
const sqlSet = `
|
||||
@@ -17,6 +18,8 @@ const sqlSet = `
|
||||
PRAGMA busy_timeout = 7000;
|
||||
`
|
||||
|
||||
var _ repository.NoteStore = &SQLiteStore{}
|
||||
|
||||
type SQLiteStore struct {
|
||||
read *sql.DB
|
||||
write *sql.DB
|
||||
@@ -85,10 +88,14 @@ func (s *SQLiteStore) SaveNote(ctx context.Context, note models.Note) (models.No
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := note.Validate(); err != nil {
|
||||
return models.Note{}, err
|
||||
}
|
||||
|
||||
if note.ID == 0 {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO notes (content, last_update) VALUES (?, ?);
|
||||
`, note.Content, note.LastUpdate)
|
||||
INSERT INTO notes (title, content, last_update) VALUES (?, ?, ?);
|
||||
`, note.Title, note.Content, note.LastUpdate)
|
||||
if err != nil {
|
||||
return models.Note{}, fmt.Errorf("failed to insert note: %w", err)
|
||||
}
|
||||
@@ -99,14 +106,14 @@ func (s *SQLiteStore) SaveNote(ctx context.Context, note models.Note) (models.No
|
||||
note.ID = int(id)
|
||||
} else {
|
||||
_, err := s.scanNote(tx.QueryRowContext(ctx, `
|
||||
SELECT id, content, last_update FROM notes WHERE id = ?;
|
||||
SELECT id, title, content, last_update FROM notes WHERE id = ?;
|
||||
`, note.ID))
|
||||
if err != nil {
|
||||
return models.Note{}, fmt.Errorf("cannot update note not found: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE notes SET content = ?, last_update = ? WHERE id = ?;
|
||||
`, note.Content, note.LastUpdate, note.ID)
|
||||
UPDATE notes SET title = ?, content = ?, last_update = ? WHERE id = ?;
|
||||
`, note.Title, note.Content, note.LastUpdate, note.ID)
|
||||
if err != nil {
|
||||
return models.Note{}, fmt.Errorf("failed to update note: %w", err)
|
||||
}
|
||||
@@ -120,7 +127,7 @@ func (s *SQLiteStore) SaveNote(ctx context.Context, note models.Note) (models.No
|
||||
|
||||
func (s *SQLiteStore) GetNoteByID(ctx context.Context, id int) (models.Note, error) {
|
||||
row := s.read.QueryRowContext(ctx, `
|
||||
SELECT id, content, last_update FROM notes WHERE id = ?;
|
||||
SELECT id, title, content, last_update FROM notes WHERE id = ?;
|
||||
`, id)
|
||||
note, err := s.scanNote(row)
|
||||
if err != nil {
|
||||
@@ -134,7 +141,7 @@ func (s *SQLiteStore) GetNoteByID(ctx context.Context, id int) (models.Note, err
|
||||
|
||||
func (s *SQLiteStore) GetAllNotes(ctx context.Context) ([]models.Note, error) {
|
||||
rows, err := s.read.QueryContext(ctx, `
|
||||
SELECT id, content, last_update FROM notes ORDER BY last_update DESC;
|
||||
SELECT id, title, content, last_update FROM notes ORDER BY id ASC;
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query all notes: %w", err)
|
||||
@@ -177,8 +184,11 @@ func (s *SQLiteStore) validateSchema(ctx context.Context) error {
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
last_update TIMESTAMP NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
title TEXT NOT NULL,
|
||||
content BLOB NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_id ON notes(id);
|
||||
CREATE INDEX idx_title ON notes(title);
|
||||
`, nil)
|
||||
return err
|
||||
}
|
||||
@@ -193,7 +203,7 @@ func (s *SQLiteStore) getWriteTransaction(ctx context.Context) (*sql.Tx, error)
|
||||
|
||||
func (s *SQLiteStore) scanNote(results scannable) (models.Note, error) {
|
||||
var note models.Note
|
||||
err := results.Scan(¬e.ID, ¬e.Content, ¬e.LastUpdate)
|
||||
err := results.Scan(¬e.ID, ¬e.Title, ¬e.Content, ¬e.LastUpdate)
|
||||
if err != nil {
|
||||
return models.Note{}, fmt.Errorf("failed to scan note: %w", err)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.hrafn.xyz/aether/notes/internal/models"
|
||||
"git.hrafn.xyz/aether/notes/internal/store/sqlite"
|
||||
"git.hrafn.xyz/aether/gotes/internal/models"
|
||||
"git.hrafn.xyz/aether/gotes/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestCreateSQLiteStore(t *testing.T) {
|
||||
@@ -86,11 +86,13 @@ func TestSaveNote(t *testing.T) {
|
||||
name: "new note",
|
||||
note: models.Note{
|
||||
Content: "Test note",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
expectedNote: models.Note{
|
||||
ID: 1,
|
||||
Content: "Test note",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
expectedError: false,
|
||||
@@ -98,11 +100,13 @@ func TestSaveNote(t *testing.T) {
|
||||
name: "new 2 note",
|
||||
note: models.Note{
|
||||
Content: "Test note 2!",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
expectedNote: models.Note{
|
||||
ID: 2,
|
||||
Content: "Test note 2!",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
expectedError: false,
|
||||
@@ -112,11 +116,13 @@ func TestSaveNote(t *testing.T) {
|
||||
note: models.Note{
|
||||
ID: 1,
|
||||
Content: "Updated note",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
expectedNote: models.Note{
|
||||
ID: 1,
|
||||
Content: "Updated note",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
expectedError: false,
|
||||
@@ -126,6 +132,7 @@ func TestSaveNote(t *testing.T) {
|
||||
note: models.Note{
|
||||
ID: 999,
|
||||
Content: "This note does not exist",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Now(),
|
||||
},
|
||||
expectedNote: models.Note{},
|
||||
@@ -168,6 +175,7 @@ func TestGetNoteByID(t *testing.T) {
|
||||
}()
|
||||
store.SaveNote(context.Background(), models.Note{
|
||||
Content: "Test note",
|
||||
Title: "Saved No Problem",
|
||||
LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
|
||||
@@ -183,6 +191,7 @@ func TestGetNoteByID(t *testing.T) {
|
||||
expectedNote: models.Note{
|
||||
ID: 1,
|
||||
Content: "Test note",
|
||||
Title: "Saved No Problem",
|
||||
LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
expectedError: false,
|
||||
@@ -259,10 +268,12 @@ func TestGetAllNotes(t *testing.T) {
|
||||
|
||||
store.SaveNote(context.Background(), models.Note{
|
||||
Content: "Test note 1",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
store.SaveNote(context.Background(), models.Note{
|
||||
Content: "Test note 2",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
|
||||
@@ -277,11 +288,13 @@ func TestGetAllNotes(t *testing.T) {
|
||||
{
|
||||
ID: 1,
|
||||
Content: "Test note 1",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Content: "Test note 2",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
@@ -324,14 +337,17 @@ func TestDeleteNoteByID(t *testing.T) {
|
||||
|
||||
store.SaveNote(context.Background(), models.Note{
|
||||
Content: "Test note 1",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
store.SaveNote(context.Background(), models.Note{
|
||||
Content: "Test note 2",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
store.SaveNote(context.Background(), models.Note{
|
||||
Content: "Test note 3",
|
||||
Title: "No Problem",
|
||||
LastUpdate: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
|
||||
|
||||
42
justfile
42
justfile
@@ -1,5 +1,7 @@
|
||||
# Use 'just <command>' to run tasks
|
||||
|
||||
build_path := "_build"
|
||||
server_exec_name := "server"
|
||||
simplecli_exec_name := "simplecli"
|
||||
|
||||
# Default recipe (runs when you just type 'just')
|
||||
default:
|
||||
@@ -7,9 +9,20 @@ default:
|
||||
|
||||
build: clean
|
||||
@echo "Building notes..."
|
||||
@mkdir -p _build
|
||||
go build -o _build/notes -ldflags="-s -w" .
|
||||
@echo "Build complete: ./_build/notes"
|
||||
@mkdir -p {{ build_path }}
|
||||
@just build-server
|
||||
@just build-simplecli
|
||||
|
||||
build-server:
|
||||
@echo "Building the isolated server..."
|
||||
@rm -f {{ build_path }}/{{ server_exec_name }}
|
||||
go build -o {{ build_path }}/{{ server_exec_name }} -ldflags="-s -w" cmd/server/main.go
|
||||
|
||||
build-simplecli:
|
||||
@echo "Building the simple cli..."
|
||||
@rm -f {{ build_path }}/{{ simplecli_exec_name }}
|
||||
go build -o {{ build_path }}/{{ simplecli_exec_name }} -ldflags="-s -w" cmd/simplecli/main.go
|
||||
|
||||
|
||||
# Run tests with short output
|
||||
test:
|
||||
@@ -20,12 +33,12 @@ test:
|
||||
# Run tests with coverage
|
||||
test-coverage:
|
||||
@echo "Running tests with coverage..."
|
||||
@mkdir -p _build
|
||||
@rm -f _build/coverage.out
|
||||
go test -v -coverprofile=_build/coverage.out ./...
|
||||
@mkdir -p {{ build_path }}
|
||||
@rm -f {{ build_path }}/coverage.out
|
||||
go test -v -coverprofile={{ build_path }}/coverage.out ./...
|
||||
@echo "Generate HTML report"
|
||||
go tool cover -html=_build/coverage.out -o _build/coverage.html
|
||||
@echo "Coverage report generated: _build/coverage.html"
|
||||
go tool cover -html={{ build_path }}/coverage.out -o {{ build_path }}/coverage.html
|
||||
@echo "Coverage report generated: {{ build_path }}/coverage.html"
|
||||
|
||||
# Watch for changes and run tests automatically
|
||||
watch:
|
||||
@@ -33,17 +46,8 @@ watch:
|
||||
@command -v watchexec >/dev/null 2>&1 || (echo "Error: watchexec not installed. Install with: brew install watchexec" && exit 1)
|
||||
@watchexec --clear --exts go -- sh -c 'echo "Changes detected, running tests..." && just test'
|
||||
|
||||
# Run the server
|
||||
run:
|
||||
@echo "Starting notes server..."
|
||||
go run . --server
|
||||
|
||||
# Run the built binary
|
||||
run-binary: build
|
||||
./_build/notes --server
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
rm -rf _build
|
||||
rm -rf {{ build_path }}
|
||||
@echo "Clean complete"
|
||||
Reference in New Issue
Block a user