Peter Sanchez: 1 Created Pinboard API to GraphQL bridge. Now you can use any existing Pinboard clients with LinkTaco. 9 files changed, 2496 insertions(+), 2 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/158/mbox | git am -3Learn more about email & git
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
Applied. To git@git.code.netlandish.com:~netlandish/links 943c898..43ae7d2 master -> master