cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat(wip): create or update post handler & command

+872 -68
+94
cmd/publication_commands.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "strconv" 5 6 6 7 "github.com/spf13/cobra" 7 8 "github.com/stormlightlabs/noteleaf/internal/handlers" ··· 154 155 } 155 156 root.AddCommand(statusCmd) 156 157 158 + postCmd := &cobra.Command{ 159 + Use: "post [note-id]", 160 + Short: "Create a new document on leaflet", 161 + Long: `Publish a local note to leaflet.pub as a new document. 162 + 163 + This command converts your markdown note to leaflet's block format and creates 164 + a new document on the platform. The note will be linked to the leaflet document 165 + for future updates via the patch command. 166 + 167 + Examples: 168 + noteleaf pub post 123 # Publish note 123 169 + noteleaf pub post 123 --draft # Create as draft 170 + noteleaf pub post 123 --preview # Preview without posting 171 + noteleaf pub post 123 --validate # Validate conversion only`, 172 + Args: cobra.ExactArgs(1), 173 + RunE: func(cmd *cobra.Command, args []string) error { 174 + noteID, err := parseNoteID(args[0]) 175 + if err != nil { 176 + return err 177 + } 178 + 179 + isDraft, _ := cmd.Flags().GetBool("draft") 180 + preview, _ := cmd.Flags().GetBool("preview") 181 + validate, _ := cmd.Flags().GetBool("validate") 182 + 183 + defer c.handler.Close() 184 + 185 + if preview { 186 + return c.handler.PostPreview(cmd.Context(), noteID, isDraft) 187 + } 188 + 189 + if validate { 190 + return c.handler.PostValidate(cmd.Context(), noteID, isDraft) 191 + } 192 + 193 + return c.handler.Post(cmd.Context(), noteID, isDraft) 194 + }, 195 + } 196 + postCmd.Flags().Bool("draft", false, "Create as draft instead of publishing") 197 + postCmd.Flags().Bool("preview", false, "Show what would be posted without actually posting") 198 + postCmd.Flags().Bool("validate", false, "Validate markdown conversion without posting") 199 + root.AddCommand(postCmd) 200 + 201 + patchCmd := &cobra.Command{ 202 + Use: "patch [note-id]", 203 + Short: "Update an existing document on leaflet", 204 + Long: `Update an existing leaflet document from a local note. 205 + 206 + This command converts your markdown note to leaflet's block format and updates 207 + the existing document on the platform. The note must have been previously posted 208 + or pulled from leaflet (it needs a leaflet record key). 209 + 210 + The document's draft/published status is preserved from the note's current state. 211 + 212 + Examples: 213 + noteleaf pub patch 123 # Update existing document 214 + noteleaf pub patch 123 --preview # Preview without updating 215 + noteleaf pub patch 123 --validate # Validate conversion only`, 216 + Args: cobra.ExactArgs(1), 217 + RunE: func(cmd *cobra.Command, args []string) error { 218 + noteID, err := parseNoteID(args[0]) 219 + if err != nil { 220 + return err 221 + } 222 + 223 + preview, _ := cmd.Flags().GetBool("preview") 224 + validate, _ := cmd.Flags().GetBool("validate") 225 + 226 + defer c.handler.Close() 227 + 228 + if preview { 229 + return c.handler.PatchPreview(cmd.Context(), noteID) 230 + } 231 + 232 + if validate { 233 + return c.handler.PatchValidate(cmd.Context(), noteID) 234 + } 235 + 236 + return c.handler.Patch(cmd.Context(), noteID) 237 + }, 238 + } 239 + patchCmd.Flags().Bool("preview", false, "Show what would be updated without actually patching") 240 + patchCmd.Flags().Bool("validate", false, "Validate markdown conversion without patching") 241 + root.AddCommand(patchCmd) 242 + 157 243 return root 158 244 } 245 + 246 + func parseNoteID(arg string) (int64, error) { 247 + noteID, err := strconv.ParseInt(arg, 10, 64) 248 + if err != nil { 249 + return 0, fmt.Errorf("invalid note ID '%s': must be a number", arg) 250 + } 251 + return noteID, nil 252 + }
+192
cmd/publication_commands_test.go
··· 66 66 "pull", 67 67 "list [--published|--draft|--all]", 68 68 "status", 69 + "post [note-id]", 70 + "patch [note-id]", 69 71 } 70 72 71 73 for _, expected := range expectedSubcommands { ··· 171 173 t.Error("Expected pull to fail when not authenticated") 172 174 } 173 175 if err != nil && !strings.Contains(err.Error(), "not authenticated") { 176 + t.Errorf("Expected 'not authenticated' error, got: %v", err) 177 + } 178 + }) 179 + }) 180 + 181 + t.Run("Post Command", func(t *testing.T) { 182 + t.Run("requires note ID argument", func(t *testing.T) { 183 + handler, cleanup := createTestPublicationHandler(t) 184 + defer cleanup() 185 + 186 + cmd := NewPublicationCommand(handler).Create() 187 + cmd.SetArgs([]string{"post"}) 188 + err := cmd.Execute() 189 + 190 + if err == nil { 191 + t.Error("Expected error for missing note ID") 192 + } 193 + }) 194 + 195 + t.Run("rejects invalid note ID", func(t *testing.T) { 196 + handler, cleanup := createTestPublicationHandler(t) 197 + defer cleanup() 198 + 199 + cmd := NewPublicationCommand(handler).Create() 200 + cmd.SetArgs([]string{"post", "not-a-number"}) 201 + err := cmd.Execute() 202 + 203 + if err == nil { 204 + t.Error("Expected error for invalid note ID") 205 + } 206 + if !strings.Contains(err.Error(), "invalid note ID") { 207 + t.Errorf("Expected 'invalid note ID' error, got: %v", err) 208 + } 209 + }) 210 + 211 + t.Run("fails when not authenticated", func(t *testing.T) { 212 + handler, cleanup := createTestPublicationHandler(t) 213 + defer cleanup() 214 + 215 + cmd := NewPublicationCommand(handler).Create() 216 + cmd.SetArgs([]string{"post", "123"}) 217 + err := cmd.Execute() 218 + 219 + if err == nil { 220 + t.Error("Expected post to fail when not authenticated") 221 + } 222 + if !strings.Contains(err.Error(), "not authenticated") { 223 + t.Errorf("Expected 'not authenticated' error, got: %v", err) 224 + } 225 + }) 226 + 227 + t.Run("preview mode fails when not authenticated", func(t *testing.T) { 228 + handler, cleanup := createTestPublicationHandler(t) 229 + defer cleanup() 230 + 231 + cmd := NewPublicationCommand(handler).Create() 232 + cmd.SetArgs([]string{"post", "123", "--preview"}) 233 + err := cmd.Execute() 234 + 235 + if err == nil { 236 + t.Error("Expected post --preview to fail when not authenticated") 237 + } 238 + if !strings.Contains(err.Error(), "not authenticated") { 239 + t.Errorf("Expected 'not authenticated' error, got: %v", err) 240 + } 241 + }) 242 + 243 + t.Run("validate mode fails when not authenticated", func(t *testing.T) { 244 + handler, cleanup := createTestPublicationHandler(t) 245 + defer cleanup() 246 + 247 + cmd := NewPublicationCommand(handler).Create() 248 + cmd.SetArgs([]string{"post", "123", "--validate"}) 249 + err := cmd.Execute() 250 + 251 + if err == nil { 252 + t.Error("Expected post --validate to fail when not authenticated") 253 + } 254 + if !strings.Contains(err.Error(), "not authenticated") { 255 + t.Errorf("Expected 'not authenticated' error, got: %v", err) 256 + } 257 + }) 258 + 259 + t.Run("accepts draft flag", func(t *testing.T) { 260 + handler, cleanup := createTestPublicationHandler(t) 261 + defer cleanup() 262 + 263 + cmd := NewPublicationCommand(handler).Create() 264 + cmd.SetArgs([]string{"post", "123", "--draft"}) 265 + err := cmd.Execute() 266 + 267 + if err == nil { 268 + t.Error("Expected post --draft to fail when not authenticated") 269 + } 270 + if !strings.Contains(err.Error(), "not authenticated") { 271 + t.Errorf("Expected 'not authenticated' error, got: %v", err) 272 + } 273 + }) 274 + 275 + t.Run("accepts preview and draft flags together", func(t *testing.T) { 276 + handler, cleanup := createTestPublicationHandler(t) 277 + defer cleanup() 278 + 279 + cmd := NewPublicationCommand(handler).Create() 280 + cmd.SetArgs([]string{"post", "123", "--preview", "--draft"}) 281 + err := cmd.Execute() 282 + 283 + if err == nil { 284 + t.Error("Expected post --preview --draft to fail when not authenticated") 285 + } 286 + if !strings.Contains(err.Error(), "not authenticated") { 287 + t.Errorf("Expected 'not authenticated' error, got: %v", err) 288 + } 289 + }) 290 + }) 291 + 292 + t.Run("Patch Command", func(t *testing.T) { 293 + t.Run("requires note ID argument", func(t *testing.T) { 294 + handler, cleanup := createTestPublicationHandler(t) 295 + defer cleanup() 296 + 297 + cmd := NewPublicationCommand(handler).Create() 298 + cmd.SetArgs([]string{"patch"}) 299 + err := cmd.Execute() 300 + 301 + if err == nil { 302 + t.Error("Expected error for missing note ID") 303 + } 304 + }) 305 + 306 + t.Run("rejects invalid note ID", func(t *testing.T) { 307 + handler, cleanup := createTestPublicationHandler(t) 308 + defer cleanup() 309 + 310 + cmd := NewPublicationCommand(handler).Create() 311 + cmd.SetArgs([]string{"patch", "not-a-number"}) 312 + err := cmd.Execute() 313 + 314 + if err == nil { 315 + t.Error("Expected error for invalid note ID") 316 + } 317 + if !strings.Contains(err.Error(), "invalid note ID") { 318 + t.Errorf("Expected 'invalid note ID' error, got: %v", err) 319 + } 320 + }) 321 + 322 + t.Run("fails when not authenticated", func(t *testing.T) { 323 + handler, cleanup := createTestPublicationHandler(t) 324 + defer cleanup() 325 + 326 + cmd := NewPublicationCommand(handler).Create() 327 + cmd.SetArgs([]string{"patch", "123"}) 328 + err := cmd.Execute() 329 + 330 + if err == nil { 331 + t.Error("Expected patch to fail when not authenticated") 332 + } 333 + if !strings.Contains(err.Error(), "not authenticated") { 334 + t.Errorf("Expected 'not authenticated' error, got: %v", err) 335 + } 336 + }) 337 + 338 + t.Run("preview mode fails when not authenticated", func(t *testing.T) { 339 + handler, cleanup := createTestPublicationHandler(t) 340 + defer cleanup() 341 + 342 + cmd := NewPublicationCommand(handler).Create() 343 + cmd.SetArgs([]string{"patch", "123", "--preview"}) 344 + err := cmd.Execute() 345 + 346 + if err == nil { 347 + t.Error("Expected patch --preview to fail when not authenticated") 348 + } 349 + if !strings.Contains(err.Error(), "not authenticated") { 350 + t.Errorf("Expected 'not authenticated' error, got: %v", err) 351 + } 352 + }) 353 + 354 + t.Run("validate mode fails when not authenticated", func(t *testing.T) { 355 + handler, cleanup := createTestPublicationHandler(t) 356 + defer cleanup() 357 + 358 + cmd := NewPublicationCommand(handler).Create() 359 + cmd.SetArgs([]string{"patch", "123", "--validate"}) 360 + err := cmd.Execute() 361 + 362 + if err == nil { 363 + t.Error("Expected patch --validate to fail when not authenticated") 364 + } 365 + if !strings.Contains(err.Error(), "not authenticated") { 174 366 t.Errorf("Expected 'not authenticated' error, got: %v", err) 175 367 } 176 368 })
+180 -68
internal/handlers/publication.go
··· 2 2 // 3 3 // TODO: Post (create 1) 4 4 // TODO: Patch (update 1) 5 - // TODO: Push (create or update - more than 1) 5 + // TODO: Push 6 + // - Builds on Post & Patch (create or update - more than 1) 7 + // 6 8 // TODO: Add TUI viewing for document details 9 + // - interactive list, read Markdown with glamour 10 + // 11 + // TODO: Add TUI for confirming post 12 + // - proofread post with glamour 13 + // 7 14 // TODO: Repost - "Reblog" - post to BlueSky 8 15 package handlers 9 16 ··· 283 290 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 284 291 } 285 292 286 - note, err := h.repos.Notes.Get(ctx, noteID) 287 - if err != nil { 288 - return fmt.Errorf("failed to get note: %w", err) 289 - } 290 - 291 - if note.HasLeafletAssociation() { 292 - return fmt.Errorf("note already published - use patch to update") 293 - } 294 - 295 - session, err := h.atproto.GetSession() 296 - if err != nil { 297 - return fmt.Errorf("failed to get session: %w", err) 298 - } 299 - 300 293 // TODO: Implement image handling for markdown conversion 301 294 // 1. Extract note's directory from filepath/database 302 295 // 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob() 303 296 // 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet() 304 297 // This will upload images to AT Protocol and get real CIDs/dimensions 305 - converter := public.NewMarkdownConverter() 306 - blocks, err := converter.ToLeaflet(note.Content) 298 + note, doc, err := h.prepareDocumentForPublish(ctx, noteID, isDraft, false) 307 299 if err != nil { 308 - return fmt.Errorf("failed to convert markdown to leaflet format: %w", err) 309 - } 310 - 311 - doc := public.Document{ 312 - Author: session.DID, 313 - Title: note.Title, 314 - Description: "", 315 - Pages: []public.LinearDocument{ 316 - { 317 - Type: public.TypeLinearDocument, 318 - Blocks: blocks, 319 - }, 320 - }, 321 - } 322 - 323 - if !isDraft { 324 - now := time.Now() 325 - doc.PublishedAt = now.Format(time.RFC3339) 300 + return err 326 301 } 327 302 328 303 ui.Infoln("Creating document '%s' on leaflet...", note.Title) 329 304 330 - result, err := h.atproto.PostDocument(ctx, doc, isDraft) 305 + result, err := h.atproto.PostDocument(ctx, *doc, isDraft) 331 306 if err != nil { 332 307 return fmt.Errorf("failed to post document: %w", err) 333 308 } ··· 364 339 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 365 340 } 366 341 367 - note, err := h.repos.Notes.Get(ctx, noteID) 342 + tempNote, err := h.repos.Notes.Get(ctx, noteID) 368 343 if err != nil { 369 344 return fmt.Errorf("failed to get note: %w", err) 370 345 } 371 346 372 - if !note.HasLeafletAssociation() { 373 - return fmt.Errorf("note not published - use post to create") 374 - } 375 - 376 - session, err := h.atproto.GetSession() 377 - if err != nil { 378 - return fmt.Errorf("failed to get session: %w", err) 379 - } 380 - 381 347 // TODO: Implement image handling for markdown conversion (same as Post method) 382 348 // 1. Extract note's directory from filepath/database 383 349 // 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob() 384 350 // 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet() 385 351 // This will upload images to AT Protocol and get real CIDs/dimensions 386 - converter := public.NewMarkdownConverter() 387 - blocks, err := converter.ToLeaflet(note.Content) 352 + note, doc, err := h.prepareDocumentForPublish(ctx, noteID, tempNote.IsDraft, true) 388 353 if err != nil { 389 - return fmt.Errorf("failed to convert markdown to leaflet format: %w", err) 354 + return err 390 355 } 391 356 392 - doc := public.Document{ 393 - Author: session.DID, 394 - Title: note.Title, 395 - Description: "", 396 - Pages: []public.LinearDocument{ 397 - { 398 - Type: public.TypeLinearDocument, 399 - Blocks: blocks, 400 - }, 401 - }, 402 - } 403 - 404 - if !note.IsDraft && note.PublishedAt != nil { 405 - doc.PublishedAt = note.PublishedAt.Format(time.RFC3339) 406 - } else if !note.IsDraft { 407 - now := time.Now() 408 - doc.PublishedAt = now.Format(time.RFC3339) 409 - note.PublishedAt = &now 357 + // Update note.PublishedAt if we set a new timestamp 358 + if !note.IsDraft && note.PublishedAt == nil && doc.PublishedAt != "" { 359 + publishedAt, err := time.Parse(time.RFC3339, doc.PublishedAt) 360 + if err == nil { 361 + note.PublishedAt = &publishedAt 362 + } 410 363 } 411 364 412 365 ui.Infoln("Updating document '%s' on leaflet...", note.Title) 413 366 414 - result, err := h.atproto.PatchDocument(ctx, *note.LeafletRKey, doc, note.IsDraft) 367 + result, err := h.atproto.PatchDocument(ctx, *note.LeafletRKey, *doc, note.IsDraft) 415 368 if err != nil { 416 369 return fmt.Errorf("failed to patch document: %w", err) 417 370 } ··· 461 414 } 462 415 463 416 ui.Successln("Document deleted successfully!") 417 + 418 + return nil 419 + } 420 + 421 + // prepareDocumentForPublish prepares a note for publication by converting to Leaflet format 422 + func (h *PublicationHandler) prepareDocumentForPublish(ctx context.Context, noteID int64, isDraft bool, forPatch bool) (*models.Note, *public.Document, error) { 423 + note, err := h.repos.Notes.Get(ctx, noteID) 424 + if err != nil { 425 + return nil, nil, fmt.Errorf("failed to get note: %w", err) 426 + } 427 + 428 + if !forPatch && note.HasLeafletAssociation() { 429 + return nil, nil, fmt.Errorf("note already published - use patch to update") 430 + } 431 + 432 + if forPatch && !note.HasLeafletAssociation() { 433 + return nil, nil, fmt.Errorf("note not published - use post to create") 434 + } 435 + 436 + session, err := h.atproto.GetSession() 437 + if err != nil { 438 + return nil, nil, fmt.Errorf("failed to get session: %w", err) 439 + } 440 + 441 + converter := public.NewMarkdownConverter() 442 + blocks, err := converter.ToLeaflet(note.Content) 443 + if err != nil { 444 + return nil, nil, fmt.Errorf("failed to convert markdown to leaflet format: %w", err) 445 + } 446 + 447 + doc := &public.Document{ 448 + Author: session.DID, 449 + Title: note.Title, 450 + Description: "", 451 + Pages: []public.LinearDocument{ 452 + { 453 + Type: public.TypeLinearDocument, 454 + Blocks: blocks, 455 + }, 456 + }, 457 + } 458 + 459 + if !isDraft { 460 + if forPatch && note.PublishedAt != nil { 461 + doc.PublishedAt = note.PublishedAt.Format(time.RFC3339) 462 + } else { 463 + now := time.Now() 464 + doc.PublishedAt = now.Format(time.RFC3339) 465 + } 466 + } 467 + 468 + return note, doc, nil 469 + } 470 + 471 + // PostPreview shows what would be posted without actually posting 472 + func (h *PublicationHandler) PostPreview(ctx context.Context, noteID int64, isDraft bool) error { 473 + if !h.atproto.IsAuthenticated() { 474 + return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 475 + } 476 + 477 + note, doc, err := h.prepareDocumentForPublish(ctx, noteID, isDraft, false) 478 + if err != nil { 479 + return err 480 + } 481 + 482 + status := "published" 483 + if isDraft { 484 + status = "draft" 485 + } 486 + 487 + ui.Infoln("Preview: Would create document on leaflet") 488 + ui.Infoln(" Title: %s", doc.Title) 489 + ui.Infoln(" Status: %s", status) 490 + ui.Infoln(" Pages: %d", len(doc.Pages)) 491 + ui.Infoln(" Blocks: %d", len(doc.Pages[0].Blocks)) 492 + if doc.PublishedAt != "" { 493 + ui.Infoln(" PublishedAt: %s", doc.PublishedAt) 494 + } 495 + ui.Infoln(" Note ID: %d", note.ID) 496 + ui.Successln("Preview complete - no changes made") 497 + 498 + return nil 499 + } 500 + 501 + // PostValidate validates markdown conversion without posting 502 + func (h *PublicationHandler) PostValidate(ctx context.Context, noteID int64, isDraft bool) error { 503 + if !h.atproto.IsAuthenticated() { 504 + return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 505 + } 506 + 507 + note, doc, err := h.prepareDocumentForPublish(ctx, noteID, isDraft, false) 508 + if err != nil { 509 + return err 510 + } 511 + 512 + ui.Infoln("Validating markdown conversion for note %d...", note.ID) 513 + ui.Successln("Validation successful!") 514 + ui.Infoln(" Title: %s", doc.Title) 515 + ui.Infoln(" Blocks converted: %d", len(doc.Pages[0].Blocks)) 516 + 517 + return nil 518 + } 519 + 520 + // PatchPreview shows what would be patched without actually patching 521 + func (h *PublicationHandler) PatchPreview(ctx context.Context, noteID int64) error { 522 + if !h.atproto.IsAuthenticated() { 523 + return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 524 + } 525 + 526 + tempNote, err := h.repos.Notes.Get(ctx, noteID) 527 + if err != nil { 528 + return fmt.Errorf("failed to get note: %w", err) 529 + } 530 + 531 + note, doc, err := h.prepareDocumentForPublish(ctx, noteID, tempNote.IsDraft, true) 532 + if err != nil { 533 + return err 534 + } 535 + 536 + status := "published" 537 + if note.IsDraft { 538 + status = "draft" 539 + } 540 + 541 + ui.Infoln("Preview: Would update document on leaflet") 542 + ui.Infoln(" Title: %s", doc.Title) 543 + ui.Infoln(" Status: %s", status) 544 + ui.Infoln(" RKey: %s", *note.LeafletRKey) 545 + ui.Infoln(" Pages: %d", len(doc.Pages)) 546 + ui.Infoln(" Blocks: %d", len(doc.Pages[0].Blocks)) 547 + if doc.PublishedAt != "" { 548 + ui.Infoln(" PublishedAt: %s", doc.PublishedAt) 549 + } 550 + ui.Successln("Preview complete - no changes made") 551 + 552 + return nil 553 + } 554 + 555 + // PatchValidate validates markdown conversion without patching 556 + func (h *PublicationHandler) PatchValidate(ctx context.Context, noteID int64) error { 557 + if !h.atproto.IsAuthenticated() { 558 + return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 559 + } 560 + 561 + tempNote, err := h.repos.Notes.Get(ctx, noteID) 562 + if err != nil { 563 + return fmt.Errorf("failed to get note: %w", err) 564 + } 565 + 566 + note, doc, err := h.prepareDocumentForPublish(ctx, noteID, tempNote.IsDraft, true) 567 + if err != nil { 568 + return err 569 + } 570 + 571 + ui.Infoln("Validating markdown conversion for note %d...", note.ID) 572 + ui.Successln("Validation successful!") 573 + ui.Infoln(" Title: %s", doc.Title) 574 + ui.Infoln(" RKey: %s", *note.LeafletRKey) 575 + ui.Infoln(" Blocks converted: %d", len(doc.Pages[0].Blocks)) 464 576 465 577 return nil 466 578 }
+406
internal/handlers/publication_test.go
··· 1010 1010 }) 1011 1011 }) 1012 1012 1013 + t.Run("PostPreview", func(t *testing.T) { 1014 + t.Run("returns error when not authenticated", func(t *testing.T) { 1015 + suite := NewHandlerTestSuite(t) 1016 + defer suite.Cleanup() 1017 + 1018 + handler := CreateHandler(t, NewPublicationHandler) 1019 + ctx := context.Background() 1020 + 1021 + err := handler.PostPreview(ctx, 1, false) 1022 + if err == nil { 1023 + t.Error("Expected error when not authenticated") 1024 + } 1025 + 1026 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1027 + t.Errorf("Expected 'not authenticated' error, got '%v'", err) 1028 + } 1029 + }) 1030 + 1031 + t.Run("returns error when note does not exist", func(t *testing.T) { 1032 + suite := NewHandlerTestSuite(t) 1033 + defer suite.Cleanup() 1034 + 1035 + handler := CreateHandler(t, NewPublicationHandler) 1036 + ctx := context.Background() 1037 + 1038 + session := &services.Session{ 1039 + DID: "did:plc:test123", 1040 + Handle: "test.bsky.social", 1041 + AccessJWT: "access_token", 1042 + RefreshJWT: "refresh_token", 1043 + PDSURL: "https://bsky.social", 1044 + ExpiresAt: time.Now().Add(2 * time.Hour), 1045 + Authenticated: true, 1046 + } 1047 + 1048 + err := handler.atproto.RestoreSession(session) 1049 + if err != nil { 1050 + t.Fatalf("Failed to restore session: %v", err) 1051 + } 1052 + 1053 + err = handler.PostPreview(ctx, 999, false) 1054 + if err == nil { 1055 + t.Error("Expected error when note does not exist") 1056 + } 1057 + 1058 + if err != nil && !strings.Contains(err.Error(), "failed to get note") { 1059 + t.Errorf("Expected 'failed to get note' error, got '%v'", err) 1060 + } 1061 + }) 1062 + 1063 + t.Run("returns error when note already published", func(t *testing.T) { 1064 + suite := NewHandlerTestSuite(t) 1065 + defer suite.Cleanup() 1066 + 1067 + handler := CreateHandler(t, NewPublicationHandler) 1068 + ctx := context.Background() 1069 + 1070 + rkey := "existing_rkey" 1071 + cid := "existing_cid" 1072 + note := &models.Note{ 1073 + Title: "Already Published", 1074 + Content: "# Test content", 1075 + LeafletRKey: &rkey, 1076 + LeafletCID: &cid, 1077 + } 1078 + 1079 + id, err := handler.repos.Notes.Create(ctx, note) 1080 + suite.AssertNoError(err, "create note") 1081 + 1082 + session := &services.Session{ 1083 + DID: "did:plc:test123", 1084 + Handle: "test.bsky.social", 1085 + AccessJWT: "access_token", 1086 + RefreshJWT: "refresh_token", 1087 + PDSURL: "https://bsky.social", 1088 + ExpiresAt: time.Now().Add(2 * time.Hour), 1089 + Authenticated: true, 1090 + } 1091 + 1092 + err = handler.atproto.RestoreSession(session) 1093 + if err != nil { 1094 + t.Fatalf("Failed to restore session: %v", err) 1095 + } 1096 + 1097 + err = handler.PostPreview(ctx, id, false) 1098 + if err == nil { 1099 + t.Error("Expected error when note already published") 1100 + } 1101 + 1102 + if err != nil && !strings.Contains(err.Error(), "already published") { 1103 + t.Errorf("Expected 'already published' error, got '%v'", err) 1104 + } 1105 + }) 1106 + 1107 + t.Run("shows preview for valid note", func(t *testing.T) { 1108 + suite := NewHandlerTestSuite(t) 1109 + defer suite.Cleanup() 1110 + 1111 + handler := CreateHandler(t, NewPublicationHandler) 1112 + ctx := context.Background() 1113 + 1114 + note := &models.Note{ 1115 + Title: "Test Note", 1116 + Content: "# Test content\n\nThis is a test.", 1117 + } 1118 + 1119 + id, err := handler.repos.Notes.Create(ctx, note) 1120 + suite.AssertNoError(err, "create note") 1121 + 1122 + session := &services.Session{ 1123 + DID: "did:plc:test123", 1124 + Handle: "test.bsky.social", 1125 + AccessJWT: "access_token", 1126 + RefreshJWT: "refresh_token", 1127 + PDSURL: "https://bsky.social", 1128 + ExpiresAt: time.Now().Add(2 * time.Hour), 1129 + Authenticated: true, 1130 + } 1131 + 1132 + err = handler.atproto.RestoreSession(session) 1133 + if err != nil { 1134 + t.Fatalf("Failed to restore session: %v", err) 1135 + } 1136 + 1137 + err = handler.PostPreview(ctx, id, false) 1138 + suite.AssertNoError(err, "preview should succeed") 1139 + }) 1140 + 1141 + t.Run("shows preview for draft", func(t *testing.T) { 1142 + suite := NewHandlerTestSuite(t) 1143 + defer suite.Cleanup() 1144 + 1145 + handler := CreateHandler(t, NewPublicationHandler) 1146 + ctx := context.Background() 1147 + 1148 + note := &models.Note{ 1149 + Title: "Draft Note", 1150 + Content: "# Draft content", 1151 + } 1152 + 1153 + id, err := handler.repos.Notes.Create(ctx, note) 1154 + suite.AssertNoError(err, "create note") 1155 + 1156 + session := &services.Session{ 1157 + DID: "did:plc:test123", 1158 + Handle: "test.bsky.social", 1159 + AccessJWT: "access_token", 1160 + RefreshJWT: "refresh_token", 1161 + PDSURL: "https://bsky.social", 1162 + ExpiresAt: time.Now().Add(2 * time.Hour), 1163 + Authenticated: true, 1164 + } 1165 + 1166 + err = handler.atproto.RestoreSession(session) 1167 + if err != nil { 1168 + t.Fatalf("Failed to restore session: %v", err) 1169 + } 1170 + 1171 + err = handler.PostPreview(ctx, id, true) 1172 + suite.AssertNoError(err, "preview draft should succeed") 1173 + }) 1174 + }) 1175 + 1176 + t.Run("PostValidate", func(t *testing.T) { 1177 + t.Run("returns error when not authenticated", func(t *testing.T) { 1178 + suite := NewHandlerTestSuite(t) 1179 + defer suite.Cleanup() 1180 + 1181 + handler := CreateHandler(t, NewPublicationHandler) 1182 + ctx := context.Background() 1183 + 1184 + err := handler.PostValidate(ctx, 1, false) 1185 + if err == nil { 1186 + t.Error("Expected error when not authenticated") 1187 + } 1188 + 1189 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1190 + t.Errorf("Expected 'not authenticated' error, got '%v'", err) 1191 + } 1192 + }) 1193 + 1194 + t.Run("validates markdown conversion successfully", func(t *testing.T) { 1195 + suite := NewHandlerTestSuite(t) 1196 + defer suite.Cleanup() 1197 + 1198 + handler := CreateHandler(t, NewPublicationHandler) 1199 + ctx := context.Background() 1200 + 1201 + note := &models.Note{ 1202 + Title: "Test Note", 1203 + Content: "# Test content\n\nValid markdown here.", 1204 + } 1205 + 1206 + id, err := handler.repos.Notes.Create(ctx, note) 1207 + suite.AssertNoError(err, "create note") 1208 + 1209 + session := &services.Session{ 1210 + DID: "did:plc:test123", 1211 + Handle: "test.bsky.social", 1212 + AccessJWT: "access_token", 1213 + RefreshJWT: "refresh_token", 1214 + PDSURL: "https://bsky.social", 1215 + ExpiresAt: time.Now().Add(2 * time.Hour), 1216 + Authenticated: true, 1217 + } 1218 + 1219 + err = handler.atproto.RestoreSession(session) 1220 + if err != nil { 1221 + t.Fatalf("Failed to restore session: %v", err) 1222 + } 1223 + 1224 + err = handler.PostValidate(ctx, id, false) 1225 + suite.AssertNoError(err, "validation should succeed") 1226 + }) 1227 + }) 1228 + 1229 + t.Run("PatchPreview", func(t *testing.T) { 1230 + t.Run("returns error when not authenticated", func(t *testing.T) { 1231 + suite := NewHandlerTestSuite(t) 1232 + defer suite.Cleanup() 1233 + 1234 + handler := CreateHandler(t, NewPublicationHandler) 1235 + ctx := context.Background() 1236 + 1237 + err := handler.PatchPreview(ctx, 1) 1238 + if err == nil { 1239 + t.Error("Expected error when not authenticated") 1240 + } 1241 + 1242 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1243 + t.Errorf("Expected 'not authenticated' error, got '%v'", err) 1244 + } 1245 + }) 1246 + 1247 + t.Run("returns error when note does not exist", func(t *testing.T) { 1248 + suite := NewHandlerTestSuite(t) 1249 + defer suite.Cleanup() 1250 + 1251 + handler := CreateHandler(t, NewPublicationHandler) 1252 + ctx := context.Background() 1253 + 1254 + session := &services.Session{ 1255 + DID: "did:plc:test123", 1256 + Handle: "test.bsky.social", 1257 + AccessJWT: "access_token", 1258 + RefreshJWT: "refresh_token", 1259 + PDSURL: "https://bsky.social", 1260 + ExpiresAt: time.Now().Add(2 * time.Hour), 1261 + Authenticated: true, 1262 + } 1263 + 1264 + err := handler.atproto.RestoreSession(session) 1265 + if err != nil { 1266 + t.Fatalf("Failed to restore session: %v", err) 1267 + } 1268 + 1269 + err = handler.PatchPreview(ctx, 999) 1270 + if err == nil { 1271 + t.Error("Expected error when note does not exist") 1272 + } 1273 + 1274 + if err != nil && !strings.Contains(err.Error(), "failed to get note") { 1275 + t.Errorf("Expected 'failed to get note' error, got '%v'", err) 1276 + } 1277 + }) 1278 + 1279 + t.Run("returns error when note not published", func(t *testing.T) { 1280 + suite := NewHandlerTestSuite(t) 1281 + defer suite.Cleanup() 1282 + 1283 + handler := CreateHandler(t, NewPublicationHandler) 1284 + ctx := context.Background() 1285 + 1286 + note := &models.Note{ 1287 + Title: "Not Published", 1288 + Content: "# Test content", 1289 + } 1290 + 1291 + id, err := handler.repos.Notes.Create(ctx, note) 1292 + suite.AssertNoError(err, "create note") 1293 + 1294 + session := &services.Session{ 1295 + DID: "did:plc:test123", 1296 + Handle: "test.bsky.social", 1297 + AccessJWT: "access_token", 1298 + RefreshJWT: "refresh_token", 1299 + PDSURL: "https://bsky.social", 1300 + ExpiresAt: time.Now().Add(2 * time.Hour), 1301 + Authenticated: true, 1302 + } 1303 + 1304 + err = handler.atproto.RestoreSession(session) 1305 + if err != nil { 1306 + t.Fatalf("Failed to restore session: %v", err) 1307 + } 1308 + 1309 + err = handler.PatchPreview(ctx, id) 1310 + if err == nil { 1311 + t.Error("Expected error when note not published") 1312 + } 1313 + 1314 + if err != nil && !strings.Contains(err.Error(), "not published") { 1315 + t.Errorf("Expected 'not published' error, got '%v'", err) 1316 + } 1317 + }) 1318 + 1319 + t.Run("shows preview for published note", func(t *testing.T) { 1320 + suite := NewHandlerTestSuite(t) 1321 + defer suite.Cleanup() 1322 + 1323 + handler := CreateHandler(t, NewPublicationHandler) 1324 + ctx := context.Background() 1325 + 1326 + rkey := "test_rkey" 1327 + cid := "test_cid" 1328 + publishedAt := time.Now().Add(-24 * time.Hour) 1329 + note := &models.Note{ 1330 + Title: "Published Note", 1331 + Content: "# Updated content", 1332 + LeafletRKey: &rkey, 1333 + LeafletCID: &cid, 1334 + PublishedAt: &publishedAt, 1335 + IsDraft: false, 1336 + } 1337 + 1338 + id, err := handler.repos.Notes.Create(ctx, note) 1339 + suite.AssertNoError(err, "create note") 1340 + 1341 + session := &services.Session{ 1342 + DID: "did:plc:test123", 1343 + Handle: "test.bsky.social", 1344 + AccessJWT: "access_token", 1345 + RefreshJWT: "refresh_token", 1346 + PDSURL: "https://bsky.social", 1347 + ExpiresAt: time.Now().Add(2 * time.Hour), 1348 + Authenticated: true, 1349 + } 1350 + 1351 + err = handler.atproto.RestoreSession(session) 1352 + if err != nil { 1353 + t.Fatalf("Failed to restore session: %v", err) 1354 + } 1355 + 1356 + err = handler.PatchPreview(ctx, id) 1357 + suite.AssertNoError(err, "preview should succeed") 1358 + }) 1359 + }) 1360 + 1361 + t.Run("PatchValidate", func(t *testing.T) { 1362 + t.Run("returns error when not authenticated", func(t *testing.T) { 1363 + suite := NewHandlerTestSuite(t) 1364 + defer suite.Cleanup() 1365 + 1366 + handler := CreateHandler(t, NewPublicationHandler) 1367 + ctx := context.Background() 1368 + 1369 + err := handler.PatchValidate(ctx, 1) 1370 + if err == nil { 1371 + t.Error("Expected error when not authenticated") 1372 + } 1373 + 1374 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1375 + t.Errorf("Expected 'not authenticated' error, got '%v'", err) 1376 + } 1377 + }) 1378 + 1379 + t.Run("validates markdown conversion successfully", func(t *testing.T) { 1380 + suite := NewHandlerTestSuite(t) 1381 + defer suite.Cleanup() 1382 + 1383 + handler := CreateHandler(t, NewPublicationHandler) 1384 + ctx := context.Background() 1385 + 1386 + rkey := "test_rkey" 1387 + cid := "test_cid" 1388 + note := &models.Note{ 1389 + Title: "Published Note", 1390 + Content: "# Updated content\n\nValid markdown here.", 1391 + LeafletRKey: &rkey, 1392 + LeafletCID: &cid, 1393 + IsDraft: false, 1394 + } 1395 + 1396 + id, err := handler.repos.Notes.Create(ctx, note) 1397 + suite.AssertNoError(err, "create note") 1398 + 1399 + session := &services.Session{ 1400 + DID: "did:plc:test123", 1401 + Handle: "test.bsky.social", 1402 + AccessJWT: "access_token", 1403 + RefreshJWT: "refresh_token", 1404 + PDSURL: "https://bsky.social", 1405 + ExpiresAt: time.Now().Add(2 * time.Hour), 1406 + Authenticated: true, 1407 + } 1408 + 1409 + err = handler.atproto.RestoreSession(session) 1410 + if err != nil { 1411 + t.Fatalf("Failed to restore session: %v", err) 1412 + } 1413 + 1414 + err = handler.PatchValidate(ctx, id) 1415 + suite.AssertNoError(err, "validation should succeed") 1416 + }) 1417 + }) 1418 + 1013 1419 t.Run("Delete", func(t *testing.T) { 1014 1420 t.Run("returns error when not authenticated", func(t *testing.T) { 1015 1421 suite := NewHandlerTestSuite(t)