Implements: https://todo.code.netlandish.com/~netlandish/links/76
Changelog-added: Pinboard API to GraphQL bridge
---
CLAUDE.md | 116 ++++
cmd/links/main.go | 6 +-
pinboard/input.go | 47 ++
pinboard/middleware.go | 45 ++
pinboard/responses.go | 122 ++++
pinboard/routes.go | 737 ++++++++++++++++++++++++
pinboard/routes_test.go | 1168 +++++++++++++++++++++++++++++++++++++++
pinboard/utils.go | 145 +++++
pinboard/utils_test.go | 112 ++++
9 files changed, 2496 insertions(+), 2 deletions(-)
create mode 100644 CLAUDE.md
create mode 100644 pinboard/input.go
create mode 100644 pinboard/middleware.go
create mode 100644 pinboard/responses.go
create mode 100644 pinboard/routes.go
create mode 100644 pinboard/routes_test.go
create mode 100644 pinboard/utils.go
create mode 100644 pinboard/utils_test.go
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..65c4624
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,116 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+This project is known as Link Taco (aka, LinkTaco, links, link taco, etc.) and
+is is deployed live at https://linktaco.com
+
+## Common Development Commands
+
+### Building Services
+```bash
+# Build all main services
+make all
+
+# Build individual services
+make links # Main web service (port 8000)
+make links-api # GraphQL API service (port 8003)
+make links-short # URL shortening service (port 8001)
+make links-list # Link listing service (port 8002)
+make links-domains # Domain management service (port 8004)
+make links-admin # Admin service
+
+# Clean built binaries
+make clean
+```
+
+### Testing
+```bash
+# Run all tests (sequential)
+make test
+# or
+go test ./... -p 1
+
+# Run tests for specific package
+go test ./api/...
+
+# Run a single test
+go test -v -run TestDirective ./api/... -count=1
+
+# Verify code quality
+go vet ./...
+```
+
+### Code Generation
+```bash
+# Generate GraphQL schema and resolvers
+make schema
+# or
+cd api && go generate ./graph
+
+# Generate translations
+make trans
+# or
+go generate ./internal/translations/translations.go
+
+# Generate data loaders
+cd api/loaders && go generate
+```
+
+## Architecture Overview
+
+### Service-Oriented Architecture
+The application consists of 6 independent services that communicate via GraphQL API:
+
+1. **links** (port 8000) - Main web service for bookmarking and link management
+2. **links-api** (port 8003) - Central GraphQL API powering all services
+3. **links-short** (port 8001) - URL shortening service (like Bitly)
+4. **links-list** (port 8002) - Link listing/bio pages (like Linktree)
+5. **links-domains** (port 8004) - Domain verification service for custom domains
+6. **links-admin** - Administrative interface
+
+### Technology Stack
+- **Backend**: Go 1.22.5+ with Echo v4 web framework
+- **API**: GraphQL using gqlgen with complexity limits and file uploads
+- **Database**: PostgreSQL with Squirrel query builder
+- **Frontend**: Server-side rendered HTML templates (not SPA)
+- **Authentication**: Custom auth with OAuth2 support, session management via SCS
+- **Payments**: Stripe integration for billing
+- **Storage**: Local filesystem or S3-compatible storage
+- **Integrations**: Slack/Mattermost bots, email (SMTP/AWS SES)
+
+### Database & Migrations
+- Migrations are embedded in the binary and run automatically on startup
+- Migration files: `migrations/*.sql`
+- Test environment automatically rolls back and re-runs migrations
+- Database connection configured in `[database]` section of config file
+
+### GraphQL API Structure
+- Schema files: `api/graph/*.graphqls`
+- Configuration: `api/gqlgen.yml`
+- Generated code: `api/graph/generated.go`
+- Custom models bind to `links/models` package
+- Directives for authentication and authorization
+- Data loaders for N+1 query optimization
+
+### Configuration
+- Main config file: `config.ini` (copy from `config.example.ini`)
+- Service ports and domains configured in `[links]` section
+- Each service can have different listen addresses
+- Rate limiting, queue sizes, and limits configurable
+- AutoTLS support for SSL certificate management
+
+### Development Workflow
+1. Copy `config.example.ini` to `config.ini` and configure
+2. Set up PostgreSQL database
+3. Run `make all` to build services
+4. Services will auto-migrate database on first run
+5. Access main service at `http://localhost:8000`
+6. GraphQL playground available at `http://localhost:8000/graphql`
+
+### Testing Approach
+- Tests use in-memory test server with fresh database
+- Each test gets automatic migration rollback/setup
+- Test helpers in `cmd/test/helpers.go`
+- JSON sample data in module directories for testing
+- Tests run sequentially (`-p 1`) to avoid database conflicts
diff --git a/cmd/links/main.go b/cmd/links/main.go
index 131f3e9..b9b9524 100644
--- a/cmd/links/main.go
+++ b/cmd/links/main.go
@@ -24,6 +24,7 @@ import (
"links/list"
"links/mattermost"
"links/models"
+ "links/pinboard"
"links/short"
"links/slack"
@@ -311,9 +312,8 @@ func run() error {
return "tag-large"
} else if count > 4 {
return "tag-medium"
- } else {
- return "tag-normal"
}
+ return "tag-normal"
},
"showCounter": func(obj any) bool {
return models.ShowLinkCounter(obj)
@@ -365,6 +365,8 @@ func run() error {
analytics.NewService(analyticsService, links.Render)
billingService := e.Group("/billing")
billing.NewService(billingService, links.Render)
+ pinboardService := e.Group("/pinboard")
+ pinboard.NewService(pinboardService, links.Render)
oauthConfig := oauth2.ServiceConfig{
Helper: &core.OAuth2Helper{},
diff --git a/pinboard/input.go b/pinboard/input.go
new file mode 100644
index 0000000..55a9374
--- /dev/null
+++ b/pinboard/input.go
@@ -0,0 +1,47 @@
+package pinboard
+
+// AddPostInput represents the input for /v1/posts/add
+type AddPostInput struct {
+ URL string `form:"url" validate:"required,url"`
+ Description string `form:"description" validate:"required"`
+ Extended string `form:"extended"`
+ Tags string `form:"tags"`
+ Dt string `form:"dt"`
+ Replace string `form:"replace"`
+ Shared string `form:"shared"`
+ Toread string `form:"toread"`
+}
+
+// DeletePostInput represents the input for /v1/posts/delete
+type DeletePostInput struct {
+ URL string `form:"url" validate:"required,url"`
+}
+
+// GetPostInput represents the input for /v1/posts/get
+type GetPostInput struct {
+ Tag string `form:"tag"`
+ Dt string `form:"dt"`
+ URL string `form:"url"`
+ Meta string `form:"meta"`
+}
+
+// RecentPostInput represents the input for /v1/posts/recent
+type RecentPostInput struct {
+ Tag string `form:"tag"`
+ Count int `form:"count"`
+}
+
+// DatesPostInput represents the input for /v1/posts/dates
+type DatesPostInput struct {
+ Tag string `form:"tag"`
+}
+
+// AllPostInput represents the input for /v1/posts/all
+type AllPostInput struct {
+ Tag string `form:"tag"`
+ Start int `form:"start"`
+ Results int `form:"results"`
+ Fromdt string `form:"fromdt"`
+ Todt string `form:"todt"`
+ Meta string `form:"meta"`
+}
\ No newline at end of file
diff --git a/pinboard/middleware.go b/pinboard/middleware.go
new file mode 100644
index 0000000..8a77911
--- /dev/null
+++ b/pinboard/middleware.go
@@ -0,0 +1,45 @@
+package pinboard
+
+import (
+ "encoding/base64"
+ "strings"
+
+ "github.com/labstack/echo/v4"
+)
+
+// PinboardAuthMiddleware extracts Pinboard authentication and converts it to LinkTaco format
+func PinboardAuthMiddleware() echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ var token string
+
+ // 1. Check for auth_token parameter
+ authToken := c.QueryParam("auth_token")
+ if authToken != "" {
+ token = authToken
+ } else {
+ // 2. Check for HTTP Basic Auth
+ auth := c.Request().Header.Get("Authorization")
+ if auth != "" && strings.HasPrefix(auth, "Basic ") {
+ // Decode Basic auth
+ encoded := strings.TrimPrefix(auth, "Basic ")
+ decoded, err := base64.StdEncoding.DecodeString(encoded)
+ if err == nil {
+ parts := strings.SplitN(string(decoded), ":", 2)
+ if len(parts) == 2 {
+ // Use password as token (Pinboard uses username:token format)
+ token = parts[1]
+ }
+ }
+ }
+ }
+
+ // If we found a token, convert it to Bearer auth header
+ if token != "" {
+ c.Request().Header.Set("Authorization", "Bearer "+token)
+ }
+
+ return next(c)
+ }
+ }
+}
diff --git a/pinboard/responses.go b/pinboard/responses.go
new file mode 100644
index 0000000..e0e36cb
--- /dev/null
+++ b/pinboard/responses.go
@@ -0,0 +1,122 @@
+package pinboard
+
+import (
+ "encoding/json"
+ "encoding/xml"
+ "net/http"
+ "time"
+
+ "github.com/labstack/echo/v4"
+)
+
+// PinboardPost represents a single post in Pinboard format
+type PinboardPost struct {
+ Href string `xml:"href,attr" json:"href"`
+ Description string `xml:"description,attr" json:"description"`
+ Extended string `xml:"extended,attr" json:"extended"`
+ Hash string `xml:"hash,attr" json:"hash"`
+ Tag string `xml:"tag,attr" json:"tags"`
+ Time string `xml:"time,attr" json:"time"`
+ Shared string `xml:"shared,attr" json:"shared"`
+ Toread string `xml:"toread,attr" json:"toread"`
+}
+
+// PinboardPosts represents the posts response
+type PinboardPosts struct {
+ XMLName xml.Name `xml:"posts" json:"-"`
+ Dt string `xml:"dt,attr" json:"date"`
+ User string `xml:"user,attr" json:"user"`
+ Posts []PinboardPost `xml:"post" json:"posts"`
+}
+
+// PinboardResult represents a simple result response
+type PinboardResult struct {
+ XMLName xml.Name `xml:"result" json:"-"`
+ Code string `xml:"code,attr" json:"result_code"`
+}
+
+// PinboardDates represents the dates response
+type PinboardDates struct {
+ XMLName xml.Name `xml:"dates" json:"-"`
+ User string `xml:"user,attr" json:"user"`
+ Tag string `xml:"tag,attr" json:"tag"`
+ Dates []PinboardDate `xml:"date" json:"dates"`
+}
+
+// PinboardDate represents a single date entry
+type PinboardDate struct {
+ Date string `xml:"date,attr" json:"date"`
+ Count int `xml:"count,attr" json:"count"`
+}
+
+// PinboardNote represents a note in Pinboard format
+type PinboardNote struct {
+ ID string `xml:"id,attr" json:"id"`
+ Title string `xml:"title,attr" json:"title"`
+ Hash string `xml:"hash,attr" json:"hash"`
+ CreatedAt string `xml:"created_at,attr" json:"created_at"`
+ UpdatedAt string `xml:"updated_at,attr" json:"updated_at"`
+ Length int `xml:"length,attr" json:"length"`
+}
+
+// PinboardNotes represents the notes list response
+type PinboardNotes struct {
+ XMLName xml.Name `xml:"notes" json:"-"`
+ Count int `xml:"count,attr" json:"count"`
+ Notes []PinboardNote `xml:"note" json:"notes"`
+}
+
+// PinboardNoteContent represents a single note with content
+type PinboardNoteContent struct {
+ XMLName xml.Name `xml:"note" json:"-"`
+ ID string `xml:"id,attr" json:"id"`
+ Title string `xml:"title,attr" json:"title"`
+ Hash string `xml:"hash,attr" json:"hash"`
+ CreatedAt string `xml:"created_at,attr" json:"created_at"`
+ UpdatedAt string `xml:"updated_at,attr" json:"updated_at"`
+ Length int `xml:"length,attr" json:"length"`
+ Text string `xml:",chardata" json:"text"`
+}
+
+// Helper functions
+
+// formatResponse returns the response in XML or JSON based on format parameter
+func formatResponse(c echo.Context, data interface{}) error {
+ // Check both query parameter and form value
+ format := c.QueryParam("format")
+ if format == "" {
+ format = c.FormValue("format")
+ }
+
+ if format == "json" {
+ c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ return json.NewEncoder(c.Response()).Encode(data)
+ }
+
+ // Default to XML
+ c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8)
+ c.Response().Write([]byte(xml.Header))
+ return xml.NewEncoder(c.Response()).Encode(data)
+}
+
+// formatError returns an error response in Pinboard format
+func formatError(c echo.Context, message string) error {
+ result := &PinboardResult{
+ Code: "something went wrong: " + message,
+ }
+ c.Response().Status = http.StatusOK // Pinboard returns 200 even for errors
+ return formatResponse(c, result)
+}
+
+// formatSuccess returns a success response in Pinboard format
+func formatSuccess(c echo.Context) error {
+ result := &PinboardResult{
+ Code: "done",
+ }
+ return formatResponse(c, result)
+}
+
+// formatTime converts time to Pinboard format (RFC3339)
+func formatTime(t time.Time) string {
+ return t.Format(time.RFC3339)
+}
\ No newline at end of file
diff --git a/pinboard/routes.go b/pinboard/routes.go
new file mode 100644
index 0000000..231fbd9
--- /dev/null
+++ b/pinboard/routes.go
@@ -0,0 +1,737 @@
+package pinboard
+
+import (
+ "links"
+ "links/accounts"
+ "links/core"
+ "links/models"
+ "net/http"
+ "sort"
+ "strconv"
+ "time"
+
+ "git.sr.ht/~emersion/gqlclient"
+ sq "github.com/Masterminds/squirrel"
+ "github.com/labstack/echo/v4"
+ "netlandish.com/x/gobwebs/auth"
+ "netlandish.com/x/gobwebs/database"
+ "netlandish.com/x/gobwebs/server"
+ "netlandish.com/x/gobwebs/validate"
+)
+
+type Service struct {
+ server.BaseService
+}
+
+func (s *Service) RegisterRoutes() {
+ // Add Pinboard auth extraction middleware
+ s.Group.Use(PinboardAuthMiddleware())
+ s.Group.Use(core.InternalAuthMiddleware(accounts.NewUserFetch()))
+ s.Group.Use(auth.AuthRequired())
+
+ // API v1 routes
+ v1 := s.Group.Group("/v1")
+
+ // Posts endpoints
+ v1.POST("/posts/add", s.PostsAdd).Name = s.RouteName("posts_add")
+ v1.POST("/posts/delete", s.PostsDelete).Name = s.RouteName("posts_delete")
+ v1.GET("/posts/get", s.PostsGet).Name = s.RouteName("posts_get")
+ v1.GET("/posts/recent", s.PostsRecent).Name = s.RouteName("posts_recent")
+ v1.GET("/posts/dates", s.PostsDates).Name = s.RouteName("posts_dates")
+ v1.GET("/posts/all", s.PostsAll).Name = s.RouteName("posts_all")
+
+ // Notes endpoints
+ v1.GET("/notes/list", s.NotesList).Name = s.RouteName("notes_list")
+ v1.GET("/notes/:id", s.NotesGet).Name = s.RouteName("notes_get")
+}
+
+// NewService creates a new Pinboard API service
+func NewService(eg *echo.Group, render validate.TemplateRenderFunc) *Service {
+ baseService := server.NewService(eg, "pinboard", render)
+ service := &Service{BaseService: baseService}
+ service.RegisterRoutes()
+ return service
+}
+
+// Helper to get user's default organization (org_type = USER)
+func (s *Service) getUserOrg(c echo.Context) (*models.Organization, error) {
+ gctx := c.(*server.Context)
+ user := gctx.User.(*models.User)
+
+ orgs, err := models.GetOrganizations(c.Request().Context(), &database.FilterOptions{
+ Filter: sq.And{
+ sq.Eq{"o.owner_id": user.ID},
+ sq.Eq{"o.org_type": models.OrgTypeUser},
+ },
+ Limit: 1,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if len(orgs) == 0 {
+ return nil, echo.NewHTTPError(http.StatusNotFound, "No default organization found")
+ }
+ return orgs[0], nil
+}
+
+// PostsAdd handles /v1/posts/add
+func (s *Service) PostsAdd(c echo.Context) error {
+ var input AddPostInput
+ if err := c.Bind(&input); err != nil {
+ return formatError(c, "Invalid input parameters")
+ }
+
+ // Validate input
+ if err := c.Validate(input); err != nil {
+ return formatError(c, err.Error())
+ }
+
+ // Get user's default organization
+ org, err := s.getUserOrg(c)
+ if err != nil {
+ return formatError(c, "Failed to get organization")
+ }
+
+ // Convert fields
+ visibility := convertVisibility(input.Shared)
+ unread := convertToRead(input.Toread)
+ tags := convertTagsToComma(input.Tags)
+
+ // Prepare GraphQL mutation
+ type GraphQLResponse struct {
+ AddLink models.OrgLink `json:"addLink"`
+ }
+
+ var result GraphQLResponse
+ op := gqlclient.NewOperation(
+ `mutation AddLink($url: String!, $title: String!, $description: String, $tags: String, $visibility: LinkVisibility, $unread: Boolean!, $orgSlug: String!) {
+ addLink(input: {
+ url: $url,
+ title: $title,
+ description: $description,
+ tags: $tags,
+ visibility: $visibility,
+ unread: $unread,
+ orgSlug: $orgSlug
+ }) {
+ hash
+ }
+ }`)
+
+ op.Var("url", input.URL)
+ op.Var("title", input.Description)
+ op.Var("description", input.Extended)
+ op.Var("tags", tags)
+ op.Var("visibility", visibility)
+ op.Var("unread", unread)
+ op.Var("orgSlug", org.Slug)
+
+ err = links.Execute(c.Request().Context(), op, &result)
+ if err != nil {
+ if graphError, ok := err.(*gqlclient.Error); ok {
+ return formatError(c, graphError.Error())
+ }
+ return formatError(c, err.Error())
+ }
+
+ return formatSuccess(c)
+}
+
+// PostsDelete handles /v1/posts/delete
+func (s *Service) PostsDelete(c echo.Context) error {
+ var input DeletePostInput
+ if err := c.Bind(&input); err != nil {
+ return formatError(c, "Invalid input parameters")
+ }
+
+ // Validate input
+ if err := c.Validate(input); err != nil {
+ return formatError(c, err.Error())
+ }
+
+ // Get user's default organization
+ org, err := s.getUserOrg(c)
+ if err != nil {
+ return formatError(c, "Failed to get organization")
+ }
+
+ // First, find the link by URL
+ type GraphQLFindResponse struct {
+ GetOrgLinks struct {
+ Result []models.OrgLink `json:"result"`
+ } `json:"getOrgLinks"`
+ }
+
+ var findResult GraphQLFindResponse
+ findOp := gqlclient.NewOperation(
+ `query GetOrgLinks($orgSlug: String!, $search: String!) {
+ getOrgLinks(input: {orgSlug: $orgSlug, search: $search, limit: 1}) {
+ result {
+ hash
+ url
+ }
+ }
+ }`)
+
+ findOp.Var("orgSlug", org.Slug)
+ findOp.Var("search", input.URL)
+
+ err = links.Execute(c.Request().Context(), findOp, &findResult)
+ if err != nil {
+ return formatError(c, "Failed to find link")
+ }
+
+ // Check if we found the link
+ if len(findResult.GetOrgLinks.Result) == 0 {
+ return formatError(c, "Link not found")
+ }
+
+ // Find exact URL match
+ var linkHash string
+ for _, link := range findResult.GetOrgLinks.Result {
+ if link.URL == input.URL {
+ linkHash = link.Hash
+ break
+ }
+ }
+
+ if linkHash == "" {
+ return formatError(c, "Link not found")
+ }
+
+ // Now delete the link by hash
+ type GraphQLDeleteResponse struct {
+ DeleteLink struct {
+ Success bool `json:"success"`
+ } `json:"deleteLink"`
+ }
+
+ var deleteResult GraphQLDeleteResponse
+ deleteOp := gqlclient.NewOperation(
+ `mutation DeleteLink($hash: String!) {
+ deleteLink(hash: $hash) {
+ success
+ }
+ }`)
+
+ deleteOp.Var("hash", linkHash)
+
+ err = links.Execute(c.Request().Context(), deleteOp, &deleteResult)
+ if err != nil {
+ return formatError(c, err.Error())
+ }
+
+ if !deleteResult.DeleteLink.Success {
+ return formatError(c, "Failed to delete link")
+ }
+
+ return formatSuccess(c)
+}
+
+// PostsGet handles /v1/posts/get
+func (s *Service) PostsGet(c echo.Context) error {
+ var input GetPostInput
+ if err := c.Bind(&input); err != nil {
+ return formatError(c, "Invalid input parameters")
+ }
+
+ // Get user's default organization
+ org, err := s.getUserOrg(c)
+ if err != nil {
+ return formatError(c, "Failed to get organization")
+ }
+
+ gctx := c.(*server.Context)
+ user := gctx.User.(*models.User)
+
+ // Prepare GraphQL query
+ type GraphQLResponse struct {
+ GetOrgLinks struct {
+ Result []models.OrgLink `json:"result"`
+ } `json:"getOrgLinks"`
+ }
+
+ var result GraphQLResponse
+ op := gqlclient.NewOperation(
+ `query GetOrgLinks($orgSlug: String!, $tag: String, $search: String) {
+ getOrgLinks(input: {orgSlug: $orgSlug, tag: $tag, search: $search}) {
+ result {
+ id
+ title
+ description
+ url
+ hash
+ visibility
+ unread
+ starred
+ tags {
+ name
+ }
+ createdOn
+ updatedOn
+ }
+ }
+ }`)
+
+ op.Var("orgSlug", org.Slug)
+
+ // Handle optional parameters
+ if input.Tag != "" {
+ op.Var("tag", input.Tag)
+ }
+ if input.URL != "" {
+ op.Var("search", input.URL)
+ }
+
+ err = links.Execute(c.Request().Context(), op, &result)
+ if err != nil {
+ return formatError(c, err.Error())
+ }
+
+ // Filter by exact URL if specified
+ links := result.GetOrgLinks.Result
+ if input.URL != "" {
+ filtered := []models.OrgLink{}
+ for _, link := range links {
+ if link.URL == input.URL {
+ filtered = append(filtered, link)
+ break // Only return first exact match
+ }
+ }
+ links = filtered
+ }
+
+ // Filter by date if specified
+ if input.Dt != "" {
+ // Parse date in YYYY-MM-DD format
+ dt, err := time.Parse("2006-01-02", input.Dt)
+ if err == nil {
+ filtered := []models.OrgLink{}
+ for _, link := range links {
+ if link.CreatedOn.Format("2006-01-02") == dt.Format("2006-01-02") {
+ filtered = append(filtered, link)
+ }
+ }
+ links = filtered
+ }
+ }
+
+ // Convert to Pinboard format
+ posts := make([]PinboardPost, len(links))
+ for i, link := range links {
+ posts[i] = linkToPost(&link)
+ }
+
+ response := &PinboardPosts{
+ Dt: formatTime(time.Now()),
+ User: user.Name,
+ Posts: posts,
+ }
+
+ return formatResponse(c, response)
+}
+
+// PostsRecent handles /v1/posts/recent
+func (s *Service) PostsRecent(c echo.Context) error {
+ var input RecentPostInput
+ if err := c.Bind(&input); err != nil {
+ return formatError(c, "Invalid input parameters")
+ }
+
+ // Workaround for test environment where c.Bind doesn't parse query params correctly
+ if input.Count == 0 && c.QueryParam("count") != "" {
+ if val, err := strconv.Atoi(c.QueryParam("count")); err == nil {
+ input.Count = val
+ }
+ }
+
+ // Default count is 15, max is 100
+ if input.Count == 0 {
+ input.Count = 15
+ } else if input.Count > 100 {
+ input.Count = 100
+ }
+
+ // Get user's default organization
+ org, err := s.getUserOrg(c)
+ if err != nil {
+ return formatError(c, "Failed to get organization")
+ }
+
+ gctx := c.(*server.Context)
+ user := gctx.User.(*models.User)
+
+ // Prepare GraphQL query
+ type GraphQLResponse struct {
+ GetOrgLinks struct {
+ Result []models.OrgLink `json:"result"`
+ } `json:"getOrgLinks"`
+ }
+
+ var result GraphQLResponse
+ op := gqlclient.NewOperation(
+ `query GetOrgLinks($orgSlug: String!, $tag: String, $limit: Int, $order: OrderType) {
+ getOrgLinks(input: {orgSlug: $orgSlug, tag: $tag, limit: $limit, order: $order}) {
+ result {
+ id
+ title
+ description
+ url
+ hash
+ visibility
+ unread
+ starred
+ tags {
+ name
+ }
+ createdOn
+ updatedOn
+ }
+ }
+ }`)
+
+ op.Var("orgSlug", org.Slug)
+ op.Var("limit", input.Count)
+ op.Var("order", "DESC") // Recent posts should be newest first
+
+ // Handle optional tag parameter
+ if input.Tag != "" {
+ op.Var("tag", input.Tag)
+ }
+
+ err = links.Execute(c.Request().Context(), op, &result)
+ if err != nil {
+ return formatError(c, err.Error())
+ }
+
+ // Convert to Pinboard format
+ posts := make([]PinboardPost, len(result.GetOrgLinks.Result))
+ for i, link := range result.GetOrgLinks.Result {
+ posts[i] = linkToPost(&link)
+ }
+
+ response := &PinboardPosts{
+ Dt: formatTime(time.Now()),
+ User: user.Name,
+ Posts: posts,
+ }
+
+ return formatResponse(c, response)
+}
+
+// PostsDates handles /v1/posts/dates
+func (s *Service) PostsDates(c echo.Context) error {
+ var input DatesPostInput
+ if err := c.Bind(&input); err != nil {
+ return formatError(c, "Invalid input parameters")
+ }
+
+ // Get user's default organization
+ org, err := s.getUserOrg(c)
+ if err != nil {
+ return formatError(c, "Failed to get organization")
+ }
+
+ gctx := c.(*server.Context)
+ user := gctx.User.(*models.User)
+
+ // Prepare GraphQL query - get all links to aggregate by date
+ type GraphQLResponse struct {
+ GetOrgLinks struct {
+ Result []models.OrgLink `json:"result"`
+ } `json:"getOrgLinks"`
+ }
+
+ var result GraphQLResponse
+ op := gqlclient.NewOperation(
+ `query GetOrgLinks($orgSlug: String!, $tag: String) {
+ getOrgLinks(input: {orgSlug: $orgSlug, tag: $tag}) {
+ result {
+ createdOn
+ }
+ }
+ }`)
+
+ op.Var("orgSlug", org.Slug)
+
+ // Handle optional tag parameter
+ if input.Tag != "" {
+ op.Var("tag", input.Tag)
+ }
+
+ err = links.Execute(c.Request().Context(), op, &result)
+ if err != nil {
+ return formatError(c, err.Error())
+ }
+
+ // Aggregate by date
+ dateCounts := make(map[string]int)
+ for _, link := range result.GetOrgLinks.Result {
+ date := link.CreatedOn.Format("2006-01-02")
+ dateCounts[date]++
+ }
+
+ // Convert to Pinboard format
+ dates := make([]PinboardDate, 0, len(dateCounts))
+ for date, count := range dateCounts {
+ dates = append(dates, PinboardDate{
+ Date: date,
+ Count: count,
+ })
+ }
+
+ // Sort dates in reverse chronological order
+ sort.Slice(dates, func(i, j int) bool {
+ return dates[i].Date > dates[j].Date
+ })
+
+ response := &PinboardDates{
+ User: user.Name,
+ Tag: input.Tag,
+ Dates: dates,
+ }
+
+ return formatResponse(c, response)
+}
+
+// PostsAll handles /v1/posts/all
+func (s *Service) PostsAll(c echo.Context) error {
+ var input AllPostInput
+ if err := c.Bind(&input); err != nil {
+ return formatError(c, "Invalid input parameters")
+ }
+
+ // Workaround for test environment where c.Bind doesn't parse query params correctly
+ // Manually parse if values are still zero/empty
+ if input.Start == 0 && c.QueryParam("start") != "" {
+ if val, err := strconv.Atoi(c.QueryParam("start")); err == nil {
+ input.Start = val
+ }
+ }
+ if input.Results == 0 && c.QueryParam("results") != "" {
+ if val, err := strconv.Atoi(c.QueryParam("results")); err == nil {
+ input.Results = val
+ }
+ }
+ if input.Fromdt == "" && c.QueryParam("fromdt") != "" {
+ input.Fromdt = c.QueryParam("fromdt")
+ }
+ if input.Todt == "" && c.QueryParam("todt") != "" {
+ input.Todt = c.QueryParam("todt")
+ }
+ if input.Tag == "" && c.QueryParam("tag") != "" {
+ input.Tag = c.QueryParam("tag")
+ }
+
+ // Get user's default organization
+ org, err := s.getUserOrg(c)
+ if err != nil {
+ return formatError(c, "Failed to get organization")
+ }
+
+ gctx := c.(*server.Context)
+ user := gctx.User.(*models.User)
+
+ // Set default results if not specified
+ if input.Results == 0 {
+ input.Results = 1000 // Default to a reasonable number
+ }
+
+ // Prepare GraphQL query
+ type GraphQLResponse struct {
+ GetOrgLinks struct {
+ Result []models.OrgLink `json:"result"`
+ } `json:"getOrgLinks"`
+ }
+
+ var result GraphQLResponse
+ op := gqlclient.NewOperation(
+ `query GetOrgLinks($orgSlug: String!, $tag: String, $limit: Int) {
+ getOrgLinks(input: {orgSlug: $orgSlug, tag: $tag, limit: $limit}) {
+ result {
+ id
+ title
+ description
+ url
+ hash
+ visibility
+ unread
+ starred
+ tags {
+ name
+ }
+ createdOn
+ updatedOn
+ }
+ }
+ }`)
+
+ op.Var("orgSlug", org.Slug)
+
+ // Handle optional tag parameter
+ if input.Tag != "" {
+ op.Var("tag", input.Tag)
+ }
+
+ // For now, we'll fetch a large number and filter/paginate client-side
+ // This is not ideal for large datasets but matches Pinboard's behavior
+ op.Var("limit", input.Results+input.Start)
+
+ err = links.Execute(c.Request().Context(), op, &result)
+ if err != nil {
+ return formatError(c, err.Error())
+ }
+
+ links := result.GetOrgLinks.Result
+
+ // Filter by date range if specified
+ if input.Fromdt != "" || input.Todt != "" {
+ filtered := []models.OrgLink{}
+
+ var fromTime, toTime time.Time
+ if input.Fromdt != "" {
+ fromTime, _ = time.Parse(time.RFC3339, input.Fromdt)
+ }
+ if input.Todt != "" {
+ toTime, _ = time.Parse(time.RFC3339, input.Todt)
+ }
+
+ for _, link := range links {
+ include := true
+ if !fromTime.IsZero() && link.CreatedOn.Before(fromTime) {
+ include = false
+ }
+ if !toTime.IsZero() && link.CreatedOn.After(toTime) {
+ include = false
+ }
+ if include {
+ filtered = append(filtered, link)
+ }
+ }
+ links = filtered
+ }
+
+ // Apply offset-based pagination
+ if input.Start > 0 && input.Start < len(links) {
+ links = links[input.Start:]
+ }
+ if input.Results > 0 && len(links) > input.Results {
+ links = links[:input.Results]
+ }
+
+ // Convert to Pinboard format
+ posts := make([]PinboardPost, len(links))
+ for i, link := range links {
+ posts[i] = linkToPost(&link)
+ }
+
+ response := &PinboardPosts{
+ Dt: formatTime(time.Now()),
+ User: user.Name,
+ Posts: posts,
+ }
+
+ return formatResponse(c, response)
+}
+
+// NotesList handles /v1/notes/list
+func (s *Service) NotesList(c echo.Context) error {
+ // Get user's default organization
+ org, err := s.getUserOrg(c)
+ if err != nil {
+ return formatError(c, "Failed to get organization")
+ }
+
+ // Prepare GraphQL query
+ type GraphQLResponse struct {
+ GetOrgLinks struct {
+ Result []models.OrgLink `json:"result"`
+ } `json:"getOrgLinks"`
+ }
+
+ var result GraphQLResponse
+ op := gqlclient.NewOperation(
+ `query GetOrgLinks($orgSlug: String!, $filter: String!) {
+ getOrgLinks(input: {orgSlug: $orgSlug, filter: $filter}) {
+ result {
+ id
+ title
+ description
+ hash
+ createdOn
+ updatedOn
+ type
+ }
+ }
+ }`)
+
+ op.Var("orgSlug", org.Slug)
+ op.Var("filter", "note") // Filter for notes only
+
+ err = links.Execute(c.Request().Context(), op, &result)
+ if err != nil {
+ return formatError(c, err.Error())
+ }
+
+ // Convert to Pinboard format
+ notes := make([]PinboardNote, 0)
+ for _, link := range result.GetOrgLinks.Result {
+ if link.Type == models.NoteType {
+ notes = append(notes, noteToNote(&link))
+ }
+ }
+
+ response := &PinboardNotes{
+ Count: len(notes),
+ Notes: notes,
+ }
+
+ return formatResponse(c, response)
+}
+
+// NotesGet handles /v1/notes/{id}
+func (s *Service) NotesGet(c echo.Context) error {
+ id := c.Param("id")
+ if id == "" {
+ return formatError(c, "Note ID is required")
+ }
+
+ // Prepare GraphQL query
+ type GraphQLResponse struct {
+ GetOrgLink *models.OrgLink `json:"getOrgLink"`
+ }
+
+ var result GraphQLResponse
+ op := gqlclient.NewOperation(
+ `query GetOrgLink($hash: String!) {
+ getOrgLink(hash: $hash) {
+ id
+ title
+ description
+ hash
+ createdOn
+ updatedOn
+ type
+ }
+ }`)
+
+ op.Var("hash", id) // ID is the hash in our implementation
+
+ err := links.Execute(c.Request().Context(), op, &result)
+ if err != nil {
+ return formatError(c, err.Error())
+ }
+
+ // Check if note was found and is actually a note
+ if result.GetOrgLink == nil {
+ return formatError(c, "Note not found")
+ }
+ if result.GetOrgLink.Type != models.NoteType {
+ return formatError(c, "Not a note")
+ }
+
+ // Convert to Pinboard format
+ response := noteToContent(result.GetOrgLink)
+
+ return formatResponse(c, &response)
+}
diff --git a/pinboard/routes_test.go b/pinboard/routes_test.go
new file mode 100644
index 0000000..a64d138
--- /dev/null
+++ b/pinboard/routes_test.go
@@ -0,0 +1,1168 @@
+package pinboard_test
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "links"
+ "links/cmd"
+ "links/cmd/test"
+ "links/models"
+ "links/pinboard"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/jarcoal/httpmock"
+ "github.com/labstack/echo/v4"
+ "github.com/stretchr/testify/require"
+ gaccts "netlandish.com/x/gobwebs/accounts"
+ "netlandish.com/x/gobwebs/server"
+)
+
+func TestPinboardAuth(t *testing.T) {
+ c := require.New(t)
+ e := echo.New()
+
+ // Create a test group with only Pinboard auth middleware
+ authGroup := e.Group("/test")
+ authGroup.Use(pinboard.PinboardAuthMiddleware())
+
+ // Test handler that checks if auth header was set correctly
+ authGroup.GET("/check", func(ctx echo.Context) error {
+ auth := ctx.Request().Header.Get("Authorization")
+ return ctx.String(http.StatusOK, auth)
+ })
+
+ t.Run("auth_token parameter", func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/test/check?auth_token=testtoken123", nil)
+ recorder := httptest.NewRecorder()
+ e.ServeHTTP(recorder, request)
+ c.Equal(http.StatusOK, recorder.Code)
+ c.Equal("Bearer testtoken123", recorder.Body.String())
+ })
+
+ t.Run("basic auth", func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/test/check", nil)
+ auth := base64.StdEncoding.EncodeToString([]byte("user:testtoken456"))
+ request.Header.Set("Authorization", "Basic "+auth)
+ recorder := httptest.NewRecorder()
+ e.ServeHTTP(recorder, request)
+ c.Equal(http.StatusOK, recorder.Code)
+ c.Equal("Bearer testtoken456", recorder.Body.String())
+ })
+
+ t.Run("no auth provided", func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/test/check", nil)
+ recorder := httptest.NewRecorder()
+ e.ServeHTTP(recorder, request)
+ c.Equal(http.StatusOK, recorder.Code)
+ c.Equal("", recorder.Body.String())
+ })
+}
+
+func TestPinboardHandlers(t *testing.T) {
+ c := require.New(t)
+ srv, e := test.NewWebTestServer(t)
+ cmd.RunMigrations(t, srv.DB)
+
+ // Use existing test user ID 1 which has 'personal-org' from test migration
+ user := test.NewTestUser(1, false, false, true, true)
+
+ pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
+ defer srv.Shutdown()
+ go srv.Run()
+
+ // Helper function to create authenticated context
+ createAuthContext := func(request *http.Request, recorder *httptest.ResponseRecorder) *server.Context {
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: user,
+ }
+ return ctx
+ }
+
+ // Helper function to check XML response structure
+ checkXMLResponse := func(body string, rootElement string) {
+ c.True(strings.HasPrefix(body, "<?xml"))
+ c.True(strings.Contains(body, "<"+rootElement))
+ c.True(strings.Contains(body, "</"+rootElement+">"))
+ }
+
+ // Helper function to check JSON response structure
+ checkJSONResponse := func(body string) {
+ var js json.RawMessage
+ err := json.Unmarshal([]byte(body), &js)
+ c.NoError(err, "Response should be valid JSON")
+ }
+
+ t.Run("posts/add success", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "addLink": map[string]interface{}{
+ "hash": "abc123def456",
+ },
+ },
+ }))
+
+ formData := url.Values{
+ "url": {"https://example.com"},
+ "description": {"Example Title"},
+ "extended": {"Extended description"},
+ "tags": {"tag1 tag2 \"tag with spaces\""},
+ "shared": {"yes"},
+ "toread": {"no"},
+ }
+
+ request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
+ request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/posts/add")
+
+ err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, `code="done"`))
+ })
+
+ t.Run("posts/add missing required fields", func(t *testing.T) {
+ formData := url.Values{
+ "url": {"https://example.com"},
+ // Missing description
+ }
+
+ request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
+ request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/posts/add")
+
+ err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, "something went wrong"))
+ })
+
+ t.Run("posts/add with JSON format", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "addLink": map[string]interface{}{
+ "hash": "abc123def456",
+ },
+ },
+ }))
+
+ formData := url.Values{
+ "url": {"https://example.com"},
+ "description": {"Example Title"},
+ "format": {"json"},
+ }
+
+ request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
+ request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/posts/add")
+
+ err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkJSONResponse(body)
+ c.True(strings.Contains(body, `"result_code":"done"`))
+ })
+
+ t.Run("posts/delete success", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // Set up sequential responses
+ callCount := 0
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ func(req *http.Request) (*http.Response, error) {
+ callCount++
+ if callCount == 1 {
+ // First call: find the link
+ return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{
+ {
+ "hash": "abc123",
+ "url": "https://example.com",
+ },
+ },
+ },
+ },
+ })
+ }
+ // Second call: delete the link
+ return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "deleteLink": map[string]interface{}{
+ "success": true,
+ },
+ },
+ })
+ })
+
+ formData := url.Values{
+ "url": {"https://example.com"},
+ }
+
+ request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/delete", strings.NewReader(formData.Encode()))
+ request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/posts/delete")
+
+ err := test.MakeRequest(srv, pinboardService.PostsDelete, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, `code="done"`))
+ })
+
+ t.Run("posts/delete not found", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{},
+ },
+ },
+ }))
+
+ formData := url.Values{
+ "url": {"https://notfound.com"},
+ }
+
+ request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/delete", strings.NewReader(formData.Encode()))
+ request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/posts/delete")
+
+ err := test.MakeRequest(srv, pinboardService.PostsDelete, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, "Link not found"))
+ })
+
+ t.Run("posts/get all", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{
+ {
+ "id": 1,
+ "title": "Example Title",
+ "description": "This is an extended description",
+ "url": "https://example.com",
+ "hash": "abc123",
+ "visibility": "PUBLIC",
+ "unread": false,
+ "starred": false,
+ "tags": []map[string]interface{}{
+ {"name": "tag1"},
+ {"name": "tag2"},
+ },
+ "createdOn": "2024-01-15T10:30:00Z",
+ "updatedOn": "2024-01-15T10:30:00Z",
+ },
+ },
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/posts/get")
+
+ err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "posts")
+ c.True(strings.Contains(body, "<post"))
+ c.True(strings.Contains(body, "href="))
+ c.True(strings.Contains(body, "tag1 tag2"))
+ })
+
+ t.Run("posts/get with URL filter", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{
+ {
+ "id": 1,
+ "title": "Example Title",
+ "description": "Description",
+ "url": "https://specific.com",
+ "hash": "xyz789",
+ "visibility": "PRIVATE",
+ "unread": true,
+ "starred": false,
+ "tags": []map[string]interface{}{},
+ "createdOn": "2024-01-15T10:30:00Z",
+ "updatedOn": "2024-01-15T10:30:00Z",
+ },
+ },
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get?url=https://specific.com", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/posts/get")
+
+ err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "posts")
+ c.True(strings.Contains(body, "https://specific.com"))
+ c.True(strings.Contains(body, `shared="no"`))
+ c.True(strings.Contains(body, `toread="yes"`))
+ })
+
+ t.Run("posts/recent with count", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ var capturedLimit int
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ func(req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ // Parse the GraphQL request
+ var gqlReq struct {
+ Query string `json:"query"`
+ Variables map[string]interface{} `json:"variables"`
+ }
+ if err := json.Unmarshal(body, &gqlReq); err == nil {
+ if limit, ok := gqlReq.Variables["limit"]; ok {
+ if limitFloat, ok := limit.(float64); ok {
+ capturedLimit = int(limitFloat)
+ }
+ }
+ }
+ return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{},
+ },
+ },
+ })
+ })
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/recent?count=50", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/posts/recent")
+
+ err := test.MakeRequest(srv, pinboardService.PostsRecent, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+ c.Equal(50, capturedLimit)
+ })
+
+ t.Run("posts/recent count exceeds max", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ var capturedLimit int
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ func(req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ // Parse the GraphQL request
+ var gqlReq struct {
+ Query string `json:"query"`
+ Variables map[string]interface{} `json:"variables"`
+ }
+ if err := json.Unmarshal(body, &gqlReq); err == nil {
+ if limit, ok := gqlReq.Variables["limit"]; ok {
+ if limitFloat, ok := limit.(float64); ok {
+ capturedLimit = int(limitFloat)
+ }
+ }
+ }
+ return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{},
+ },
+ },
+ })
+ })
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/recent?count=200", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/posts/recent")
+
+ err := test.MakeRequest(srv, pinboardService.PostsRecent, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+ c.Equal(100, capturedLimit) // Should be capped at 100
+ })
+
+ t.Run("posts/dates", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{
+ {"createdOn": "2024-01-15T10:30:00Z"},
+ {"createdOn": "2024-01-15T11:30:00Z"},
+ {"createdOn": "2024-01-14T09:00:00Z"},
+ {"createdOn": "2024-01-14T15:00:00Z"},
+ {"createdOn": "2024-01-13T08:00:00Z"},
+ },
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/dates", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/posts/dates")
+
+ err := test.MakeRequest(srv, pinboardService.PostsDates, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "dates")
+ c.True(strings.Contains(body, "<date"))
+ c.True(strings.Contains(body, `date="2024-01-15"`))
+ c.True(strings.Contains(body, `count="2"`))
+ })
+
+ t.Run("posts/all with date filter", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{
+ {
+ "id": 1,
+ "title": "Old Link",
+ "description": "Description",
+ "url": "https://old.com",
+ "hash": "old123",
+ "visibility": "PUBLIC",
+ "unread": false,
+ "starred": false,
+ "tags": []map[string]interface{}{},
+ "createdOn": "2024-01-10T10:30:00Z",
+ "updatedOn": "2024-01-10T10:30:00Z",
+ },
+ {
+ "id": 2,
+ "title": "New Link",
+ "description": "Description",
+ "url": "https://new.com",
+ "hash": "new456",
+ "visibility": "PUBLIC",
+ "unread": false,
+ "starred": false,
+ "tags": []map[string]interface{}{},
+ "createdOn": "2024-01-20T10:30:00Z",
+ "updatedOn": "2024-01-20T10:30:00Z",
+ },
+ },
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/all?fromdt=2024-01-15T00:00:00Z", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/posts/all")
+
+ err := test.MakeRequest(srv, pinboardService.PostsAll, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "posts")
+ c.True(strings.Contains(body, "https://new.com"))
+ c.False(strings.Contains(body, "https://old.com")) // Should be filtered out
+ })
+
+ t.Run("notes/list", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{
+ {
+ "id": 100,
+ "title": "My First Note",
+ "description": "This is the content of the note",
+ "hash": "note123",
+ "createdOn": "2024-01-10T10:00:00Z",
+ "updatedOn": "2024-01-11T11:00:00Z",
+ "type": "NOTE",
+ },
+ {
+ "id": 101,
+ "title": "Another Note",
+ "description": "More note content here",
+ "hash": "note456",
+ "createdOn": "2024-01-12T12:00:00Z",
+ "updatedOn": "2024-01-12T12:00:00Z",
+ "type": "NOTE",
+ },
+ },
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/notes/list", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/notes/list")
+
+ err := test.MakeRequest(srv, pinboardService.NotesList, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "notes")
+ c.True(strings.Contains(body, `count="2"`))
+ c.True(strings.Contains(body, `id="note123"`))
+ c.True(strings.Contains(body, `id="note456"`))
+ })
+
+ t.Run("notes/get success", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLink": map[string]interface{}{
+ "id": 100,
+ "title": "My First Note",
+ "description": "This is the full content of the note.\n\nIt has multiple paragraphs.",
+ "hash": "note123",
+ "createdOn": "2024-01-10T10:00:00Z",
+ "updatedOn": "2024-01-11T11:00:00Z",
+ "type": "NOTE",
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/notes/note123", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/notes/:id")
+ ctx.SetParamNames("id")
+ ctx.SetParamValues("note123")
+
+ err := test.MakeRequest(srv, pinboardService.NotesGet, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "note")
+ c.True(strings.Contains(body, `id="note123"`))
+ c.True(strings.Contains(body, "This is the full content"))
+ })
+
+ t.Run("notes/get not a note", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLink": map[string]interface{}{
+ "id": 100,
+ "title": "A Link",
+ "description": "Not a note",
+ "hash": "link123",
+ "createdOn": "2024-01-10T10:00:00Z",
+ "updatedOn": "2024-01-11T11:00:00Z",
+ "type": "LINK",
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/notes/link123", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/notes/:id")
+ ctx.SetParamNames("id")
+ ctx.SetParamValues("link123")
+
+ err := test.MakeRequest(srv, pinboardService.NotesGet, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, "Not a note"))
+ })
+}
+
+func TestTagConversion(t *testing.T) {
+ c := require.New(t)
+ srv, e := test.NewWebTestServer(t)
+ cmd.RunMigrations(t, srv.DB)
+
+ // Use existing test user ID 1 which has 'personal-org' from test migration
+ user := test.NewTestUser(1, false, false, true, true)
+
+ pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
+ defer srv.Shutdown()
+ go srv.Run()
+
+ t.Run("tags with spaces in posts/add", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ var capturedTags string
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ func(req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ // Extract tags from GraphQL variables
+ bodyStr := string(body)
+ if strings.Contains(bodyStr, "tags") {
+ // Find the tags value in the variables JSON
+ start := strings.Index(bodyStr, `"tags":"`) + 8
+ if start >= 8 {
+ end := strings.Index(bodyStr[start:], `"`)
+ if end > 0 {
+ capturedTags = bodyStr[start : start+end]
+ }
+ }
+ }
+ return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "addLink": map[string]interface{}{
+ "hash": "abc123",
+ },
+ },
+ })
+ })
+
+ formData := url.Values{
+ "url": {"https://example.com"},
+ "description": {"Example"},
+ "tags": {`simple "tag with spaces" another`},
+ }
+
+ request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
+ request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request.Header.Set("Authorization", "Internal testtoken")
+ recorder := httptest.NewRecorder()
+
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: user,
+ }
+ ctx.SetPath("/pinboard/v1/posts/add")
+
+ err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
+ c.NoError(err)
+
+ // Verify tags were converted correctly
+ c.Equal("simple,tag with spaces,another", capturedTags)
+ })
+
+ t.Run("tags returned with spaces quoted", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{
+ {
+ "id": 1,
+ "title": "Test",
+ "description": "Test",
+ "url": "https://test.com",
+ "hash": "test123",
+ "visibility": "PUBLIC",
+ "unread": false,
+ "starred": false,
+ "tags": []map[string]interface{}{
+ {"name": "simple"},
+ {"name": "tag with spaces"},
+ {"name": "another"},
+ },
+ "createdOn": "2024-01-15T10:30:00Z",
+ "updatedOn": "2024-01-15T10:30:00Z",
+ },
+ },
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get", nil)
+ request.Header.Set("Authorization", "Internal testtoken")
+ recorder := httptest.NewRecorder()
+
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: user,
+ }
+ ctx.SetPath("/pinboard/v1/posts/get")
+
+ err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
+ c.NoError(err)
+
+ body := recorder.Body.String()
+ // Check that tags with spaces are quoted (" is the XML entity for ")
+ c.True(strings.Contains(body, `tag="simple "tag with spaces" another"`))
+ })
+}
+
+func TestErrorHandling(t *testing.T) {
+ c := require.New(t)
+ srv, e := test.NewWebTestServer(t)
+ cmd.RunMigrations(t, srv.DB)
+
+ // Create a user with no organizations
+ dbCtx := test.NewDBContext(srv.DB, "America/Managua")
+ userNoOrg := &models.User{
+ BaseUser: gaccts.BaseUser{
+ ID: 999,
+ Email: "noorg@example.com",
+ },
+ Name: "No Org User",
+ }
+ userNoOrg.SetVerified(true)
+ userNoOrg.SetAuthenticated(true)
+ err := userNoOrg.Store(dbCtx)
+ c.NoError(err)
+
+ pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
+ defer srv.Shutdown()
+ go srv.Run()
+
+ t.Run("no organization found", func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get", nil)
+ request.Header.Set("Authorization", "Internal testtoken")
+ recorder := httptest.NewRecorder()
+
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: userNoOrg,
+ }
+ ctx.SetPath("/pinboard/v1/posts/get")
+
+ err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ c.True(strings.Contains(body, "something went wrong"))
+ c.True(strings.Contains(body, "Failed to get organization"))
+ })
+
+ t.Run("graphql error", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "errors": []map[string]interface{}{
+ {
+ "message": "Internal server error",
+ "extensions": map[string]interface{}{
+ "code": "INTERNAL_SERVER_ERROR",
+ },
+ },
+ },
+ }))
+
+ // Use existing test user ID 2 which has 'api-test-org' from test migration
+ user := test.NewTestUser(2, false, false, true, true)
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get", nil)
+ request.Header.Set("Authorization", "Internal testtoken")
+ recorder := httptest.NewRecorder()
+
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: user,
+ }
+ ctx.SetPath("/pinboard/v1/posts/get")
+
+ err = test.MakeRequest(srv, pinboardService.PostsGet, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ c.True(strings.Contains(body, "something went wrong"))
+ })
+}
+
+func TestResponseFormats(t *testing.T) {
+ c := require.New(t)
+ srv, e := test.NewWebTestServer(t)
+ cmd.RunMigrations(t, srv.DB)
+
+ // Use existing test user ID 1 which has 'personal-org' from test migration
+ user := test.NewTestUser(1, false, false, true, true)
+
+ pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
+ defer srv.Shutdown()
+ go srv.Run()
+
+ t.Run("verify XML structure", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{
+ {
+ "id": 1,
+ "title": "Test",
+ "description": "Test Description",
+ "url": "https://test.com",
+ "hash": "test123",
+ "visibility": "PUBLIC",
+ "unread": false,
+ "starred": false,
+ "tags": []map[string]interface{}{},
+ "createdOn": "2024-01-15T10:30:00Z",
+ "updatedOn": "2024-01-15T10:30:00Z",
+ },
+ },
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get", nil)
+ request.Header.Set("Authorization", "Internal testtoken")
+ recorder := httptest.NewRecorder()
+
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: user,
+ }
+ ctx.SetPath("/pinboard/v1/posts/get")
+
+ err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
+ c.NoError(err)
+
+ // Parse XML to verify structure
+ var posts pinboard.PinboardPosts
+ err = xml.Unmarshal(recorder.Body.Bytes(), &posts)
+ c.NoError(err)
+ c.NotEmpty(posts.User)
+ c.NotEmpty(posts.Dt)
+ c.Len(posts.Posts, 1)
+ c.Equal("https://test.com", posts.Posts[0].Href)
+ })
+
+ t.Run("verify JSON structure", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": []map[string]interface{}{
+ {
+ "id": 1,
+ "title": "Test",
+ "description": "Test Description",
+ "url": "https://test.com",
+ "hash": "test123",
+ "visibility": "PUBLIC",
+ "unread": false,
+ "starred": false,
+ "tags": []map[string]interface{}{},
+ "createdOn": "2024-01-15T10:30:00Z",
+ "updatedOn": "2024-01-15T10:30:00Z",
+ },
+ },
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get?format=json", nil)
+ request.Header.Set("Authorization", "Internal testtoken")
+ recorder := httptest.NewRecorder()
+
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: user,
+ }
+ ctx.SetPath("/pinboard/v1/posts/get")
+
+ err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
+ c.NoError(err)
+
+ // Parse JSON to verify structure
+ var result map[string]interface{}
+ err = json.Unmarshal(recorder.Body.Bytes(), &result)
+ c.NoError(err)
+ c.Contains(result, "date")
+ c.Contains(result, "user")
+ c.Contains(result, "posts")
+
+ posts := result["posts"].([]interface{})
+ c.Len(posts, 1)
+ post := posts[0].(map[string]interface{})
+ c.Equal("https://test.com", post["href"])
+ })
+}
+
+func TestEdgeCases(t *testing.T) {
+ c := require.New(t)
+ srv, e := test.NewWebTestServer(t)
+ cmd.RunMigrations(t, srv.DB)
+
+ // Use existing test user ID 1 which has 'personal-org' from test migration
+ user := test.NewTestUser(1, false, false, true, true)
+
+ pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
+ defer srv.Shutdown()
+ go srv.Run()
+
+ t.Run("very long description", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "addLink": map[string]interface{}{
+ "hash": "abc123",
+ },
+ },
+ }))
+
+ longDesc := strings.Repeat("a", 1000)
+ formData := url.Values{
+ "url": {"https://example.com"},
+ "description": {longDesc},
+ }
+
+ request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
+ request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request.Header.Set("Authorization", "Internal testtoken")
+ recorder := httptest.NewRecorder()
+
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: user,
+ }
+ ctx.SetPath("/pinboard/v1/posts/add")
+
+ err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+ })
+
+ t.Run("URL with special characters", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "addLink": map[string]interface{}{
+ "hash": "abc123",
+ },
+ },
+ }))
+
+ formData := url.Values{
+ "url": {"https://example.com/path?query=value&foo=bar#anchor"},
+ "description": {"URL with params"},
+ }
+
+ request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
+ request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request.Header.Set("Authorization", "Internal testtoken")
+ recorder := httptest.NewRecorder()
+
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: user,
+ }
+ ctx.SetPath("/pinboard/v1/posts/add")
+
+ err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+ })
+
+ t.Run("empty tag string", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "addLink": map[string]interface{}{
+ "hash": "abc123",
+ },
+ },
+ }))
+
+ formData := url.Values{
+ "url": {"https://example.com"},
+ "description": {"No tags"},
+ "tags": {""},
+ }
+
+ request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
+ request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
+ request.Header.Set("Authorization", "Internal testtoken")
+ recorder := httptest.NewRecorder()
+
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: user,
+ }
+ ctx.SetPath("/pinboard/v1/posts/add")
+
+ err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+ })
+
+ t.Run("pagination at boundary", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ // Create exactly 10 links
+ links := make([]map[string]interface{}, 10)
+ for i := 0; i < 10; i++ {
+ links[i] = map[string]interface{}{
+ "id": i,
+ "title": fmt.Sprintf("Link %d", i),
+ "description": "Test",
+ "url": fmt.Sprintf("https://example%d.com", i),
+ "hash": fmt.Sprintf("hash%d", i),
+ "visibility": "PUBLIC",
+ "unread": false,
+ "starred": false,
+ "tags": []map[string]interface{}{},
+ "createdOn": "2024-01-15T10:30:00Z",
+ "updatedOn": "2024-01-15T10:30:00Z",
+ }
+ }
+
+ // Mock should return 13 links when we request start=8 with limit=13 (8+5)
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ func(req *http.Request) (*http.Response, error) {
+ // Parse the GraphQL query to check the limit
+ var gqlReq map[string]interface{}
+ json.NewDecoder(req.Body).Decode(&gqlReq)
+
+ // Return all links, handler will paginate
+ return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
+ "data": map[string]interface{}{
+ "getOrgLinks": map[string]interface{}{
+ "result": links,
+ },
+ },
+ })
+ })
+
+ // Use form values instead of query params
+ formData := url.Values{
+ "start": {"8"},
+ "results": {"5"},
+ }
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/all?"+formData.Encode(), nil)
+ request.Header.Set("Authorization", "Internal testtoken")
+ recorder := httptest.NewRecorder()
+
+ ctx := &server.Context{
+ Server: srv,
+ Context: e.NewContext(request, recorder),
+ User: user,
+ }
+ ctx.SetPath("/pinboard/v1/posts/all")
+
+ err := test.MakeRequest(srv, pinboardService.PostsAll, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ // Parse response to check we only got 2 posts
+ var posts pinboard.PinboardPosts
+ err = xml.Unmarshal(recorder.Body.Bytes(), &posts)
+ c.NoError(err)
+
+ c.Len(posts.Posts, 2)
+ if len(posts.Posts) >= 2 {
+ c.True(strings.Contains(posts.Posts[0].Href, "example8.com"))
+ c.True(strings.Contains(posts.Posts[1].Href, "example9.com"))
+ }
+ })
+}
diff --git a/pinboard/utils.go b/pinboard/utils.go
new file mode 100644
index 0000000..4a7e0e4
--- /dev/null
+++ b/pinboard/utils.go
@@ -0,0 +1,145 @@
+package pinboard
+
+import (
+ "links/models"
+ "strings"
+)
+
+// convertTagsToSpace converts comma-separated tags to space-separated
+// Tags with spaces are quoted for Pinboard compatibility
+func convertTagsToSpace(tags string) string {
+ if tags == "" {
+ return ""
+ }
+ tagList := strings.Split(tags, ",")
+ result := make([]string, 0, len(tagList))
+
+ for _, tag := range tagList {
+ tag = strings.TrimSpace(tag)
+ if tag == "" {
+ continue
+ }
+ // If tag contains spaces, quote it
+ if strings.Contains(tag, " ") {
+ tag = `"` + tag + `"`
+ }
+ result = append(result, tag)
+ }
+ return strings.Join(result, " ")
+}
+
+// convertTagsToComma converts space-separated tags to comma-separated
+// Handles quoted tags that may contain spaces
+func convertTagsToComma(tags string) string {
+ if tags == "" {
+ return ""
+ }
+
+ var result []string
+ var currentTag strings.Builder
+ inQuotes := false
+
+ for i, char := range tags {
+ switch char {
+ case '"':
+ inQuotes = !inQuotes
+ case ' ':
+ if !inQuotes {
+ if currentTag.Len() > 0 {
+ result = append(result, currentTag.String())
+ currentTag.Reset()
+ }
+ } else {
+ currentTag.WriteRune(char)
+ }
+ default:
+ currentTag.WriteRune(char)
+ }
+
+ // Handle end of string
+ if i == len(tags)-1 && currentTag.Len() > 0 {
+ result = append(result, currentTag.String())
+ }
+ }
+
+ return strings.Join(result, ",")
+}
+
+// convertVisibility converts Pinboard shared value to LinkTaco visibility
+func convertVisibility(shared string) string {
+ if shared == "no" {
+ return "PRIVATE"
+ }
+ return "PUBLIC"
+}
+
+// convertToShared converts LinkTaco visibility to Pinboard shared value
+func convertToShared(visibility string) string {
+ if visibility == models.OrgLinkVisibilityPrivate {
+ return "no"
+ }
+ return "yes"
+}
+
+// convertToRead converts Pinboard toread value to LinkTaco unread
+func convertToRead(toread string) bool {
+ return toread == "yes"
+}
+
+// convertToToread converts LinkTaco unread to Pinboard toread value
+func convertToToread(unread bool) string {
+ if unread {
+ return "yes"
+ }
+ return "no"
+}
+
+// linkToPost converts a LinkTaco OrgLink to a Pinboard post
+func linkToPost(link *models.OrgLink) PinboardPost {
+ tags := make([]string, len(link.Tags))
+ for i, tag := range link.Tags {
+ tags[i] = tag.Name
+ }
+
+ // Convert tags to space-separated format with proper quoting
+ tagString := ""
+ if len(tags) > 0 {
+ tagString = convertTagsToSpace(strings.Join(tags, ","))
+ }
+
+ return PinboardPost{
+ Href: link.URL,
+ Description: link.Title,
+ Extended: link.Description,
+ Hash: link.Hash,
+ Tag: tagString,
+ Time: formatTime(link.CreatedOn),
+ Shared: convertToShared(string(link.Visibility)),
+ Toread: convertToToread(link.Unread),
+ }
+}
+
+// noteToNote converts a LinkTaco OrgLink (type NOTE) to a Pinboard note
+func noteToNote(link *models.OrgLink) PinboardNote {
+ return PinboardNote{
+ ID: link.Hash, // Using hash as ID
+ Title: link.Title,
+ Hash: link.Hash,
+ CreatedAt: formatTime(link.CreatedOn),
+ UpdatedAt: formatTime(link.UpdatedOn),
+ Length: len(link.Description),
+ }
+}
+
+// noteToContent converts a LinkTaco OrgLink (type NOTE) to a Pinboard note with content
+func noteToContent(link *models.OrgLink) PinboardNoteContent {
+ return PinboardNoteContent{
+ ID: link.Hash,
+ Title: link.Title,
+ Hash: link.Hash,
+ CreatedAt: formatTime(link.CreatedOn),
+ UpdatedAt: formatTime(link.UpdatedOn),
+ Length: len(link.Description),
+ Text: link.Description,
+ }
+}
\ No newline at end of file
diff --git a/pinboard/utils_test.go b/pinboard/utils_test.go
new file mode 100644
index 0000000..0bbebe5
--- /dev/null
+++ b/pinboard/utils_test.go
@@ -0,0 +1,112 @@
+package pinboard
+
+import (
+ "testing"
+)
+
+func TestConvertTagsToComma(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "simple tags",
+ input: "tag1 tag2 tag3",
+ expected: "tag1,tag2,tag3",
+ },
+ {
+ name: "quoted tags with spaces",
+ input: `tag1 "tag with spaces" tag3`,
+ expected: "tag1,tag with spaces,tag3",
+ },
+ {
+ name: "mixed quoted and unquoted",
+ input: `simple "complex tag" another "multi word tag"`,
+ expected: "simple,complex tag,another,multi word tag",
+ },
+ {
+ name: "empty string",
+ input: "",
+ expected: "",
+ },
+ {
+ name: "only spaces",
+ input: " ",
+ expected: "",
+ },
+ {
+ name: "tags with extra spaces",
+ input: "tag1 tag2 tag3",
+ expected: "tag1,tag2,tag3",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := convertTagsToComma(tt.input)
+ if result != tt.expected {
+ t.Errorf("convertTagsToComma(%q) = %q, want %q", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestConvertTagsToSpace(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "simple tags",
+ input: "tag1,tag2,tag3",
+ expected: "tag1 tag2 tag3",
+ },
+ {
+ name: "tags with spaces need quotes",
+ input: "tag1,tag with spaces,tag3",
+ expected: `tag1 "tag with spaces" tag3`,
+ },
+ {
+ name: "multiple tags with spaces",
+ input: "simple,complex tag,another,multi word tag",
+ expected: `simple "complex tag" another "multi word tag"`,
+ },
+ {
+ name: "empty string",
+ input: "",
+ expected: "",
+ },
+ {
+ name: "single tag with space",
+ input: "tag with space",
+ expected: `"tag with space"`,
+ },
+ {
+ name: "tags with extra spaces in CSV",
+ input: "tag1, tag2 , tag3",
+ expected: "tag1 tag2 tag3",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := convertTagsToSpace(tt.input)
+ if result != tt.expected {
+ t.Errorf("convertTagsToSpace(%q) = %q, want %q", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestTagConversionRoundTrip(t *testing.T) {
+ // Test that converting back and forth preserves tags
+ original := "simple,tag with spaces,another"
+ spaced := convertTagsToSpace(original)
+ backToComma := convertTagsToComma(spaced)
+
+ if backToComma != original {
+ t.Errorf("Round trip conversion failed: %q -> %q -> %q", original, spaced, backToComma)
+ }
+}
\ No newline at end of file
--
2.49.0