From 43b8aac9a5fa400313ce85197c932bd87cc84a9d Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Fri, 30 Jan 2026 09:49:03 +0100 Subject: [PATCH] feat(bookstack-api-q8z): add unit tests for data types JSON unmarshaling Verify Book, Page, and SearchResult correctly unmarshal from Bookstack API JSON format including time.Time parsing. --- .beads/export-state/6feeba40f0df6736.json | 6 +- .beads/issues.jsonl | 4 +- types_test.go | 87 +++++++++++++++++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 types_test.go diff --git a/.beads/export-state/6feeba40f0df6736.json b/.beads/export-state/6feeba40f0df6736.json index 1f54760..03fb14c 100644 --- a/.beads/export-state/6feeba40f0df6736.json +++ b/.beads/export-state/6feeba40f0df6736.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/bookstack-api", - "last_export_commit": "8223a37f534ec27e57ddaa4ec90aaeaccb12891d", - "last_export_time": "2026-01-30T09:48:02.137709467+01:00", - "jsonl_hash": "ee244eab2618826b90d20b9f03326625dc7a5968df16d69c21d6ea7ffbe354ee" + "last_export_commit": "4875540f212f846802c64e7c6f3429cd82bbe514", + "last_export_time": "2026-01-30T09:48:32.619562561+01:00", + "jsonl_hash": "81400531141221ef340464e2ca11c0bd4ef679e1a0eba08826e499290f454605" } \ No newline at end of file diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index bf6678a..1c5d145 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -15,5 +15,5 @@ {"id":"bookstack-api-dd0","title":"Implement ChaptersService (List, Get)","description":"Implement the ChaptersService with List and Get operations.\n\n## Requirements\nFrom PRD Section 5:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| List | GET /api/chapters | All chapters |\n| Get | GET /api/chapters/{id} | Single chapter |\n\n## API Methods\n```go\ntype ChaptersService struct {\n client *Client\n}\n\nfunc (s *ChaptersService) List(ctx context.Context, opts *ListOptions) ([]*Chapter, error)\nfunc (s *ChaptersService) Get(ctx context.Context, id int) (*Chapter, error)\nfunc (s *ChaptersService) ListAll(ctx context.Context) iter.Seq2[*Chapter, error]\n```\n\n## Chapter Fields\n- ID, BookID, Name, Slug, Description, CreatedAt, UpdatedAt\n\n## Bookstack Hierarchy\nBook -\u003e Chapter -\u003e Page (Chapter is optional level)\n\n## Acceptance Criteria\n- [ ] ChaptersService struct created\n- [ ] List() returns paginated chapters\n- [ ] Get() returns single chapter by ID\n- [ ] ListAll() iterator implemented\n- [ ] Proper error handling\n- [ ] Unit tests with mock server","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:53.235414205+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:53.235414205+01:00"} {"id":"bookstack-api-jt9","title":"Implement Pages Export (Markdown, PDF)","description":"Implement page export functionality for Markdown and PDF formats.\n\n## Requirements\nFrom PRD Section 5:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| ExportMD | GET /api/pages/{id}/export/markdown | Markdown export |\n| ExportPDF | GET /api/pages/{id}/export/pdf | PDF export |\n\n## API Methods\n```go\nfunc (s *PagesService) ExportMarkdown(ctx context.Context, id int) ([]byte, error)\nfunc (s *PagesService) ExportPDF(ctx context.Context, id int) ([]byte, error)\n```\n\n## Technical Details\n- Markdown export returns plain text\n- PDF export returns binary data\n- Consider streaming for large PDFs (risk mitigation from PRD)\n- Return raw bytes, caller handles file writing\n\n## User Story\nUS4: Als Benutzer möchte ich eine Seite als Markdown oder PDF exportieren können.\n\n## Acceptance Criteria\n- [ ] ExportMarkdown() returns markdown content as bytes\n- [ ] ExportPDF() returns PDF content as bytes\n- [ ] Proper error handling (404 -\u003e ErrNotFound)\n- [ ] Large file handling considered\n- [ ] Unit tests with mock server","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:31.924404898+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:31.924404898+01:00"} {"id":"bookstack-api-m6n","title":"Implement pagination iterator (ListAll)","description":"Implement Go 1.23+ iterator pattern for memory-efficient pagination.\n\n## Requirements\nFrom PRD Section 5 (Pagination Iterator-Pattern):\n\n```go\n// ListAll returns an iterator over all entries\n// Uses Go 1.23+ iter.Seq or custom implementation\nfunc (s *BooksService) ListAll(ctx context.Context) iter.Seq2[*Book, error]\n\n// Usage:\nfor book, err := range client.Books.ListAll(ctx) {\n if err != nil {\n return err\n }\n fmt.Println(book.Name)\n}\n```\n\n## Technical Details\n- Go 1.23+ iter.Seq2 for yield-based iteration\n- Memory-efficient: only one page in memory at a time\n- Supports early break\n- Handle 10,000+ entries without memory issues\n\n## Pagination Parameters\n- count: 100 default, max 500\n- offset: auto-incremented per page\n- total: from API response to know when done\n\n## Services to Implement\n- BooksService.ListAll()\n- PagesService.ListAll()\n- ChaptersService.ListAll() (v0.2)\n- ShelvesService.ListAll() (v0.2)\n- SearchService.SearchAll() (v0.2)\n\n## Acceptance Criteria\n- [ ] iter.Seq2 based iterator implementation\n- [ ] Automatic pagination through all results\n- [ ] Early termination works correctly\n- [ ] Error propagation through iterator\n- [ ] Unit tests for pagination logic","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:31.682196545+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:31.682196545+01:00"} -{"id":"bookstack-api-q8z","title":"Implement data types (Book, Page, Chapter, Shelf, SearchResult)","description":"Create all data structure types as defined in the PRD.\n\n## Requirements\nFrom PRD Section 4 (Datenstrukturen):\n\n```go\ntype Book struct {\n ID int `json:\"id\"`\n Name string `json:\"name\"`\n Slug string `json:\"slug\"`\n Description string `json:\"description\"`\n CreatedAt time.Time `json:\"created_at\"`\n UpdatedAt time.Time `json:\"updated_at\"`\n CreatedBy int `json:\"created_by\"`\n UpdatedBy int `json:\"updated_by\"`\n}\n\ntype Page struct {\n ID int `json:\"id\"`\n BookID int `json:\"book_id\"`\n ChapterID int `json:\"chapter_id\"`\n Name string `json:\"name\"`\n Slug string `json:\"slug\"`\n HTML string `json:\"html\"`\n RawHTML string `json:\"raw_html\"`\n Markdown string `json:\"markdown\"`\n CreatedAt time.Time `json:\"created_at\"`\n UpdatedAt time.Time `json:\"updated_at\"`\n}\n\ntype Chapter struct {\n ID int `json:\"id\"`\n BookID int `json:\"book_id\"`\n Name string `json:\"name\"`\n Slug string `json:\"slug\"`\n Description string `json:\"description\"`\n CreatedAt time.Time `json:\"created_at\"`\n UpdatedAt time.Time `json:\"updated_at\"`\n}\n\ntype Shelf struct {\n ID int `json:\"id\"`\n Name string `json:\"name\"`\n Slug string `json:\"slug\"`\n Description string `json:\"description\"`\n CreatedAt time.Time `json:\"created_at\"`\n UpdatedAt time.Time `json:\"updated_at\"`\n}\n\ntype SearchResult struct {\n ID int `json:\"id\"`\n Name string `json:\"name\"`\n Slug string `json:\"slug\"`\n Type string `json:\"type\"\\ // page, chapter, book, bookshelf\n URL string `json:\"url\"`\n Preview string `json:\"preview\"`\n}\n```\n\n## Acceptance Criteria\n- [ ] All types defined in types.go\n- [ ] JSON tags match Bookstack API format\n- [ ] time.Time fields parse correctly from API\n- [ ] GoDoc comments on all exported types","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:06.701609698+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T09:39:06.701609698+01:00"} -{"id":"bookstack-api-vl3","title":"Implement error types and APIError","description":"Create error handling infrastructure with sentinel errors and APIError type.\n\n## Requirements\nFrom PRD Section 9 (Error-Handling-Strategie):\n\n```go\nvar (\n ErrNotFound = errors.New(\"bookstack: resource not found\")\n ErrUnauthorized = errors.New(\"bookstack: unauthorized\")\n ErrForbidden = errors.New(\"bookstack: forbidden\")\n ErrRateLimited = errors.New(\"bookstack: rate limited\")\n ErrBadRequest = errors.New(\"bookstack: bad request\")\n)\n\ntype APIError struct {\n StatusCode int\n Code int `json:\"code\"`\n Message string `json:\"message\"`\n Body []byte\n}\n\nfunc (e *APIError) Error() string\nfunc (e *APIError) Is(target error) bool // Maps status codes to sentinel errors\n```\n\n## Status Code Mapping\n- 400 -\u003e ErrBadRequest\n- 401 -\u003e ErrUnauthorized\n- 403 -\u003e ErrForbidden\n- 404 -\u003e ErrNotFound\n- 429 -\u003e ErrRateLimited\n\n## Acceptance Criteria\n- [ ] All sentinel errors defined\n- [ ] APIError struct with Error() method\n- [ ] APIError.Is() correctly maps status codes\n- [ ] errors.Is() works with wrapped APIError\n- [ ] Unit tests for error matching","status":"in_progress","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:06.467984697+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-30T09:48:09.196332476+01:00","comments":[{"id":2,"issue_id":"bookstack-api-vl3","author":"Oliver Jakoubek","text":"Plan: errors.go already implemented. Need to add unit tests for APIError.Error(), APIError.Is(), and errors.Is() integration. Sentinel error messages use short form without prefix which is fine.","created_at":"2026-01-30T08:48:14Z"}]} +{"id":"bookstack-api-q8z","title":"Implement data types (Book, Page, Chapter, Shelf, SearchResult)","description":"Create all data structure types as defined in the PRD.\n\n## Requirements\nFrom PRD Section 4 (Datenstrukturen):\n\n```go\ntype Book struct {\n ID int `json:\"id\"`\n Name string `json:\"name\"`\n Slug string `json:\"slug\"`\n Description string `json:\"description\"`\n CreatedAt time.Time `json:\"created_at\"`\n UpdatedAt time.Time `json:\"updated_at\"`\n CreatedBy int `json:\"created_by\"`\n UpdatedBy int `json:\"updated_by\"`\n}\n\ntype Page struct {\n ID int `json:\"id\"`\n BookID int `json:\"book_id\"`\n ChapterID int `json:\"chapter_id\"`\n Name string `json:\"name\"`\n Slug string `json:\"slug\"`\n HTML string `json:\"html\"`\n RawHTML string `json:\"raw_html\"`\n Markdown string `json:\"markdown\"`\n CreatedAt time.Time `json:\"created_at\"`\n UpdatedAt time.Time `json:\"updated_at\"`\n}\n\ntype Chapter struct {\n ID int `json:\"id\"`\n BookID int `json:\"book_id\"`\n Name string `json:\"name\"`\n Slug string `json:\"slug\"`\n Description string `json:\"description\"`\n CreatedAt time.Time `json:\"created_at\"`\n UpdatedAt time.Time `json:\"updated_at\"`\n}\n\ntype Shelf struct {\n ID int `json:\"id\"`\n Name string `json:\"name\"`\n Slug string `json:\"slug\"`\n Description string `json:\"description\"`\n CreatedAt time.Time `json:\"created_at\"`\n UpdatedAt time.Time `json:\"updated_at\"`\n}\n\ntype SearchResult struct {\n ID int `json:\"id\"`\n Name string `json:\"name\"`\n Slug string `json:\"slug\"`\n Type string `json:\"type\"\\ // page, chapter, book, bookshelf\n URL string `json:\"url\"`\n Preview string `json:\"preview\"`\n}\n```\n\n## Acceptance Criteria\n- [ ] All types defined in types.go\n- [ ] JSON tags match Bookstack API format\n- [ ] time.Time fields parse correctly from API\n- [ ] GoDoc comments on all exported types","status":"in_progress","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:06.701609698+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-30T09:48:37.984087869+01:00","comments":[{"id":3,"issue_id":"bookstack-api-q8z","author":"Oliver Jakoubek","text":"Plan: types.go already fully implemented with additional fields beyond PRD. Adding JSON round-trip test to verify time.Time parsing from API format.","created_at":"2026-01-30T08:48:46Z"}]} +{"id":"bookstack-api-vl3","title":"Implement error types and APIError","description":"Create error handling infrastructure with sentinel errors and APIError type.\n\n## Requirements\nFrom PRD Section 9 (Error-Handling-Strategie):\n\n```go\nvar (\n ErrNotFound = errors.New(\"bookstack: resource not found\")\n ErrUnauthorized = errors.New(\"bookstack: unauthorized\")\n ErrForbidden = errors.New(\"bookstack: forbidden\")\n ErrRateLimited = errors.New(\"bookstack: rate limited\")\n ErrBadRequest = errors.New(\"bookstack: bad request\")\n)\n\ntype APIError struct {\n StatusCode int\n Code int `json:\"code\"`\n Message string `json:\"message\"`\n Body []byte\n}\n\nfunc (e *APIError) Error() string\nfunc (e *APIError) Is(target error) bool // Maps status codes to sentinel errors\n```\n\n## Status Code Mapping\n- 400 -\u003e ErrBadRequest\n- 401 -\u003e ErrUnauthorized\n- 403 -\u003e ErrForbidden\n- 404 -\u003e ErrNotFound\n- 429 -\u003e ErrRateLimited\n\n## Acceptance Criteria\n- [ ] All sentinel errors defined\n- [ ] APIError struct with Error() method\n- [ ] APIError.Is() correctly maps status codes\n- [ ] errors.Is() works with wrapped APIError\n- [ ] Unit tests for error matching","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T09:39:06.467984697+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-30T09:48:32.685138971+01:00","closed_at":"2026-01-30T09:48:32.685138971+01:00","close_reason":"Closed","comments":[{"id":2,"issue_id":"bookstack-api-vl3","author":"Oliver Jakoubek","text":"Plan: errors.go already implemented. Need to add unit tests for APIError.Error(), APIError.Is(), and errors.Is() integration. Sentinel error messages use short form without prefix which is fine.","created_at":"2026-01-30T08:48:14Z"}]} diff --git a/types_test.go b/types_test.go new file mode 100644 index 0000000..6229250 --- /dev/null +++ b/types_test.go @@ -0,0 +1,87 @@ +package bookstack + +import ( + "encoding/json" + "testing" + "time" +) + +func TestBook_JSONUnmarshal(t *testing.T) { + data := `{ + "id": 1, + "name": "Test Book", + "slug": "test-book", + "description": "A test book", + "created_at": "2024-01-15T10:30:00.000000Z", + "updated_at": "2024-01-16T12:00:00.000000Z", + "created_by": 1, + "updated_by": 2, + "owned_by": 1 + }` + + var b Book + if err := json.Unmarshal([]byte(data), &b); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if b.ID != 1 { + t.Errorf("ID = %d, want 1", b.ID) + } + if b.Name != "Test Book" { + t.Errorf("Name = %q, want %q", b.Name, "Test Book") + } + if b.CreatedAt.Year() != 2024 || b.CreatedAt.Month() != time.January || b.CreatedAt.Day() != 15 { + t.Errorf("CreatedAt = %v, want 2024-01-15", b.CreatedAt) + } +} + +func TestPage_JSONUnmarshal(t *testing.T) { + data := `{ + "id": 5, + "book_id": 1, + "chapter_id": 2, + "name": "Test Page", + "slug": "test-page", + "html": "

Hello

", + "markdown": "Hello", + "draft": false, + "created_at": "2024-06-01T08:00:00.000000Z", + "updated_at": "2024-06-02T09:00:00.000000Z" + }` + + var p Page + if err := json.Unmarshal([]byte(data), &p); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if p.BookID != 1 { + t.Errorf("BookID = %d, want 1", p.BookID) + } + if p.ChapterID != 2 { + t.Errorf("ChapterID = %d, want 2", p.ChapterID) + } + if p.HTML != "

Hello

" { + t.Errorf("HTML = %q", p.HTML) + } +} + +func TestSearchResult_JSONUnmarshal(t *testing.T) { + data := `{ + "type": "page", + "id": 10, + "name": "Found Page", + "slug": "found-page", + "book_id": 3, + "preview": "...match...", + "score": 1.5 + }` + + var sr SearchResult + if err := json.Unmarshal([]byte(data), &sr); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if sr.Type != "page" { + t.Errorf("Type = %q, want %q", sr.Type, "page") + } + if sr.Score != 1.5 { + t.Errorf("Score = %f, want 1.5", sr.Score) + } +}