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

feat: Add create & update document handling functionality to ATProtoService

* Implement PostDocument, PatchDocument, and DeleteDocument methods to manage leaflet docs

* TypeDocumentDraft for draft documents

* more test coverage

+1442 -2
+180
internal/handlers/publication.go
··· 1 1 // Package handlers provides command handlers for leaflet publication operations. 2 2 // 3 3 // TODO: Post (create 1) 4 + // TODO: Patch (update 1) 4 5 // TODO: Push (create or update - more than 1) 5 6 // TODO: Add TUI viewing for document details 7 + // TODO: Repost - "Reblog" - post to BlueSky 6 8 package handlers 7 9 8 10 import ( ··· 271 273 for _, note := range notes { 272 274 printPublication(note) 273 275 } 276 + 277 + return nil 278 + } 279 + 280 + // Post creates a new document on leaflet from a local note 281 + func (h *PublicationHandler) Post(ctx context.Context, noteID int64, isDraft bool) error { 282 + if !h.atproto.IsAuthenticated() { 283 + return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 284 + } 285 + 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 + converter := public.NewMarkdownConverter() 301 + blocks, err := converter.ToLeaflet(note.Content) 302 + if err != nil { 303 + return fmt.Errorf("failed to convert markdown to leaflet format: %w", err) 304 + } 305 + 306 + doc := public.Document{ 307 + Author: session.DID, 308 + Title: note.Title, 309 + Description: "", 310 + Pages: []public.LinearDocument{ 311 + { 312 + Type: public.TypeLinearDocument, 313 + Blocks: blocks, 314 + }, 315 + }, 316 + } 317 + 318 + if !isDraft { 319 + now := time.Now() 320 + doc.PublishedAt = now.Format(time.RFC3339) 321 + } 322 + 323 + ui.Infoln("Creating document '%s' on leaflet...", note.Title) 324 + 325 + result, err := h.atproto.PostDocument(ctx, doc, isDraft) 326 + if err != nil { 327 + return fmt.Errorf("failed to post document: %w", err) 328 + } 329 + 330 + note.LeafletRKey = &result.Meta.RKey 331 + note.LeafletCID = &result.Meta.CID 332 + note.IsDraft = isDraft 333 + 334 + if !isDraft && doc.PublishedAt != "" { 335 + publishedAt, err := time.Parse(time.RFC3339, doc.PublishedAt) 336 + if err == nil { 337 + note.PublishedAt = &publishedAt 338 + } 339 + } 340 + 341 + if err := h.repos.Notes.Update(ctx, note); err != nil { 342 + return fmt.Errorf("document created but failed to update local note: %w", err) 343 + } 344 + 345 + if isDraft { 346 + ui.Successln("Draft created successfully!") 347 + } else { 348 + ui.Successln("Document published successfully!") 349 + } 350 + ui.Infoln(" RKey: %s", result.Meta.RKey) 351 + ui.Infoln(" CID: %s", result.Meta.CID) 352 + 353 + return nil 354 + } 355 + 356 + // Patch updates an existing document on leaflet from a local note 357 + func (h *PublicationHandler) Patch(ctx context.Context, noteID int64) error { 358 + if !h.atproto.IsAuthenticated() { 359 + return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 360 + } 361 + 362 + note, err := h.repos.Notes.Get(ctx, noteID) 363 + if err != nil { 364 + return fmt.Errorf("failed to get note: %w", err) 365 + } 366 + 367 + if !note.HasLeafletAssociation() { 368 + return fmt.Errorf("note not published - use post to create") 369 + } 370 + 371 + session, err := h.atproto.GetSession() 372 + if err != nil { 373 + return fmt.Errorf("failed to get session: %w", err) 374 + } 375 + 376 + converter := public.NewMarkdownConverter() 377 + blocks, err := converter.ToLeaflet(note.Content) 378 + if err != nil { 379 + return fmt.Errorf("failed to convert markdown to leaflet format: %w", err) 380 + } 381 + 382 + doc := public.Document{ 383 + Author: session.DID, 384 + Title: note.Title, 385 + Description: "", 386 + Pages: []public.LinearDocument{ 387 + { 388 + Type: public.TypeLinearDocument, 389 + Blocks: blocks, 390 + }, 391 + }, 392 + } 393 + 394 + if !note.IsDraft && note.PublishedAt != nil { 395 + doc.PublishedAt = note.PublishedAt.Format(time.RFC3339) 396 + } else if !note.IsDraft { 397 + now := time.Now() 398 + doc.PublishedAt = now.Format(time.RFC3339) 399 + note.PublishedAt = &now 400 + } 401 + 402 + ui.Infoln("Updating document '%s' on leaflet...", note.Title) 403 + 404 + result, err := h.atproto.PatchDocument(ctx, *note.LeafletRKey, doc, note.IsDraft) 405 + if err != nil { 406 + return fmt.Errorf("failed to patch document: %w", err) 407 + } 408 + 409 + note.LeafletCID = &result.Meta.CID 410 + 411 + if err := h.repos.Notes.Update(ctx, note); err != nil { 412 + return fmt.Errorf("document updated but failed to update local note: %w", err) 413 + } 414 + 415 + ui.Successln("Document updated successfully!") 416 + ui.Infoln(" RKey: %s", result.Meta.RKey) 417 + ui.Infoln(" CID: %s", result.Meta.CID) 418 + 419 + return nil 420 + } 421 + 422 + // Delete removes a document from leaflet 423 + func (h *PublicationHandler) Delete(ctx context.Context, noteID int64) error { 424 + if !h.atproto.IsAuthenticated() { 425 + return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 426 + } 427 + 428 + note, err := h.repos.Notes.Get(ctx, noteID) 429 + if err != nil { 430 + return fmt.Errorf("failed to get note: %w", err) 431 + } 432 + 433 + if !note.HasLeafletAssociation() { 434 + return fmt.Errorf("note not published on leaflet") 435 + } 436 + 437 + ui.Infoln("Deleting document '%s' from leaflet...", note.Title) 438 + 439 + err = h.atproto.DeleteDocument(ctx, *note.LeafletRKey, note.IsDraft) 440 + if err != nil { 441 + return fmt.Errorf("failed to delete document: %w", err) 442 + } 443 + 444 + note.LeafletRKey = nil 445 + note.LeafletCID = nil 446 + note.PublishedAt = nil 447 + note.IsDraft = false 448 + 449 + if err := h.repos.Notes.Update(ctx, note); err != nil { 450 + return fmt.Errorf("document deleted but failed to update local note: %w", err) 451 + } 452 + 453 + ui.Successln("Document deleted successfully!") 274 454 275 455 return nil 276 456 }
+625
internal/handlers/publication_test.go
··· 621 621 suite.AssertNoError(err, "list all leaflet notes") 622 622 }) 623 623 }) 624 + 625 + t.Run("Post", func(t *testing.T) { 626 + t.Run("returns error when not authenticated", func(t *testing.T) { 627 + suite := NewHandlerTestSuite(t) 628 + defer suite.Cleanup() 629 + 630 + handler := CreateHandler(t, NewPublicationHandler) 631 + ctx := context.Background() 632 + 633 + err := handler.Post(ctx, 1, false) 634 + if err == nil { 635 + t.Error("Expected error when not authenticated") 636 + } 637 + 638 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 639 + t.Errorf("Expected 'not authenticated' error, got '%v'", err) 640 + } 641 + }) 642 + 643 + t.Run("returns error when note does not exist", func(t *testing.T) { 644 + suite := NewHandlerTestSuite(t) 645 + defer suite.Cleanup() 646 + 647 + handler := CreateHandler(t, NewPublicationHandler) 648 + ctx := context.Background() 649 + 650 + session := &services.Session{ 651 + DID: "did:plc:test123", 652 + Handle: "test.bsky.social", 653 + AccessJWT: "access_token", 654 + RefreshJWT: "refresh_token", 655 + PDSURL: "https://bsky.social", 656 + ExpiresAt: time.Now().Add(2 * time.Hour), 657 + Authenticated: true, 658 + } 659 + 660 + err := handler.atproto.RestoreSession(session) 661 + if err != nil { 662 + t.Fatalf("Failed to restore session: %v", err) 663 + } 664 + 665 + err = handler.Post(ctx, 999, false) 666 + if err == nil { 667 + t.Error("Expected error when note does not exist") 668 + } 669 + 670 + if err != nil && !strings.Contains(err.Error(), "failed to get note") { 671 + t.Errorf("Expected 'failed to get note' error, got '%v'", err) 672 + } 673 + }) 674 + 675 + t.Run("returns error when note already published", func(t *testing.T) { 676 + suite := NewHandlerTestSuite(t) 677 + defer suite.Cleanup() 678 + 679 + handler := CreateHandler(t, NewPublicationHandler) 680 + ctx := context.Background() 681 + 682 + rkey := "existing_rkey" 683 + cid := "existing_cid" 684 + note := &models.Note{ 685 + Title: "Already Published", 686 + Content: "Test content", 687 + LeafletRKey: &rkey, 688 + LeafletCID: &cid, 689 + } 690 + 691 + id, err := handler.repos.Notes.Create(ctx, note) 692 + suite.AssertNoError(err, "create note") 693 + 694 + session := &services.Session{ 695 + DID: "did:plc:test123", 696 + Handle: "test.bsky.social", 697 + AccessJWT: "access_token", 698 + RefreshJWT: "refresh_token", 699 + PDSURL: "https://bsky.social", 700 + ExpiresAt: time.Now().Add(2 * time.Hour), 701 + Authenticated: true, 702 + } 703 + 704 + err = handler.atproto.RestoreSession(session) 705 + if err != nil { 706 + t.Fatalf("Failed to restore session: %v", err) 707 + } 708 + 709 + err = handler.Post(ctx, id, false) 710 + if err == nil { 711 + t.Error("Expected error when note already published") 712 + } 713 + 714 + if err != nil && !strings.Contains(err.Error(), "already published") { 715 + t.Errorf("Expected 'already published' error, got '%v'", err) 716 + } 717 + }) 718 + 719 + t.Run("handles markdown conversion errors", func(t *testing.T) { 720 + suite := NewHandlerTestSuite(t) 721 + defer suite.Cleanup() 722 + 723 + handler := CreateHandler(t, NewPublicationHandler) 724 + ctx := context.Background() 725 + 726 + note := &models.Note{ 727 + Title: "Test Note", 728 + Content: "# Valid markdown", 729 + } 730 + 731 + id, err := handler.repos.Notes.Create(ctx, note) 732 + suite.AssertNoError(err, "create note") 733 + 734 + session := &services.Session{ 735 + DID: "did:plc:test123", 736 + Handle: "test.bsky.social", 737 + AccessJWT: "access_token", 738 + RefreshJWT: "refresh_token", 739 + PDSURL: "https://bsky.social", 740 + ExpiresAt: time.Now().Add(2 * time.Hour), 741 + Authenticated: true, 742 + } 743 + 744 + err = handler.atproto.RestoreSession(session) 745 + if err != nil { 746 + t.Fatalf("Failed to restore session: %v", err) 747 + } 748 + 749 + err = handler.Post(ctx, id, false) 750 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 751 + if !strings.Contains(err.Error(), "failed to post document") && !strings.Contains(err.Error(), "failed to get session") { 752 + t.Logf("Got expected error during post: %v", err) 753 + } 754 + } 755 + }) 756 + 757 + t.Run("sets correct draft status", func(t *testing.T) { 758 + suite := NewHandlerTestSuite(t) 759 + defer suite.Cleanup() 760 + 761 + handler := CreateHandler(t, NewPublicationHandler) 762 + ctx := context.Background() 763 + 764 + note := &models.Note{ 765 + Title: "Draft Note", 766 + Content: "# Test content", 767 + } 768 + 769 + id, err := handler.repos.Notes.Create(ctx, note) 770 + suite.AssertNoError(err, "create note") 771 + 772 + session := &services.Session{ 773 + DID: "did:plc:test123", 774 + Handle: "test.bsky.social", 775 + AccessJWT: "access_token", 776 + RefreshJWT: "refresh_token", 777 + PDSURL: "https://bsky.social", 778 + ExpiresAt: time.Now().Add(2 * time.Hour), 779 + Authenticated: true, 780 + } 781 + 782 + err = handler.atproto.RestoreSession(session) 783 + if err != nil { 784 + t.Fatalf("Failed to restore session: %v", err) 785 + } 786 + 787 + err = handler.Post(ctx, id, true) 788 + 789 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 790 + t.Logf("Got error during post (expected for external service call): %v", err) 791 + } 792 + }) 793 + }) 794 + 795 + t.Run("Patch", func(t *testing.T) { 796 + t.Run("returns error when not authenticated", func(t *testing.T) { 797 + suite := NewHandlerTestSuite(t) 798 + defer suite.Cleanup() 799 + 800 + handler := CreateHandler(t, NewPublicationHandler) 801 + ctx := context.Background() 802 + 803 + err := handler.Patch(ctx, 1) 804 + if err == nil { 805 + t.Error("Expected error when not authenticated") 806 + } 807 + 808 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 809 + t.Errorf("Expected 'not authenticated' error, got '%v'", err) 810 + } 811 + }) 812 + 813 + t.Run("returns error when note does not exist", func(t *testing.T) { 814 + suite := NewHandlerTestSuite(t) 815 + defer suite.Cleanup() 816 + 817 + handler := CreateHandler(t, NewPublicationHandler) 818 + ctx := context.Background() 819 + 820 + session := &services.Session{ 821 + DID: "did:plc:test123", 822 + Handle: "test.bsky.social", 823 + AccessJWT: "access_token", 824 + RefreshJWT: "refresh_token", 825 + PDSURL: "https://bsky.social", 826 + ExpiresAt: time.Now().Add(2 * time.Hour), 827 + Authenticated: true, 828 + } 829 + 830 + err := handler.atproto.RestoreSession(session) 831 + if err != nil { 832 + t.Fatalf("Failed to restore session: %v", err) 833 + } 834 + 835 + err = handler.Patch(ctx, 999) 836 + if err == nil { 837 + t.Error("Expected error when note does not exist") 838 + } 839 + 840 + if err != nil && !strings.Contains(err.Error(), "failed to get note") { 841 + t.Errorf("Expected 'failed to get note' error, got '%v'", err) 842 + } 843 + }) 844 + 845 + t.Run("returns error when note not published", func(t *testing.T) { 846 + suite := NewHandlerTestSuite(t) 847 + defer suite.Cleanup() 848 + 849 + handler := CreateHandler(t, NewPublicationHandler) 850 + ctx := context.Background() 851 + 852 + note := &models.Note{ 853 + Title: "Not Published", 854 + Content: "Test content", 855 + } 856 + 857 + id, err := handler.repos.Notes.Create(ctx, note) 858 + suite.AssertNoError(err, "create note") 859 + 860 + session := &services.Session{ 861 + DID: "did:plc:test123", 862 + Handle: "test.bsky.social", 863 + AccessJWT: "access_token", 864 + RefreshJWT: "refresh_token", 865 + PDSURL: "https://bsky.social", 866 + ExpiresAt: time.Now().Add(2 * time.Hour), 867 + Authenticated: true, 868 + } 869 + 870 + err = handler.atproto.RestoreSession(session) 871 + if err != nil { 872 + t.Fatalf("Failed to restore session: %v", err) 873 + } 874 + 875 + err = handler.Patch(ctx, id) 876 + if err == nil { 877 + t.Error("Expected error when note not published") 878 + } 879 + 880 + if err != nil && !strings.Contains(err.Error(), "not published") { 881 + t.Errorf("Expected 'not published' error, got '%v'", err) 882 + } 883 + }) 884 + 885 + t.Run("handles published note with existing metadata", func(t *testing.T) { 886 + suite := NewHandlerTestSuite(t) 887 + defer suite.Cleanup() 888 + 889 + handler := CreateHandler(t, NewPublicationHandler) 890 + ctx := context.Background() 891 + 892 + rkey := "existing_rkey" 893 + cid := "existing_cid" 894 + publishedAt := time.Now().Add(-24 * time.Hour) 895 + note := &models.Note{ 896 + Title: "Published Note", 897 + Content: "# Updated content", 898 + LeafletRKey: &rkey, 899 + LeafletCID: &cid, 900 + PublishedAt: &publishedAt, 901 + IsDraft: false, 902 + } 903 + 904 + id, err := handler.repos.Notes.Create(ctx, note) 905 + suite.AssertNoError(err, "create note") 906 + 907 + session := &services.Session{ 908 + DID: "did:plc:test123", 909 + Handle: "test.bsky.social", 910 + AccessJWT: "access_token", 911 + RefreshJWT: "refresh_token", 912 + PDSURL: "https://bsky.social", 913 + ExpiresAt: time.Now().Add(2 * time.Hour), 914 + Authenticated: true, 915 + } 916 + 917 + err = handler.atproto.RestoreSession(session) 918 + if err != nil { 919 + t.Fatalf("Failed to restore session: %v", err) 920 + } 921 + 922 + err = handler.Patch(ctx, id) 923 + 924 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 925 + t.Logf("Got error during patch (expected for external service call): %v", err) 926 + } 927 + }) 928 + 929 + t.Run("handles draft note", func(t *testing.T) { 930 + suite := NewHandlerTestSuite(t) 931 + defer suite.Cleanup() 932 + 933 + handler := CreateHandler(t, NewPublicationHandler) 934 + ctx := context.Background() 935 + 936 + rkey := "draft_rkey" 937 + cid := "draft_cid" 938 + note := &models.Note{ 939 + Title: "Draft Note", 940 + Content: "# Draft content", 941 + LeafletRKey: &rkey, 942 + LeafletCID: &cid, 943 + IsDraft: true, 944 + } 945 + 946 + id, err := handler.repos.Notes.Create(ctx, note) 947 + suite.AssertNoError(err, "create note") 948 + 949 + session := &services.Session{ 950 + DID: "did:plc:test123", 951 + Handle: "test.bsky.social", 952 + AccessJWT: "access_token", 953 + RefreshJWT: "refresh_token", 954 + PDSURL: "https://bsky.social", 955 + ExpiresAt: time.Now().Add(2 * time.Hour), 956 + Authenticated: true, 957 + } 958 + 959 + err = handler.atproto.RestoreSession(session) 960 + if err != nil { 961 + t.Fatalf("Failed to restore session: %v", err) 962 + } 963 + 964 + err = handler.Patch(ctx, id) 965 + 966 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 967 + t.Logf("Got error during patch (expected for external service call): %v", err) 968 + } 969 + }) 970 + 971 + t.Run("handles markdown conversion errors", func(t *testing.T) { 972 + suite := NewHandlerTestSuite(t) 973 + defer suite.Cleanup() 974 + 975 + handler := CreateHandler(t, NewPublicationHandler) 976 + ctx := context.Background() 977 + 978 + rkey := "test_rkey" 979 + cid := "test_cid" 980 + note := &models.Note{ 981 + Title: "Test Note", 982 + Content: "# Valid markdown", 983 + LeafletRKey: &rkey, 984 + LeafletCID: &cid, 985 + } 986 + 987 + id, err := handler.repos.Notes.Create(ctx, note) 988 + suite.AssertNoError(err, "create note") 989 + 990 + session := &services.Session{ 991 + DID: "did:plc:test123", 992 + Handle: "test.bsky.social", 993 + AccessJWT: "access_token", 994 + RefreshJWT: "refresh_token", 995 + PDSURL: "https://bsky.social", 996 + ExpiresAt: time.Now().Add(2 * time.Hour), 997 + Authenticated: true, 998 + } 999 + 1000 + err = handler.atproto.RestoreSession(session) 1001 + if err != nil { 1002 + t.Fatalf("Failed to restore session: %v", err) 1003 + } 1004 + 1005 + err = handler.Patch(ctx, id) 1006 + 1007 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1008 + t.Logf("Got error during patch (expected for external service call): %v", err) 1009 + } 1010 + }) 1011 + }) 1012 + 1013 + t.Run("Delete", 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.Delete(ctx, 1) 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.Delete(ctx, 999) 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 not 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 + note := &models.Note{ 1071 + Title: "Not Published", 1072 + Content: "Test content", 1073 + } 1074 + 1075 + id, err := handler.repos.Notes.Create(ctx, note) 1076 + suite.AssertNoError(err, "create note") 1077 + 1078 + session := &services.Session{ 1079 + DID: "did:plc:test123", 1080 + Handle: "test.bsky.social", 1081 + AccessJWT: "access_token", 1082 + RefreshJWT: "refresh_token", 1083 + PDSURL: "https://bsky.social", 1084 + ExpiresAt: time.Now().Add(2 * time.Hour), 1085 + Authenticated: true, 1086 + } 1087 + 1088 + err = handler.atproto.RestoreSession(session) 1089 + if err != nil { 1090 + t.Fatalf("Failed to restore session: %v", err) 1091 + } 1092 + 1093 + err = handler.Delete(ctx, id) 1094 + if err == nil { 1095 + t.Error("Expected error when note not published") 1096 + } 1097 + 1098 + if err != nil && !strings.Contains(err.Error(), "not published") { 1099 + t.Errorf("Expected 'not published' error, got '%v'", err) 1100 + } 1101 + }) 1102 + 1103 + t.Run("handles published note", func(t *testing.T) { 1104 + suite := NewHandlerTestSuite(t) 1105 + defer suite.Cleanup() 1106 + 1107 + handler := CreateHandler(t, NewPublicationHandler) 1108 + ctx := context.Background() 1109 + 1110 + rkey := "test_rkey" 1111 + cid := "test_cid" 1112 + publishedAt := time.Now().Add(-24 * time.Hour) 1113 + note := &models.Note{ 1114 + Title: "Published Note", 1115 + Content: "# Test content", 1116 + LeafletRKey: &rkey, 1117 + LeafletCID: &cid, 1118 + PublishedAt: &publishedAt, 1119 + IsDraft: false, 1120 + } 1121 + 1122 + id, err := handler.repos.Notes.Create(ctx, note) 1123 + suite.AssertNoError(err, "create note") 1124 + 1125 + session := &services.Session{ 1126 + DID: "did:plc:test123", 1127 + Handle: "test.bsky.social", 1128 + AccessJWT: "access_token", 1129 + RefreshJWT: "refresh_token", 1130 + PDSURL: "https://bsky.social", 1131 + ExpiresAt: time.Now().Add(2 * time.Hour), 1132 + Authenticated: true, 1133 + } 1134 + 1135 + err = handler.atproto.RestoreSession(session) 1136 + if err != nil { 1137 + t.Fatalf("Failed to restore session: %v", err) 1138 + } 1139 + 1140 + err = handler.Delete(ctx, id) 1141 + 1142 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1143 + t.Logf("Got error during delete (expected for external service call): %v", err) 1144 + } 1145 + }) 1146 + 1147 + t.Run("handles draft note", func(t *testing.T) { 1148 + suite := NewHandlerTestSuite(t) 1149 + defer suite.Cleanup() 1150 + 1151 + handler := CreateHandler(t, NewPublicationHandler) 1152 + ctx := context.Background() 1153 + 1154 + rkey := "draft_rkey" 1155 + cid := "draft_cid" 1156 + note := &models.Note{ 1157 + Title: "Draft Note", 1158 + Content: "# Draft content", 1159 + LeafletRKey: &rkey, 1160 + LeafletCID: &cid, 1161 + IsDraft: true, 1162 + } 1163 + 1164 + id, err := handler.repos.Notes.Create(ctx, note) 1165 + suite.AssertNoError(err, "create note") 1166 + 1167 + session := &services.Session{ 1168 + DID: "did:plc:test123", 1169 + Handle: "test.bsky.social", 1170 + AccessJWT: "access_token", 1171 + RefreshJWT: "refresh_token", 1172 + PDSURL: "https://bsky.social", 1173 + ExpiresAt: time.Now().Add(2 * time.Hour), 1174 + Authenticated: true, 1175 + } 1176 + 1177 + err = handler.atproto.RestoreSession(session) 1178 + if err != nil { 1179 + t.Fatalf("Failed to restore session: %v", err) 1180 + } 1181 + 1182 + err = handler.Delete(ctx, id) 1183 + 1184 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1185 + t.Logf("Got error during delete (expected for external service call): %v", err) 1186 + } 1187 + }) 1188 + 1189 + t.Run("does not clear metadata when delete fails", func(t *testing.T) { 1190 + suite := NewHandlerTestSuite(t) 1191 + defer suite.Cleanup() 1192 + 1193 + handler := CreateHandler(t, NewPublicationHandler) 1194 + ctx := context.Background() 1195 + 1196 + rkey := "test_rkey" 1197 + cid := "test_cid" 1198 + publishedAt := time.Now().Add(-24 * time.Hour) 1199 + note := &models.Note{ 1200 + Title: "Test Note", 1201 + Content: "# Test content", 1202 + LeafletRKey: &rkey, 1203 + LeafletCID: &cid, 1204 + PublishedAt: &publishedAt, 1205 + IsDraft: false, 1206 + } 1207 + 1208 + id, err := handler.repos.Notes.Create(ctx, note) 1209 + suite.AssertNoError(err, "create note") 1210 + 1211 + session := &services.Session{ 1212 + DID: "did:plc:test123", 1213 + Handle: "test.bsky.social", 1214 + AccessJWT: "access_token", 1215 + RefreshJWT: "refresh_token", 1216 + PDSURL: "https://bsky.social", 1217 + ExpiresAt: time.Now().Add(2 * time.Hour), 1218 + Authenticated: true, 1219 + } 1220 + 1221 + err = handler.atproto.RestoreSession(session) 1222 + if err != nil { 1223 + t.Fatalf("Failed to restore session: %v", err) 1224 + } 1225 + 1226 + err = handler.Delete(ctx, id) 1227 + if err == nil { 1228 + t.Fatal("Expected delete to fail with invalid token") 1229 + } 1230 + 1231 + if !strings.Contains(err.Error(), "failed to delete document") { 1232 + t.Logf("Got error: %v", err) 1233 + } 1234 + 1235 + updatedNote, err := handler.repos.Notes.Get(ctx, id) 1236 + if err != nil { 1237 + t.Fatalf("Failed to get updated note: %v", err) 1238 + } 1239 + 1240 + if !updatedNote.HasLeafletAssociation() { 1241 + t.Error("Note should still have leaflet association after failed delete") 1242 + } 1243 + 1244 + if updatedNote.LeafletRKey == nil || updatedNote.LeafletCID == nil { 1245 + t.Error("Note metadata should not be cleared after failed delete") 1246 + } 1247 + }) 1248 + }) 624 1249 }
+3 -2
internal/public/public.go
··· 10 10 import "time" 11 11 12 12 const ( 13 - TypeDocument = "pub.leaflet.document" 14 - TypePublication = "pub.leaflet.publication" 13 + TypeDocument = "pub.leaflet.document" 14 + TypeDocumentDraft = "pub.leaflet.document.draft" 15 + TypePublication = "pub.leaflet.publication" 15 16 TypeLinearDocument = "pub.leaflet.pages.linearDocument" 16 17 TypeBlock = "pub.leaflet.pages.linearDocument#block" 17 18
+147
internal/services/atproto.go
··· 44 44 "time" 45 45 46 46 "github.com/bluesky-social/indigo/api/atproto" 47 + lexutil "github.com/bluesky-social/indigo/lex/util" 47 48 "github.com/bluesky-social/indigo/repo" 48 49 "github.com/bluesky-social/indigo/xrpc" 49 50 "github.com/ipfs/go-cid" ··· 320 321 } 321 322 322 323 return publications, nil 324 + } 325 + 326 + // PostDocument creates a new document in the user's repository 327 + func (s *ATProtoService) PostDocument(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error) { 328 + if !s.IsAuthenticated() { 329 + return nil, fmt.Errorf("not authenticated") 330 + } 331 + 332 + if doc.Title == "" { 333 + return nil, fmt.Errorf("document title is required") 334 + } 335 + 336 + collection := public.TypeDocument 337 + if isDraft { 338 + collection = public.TypeDocumentDraft 339 + } 340 + 341 + doc.Type = collection 342 + 343 + recordBytes, err := json.Marshal(doc) 344 + if err != nil { 345 + return nil, fmt.Errorf("failed to marshal document: %w", err) 346 + } 347 + 348 + record := &lexutil.LexiconTypeDecoder{} 349 + if err := record.UnmarshalJSON(recordBytes); err != nil { 350 + return nil, fmt.Errorf("failed to unmarshal document to lexicon type: %w", err) 351 + } 352 + 353 + input := &atproto.RepoCreateRecord_Input{ 354 + Repo: s.session.DID, 355 + Collection: collection, 356 + Record: record, 357 + } 358 + 359 + output, err := atproto.RepoCreateRecord(ctx, s.client, input) 360 + if err != nil { 361 + return nil, fmt.Errorf("failed to create record: %w", err) 362 + } 363 + 364 + parts := strings.Split(output.Uri, "/") 365 + rkey := "" 366 + if len(parts) > 0 { 367 + rkey = parts[len(parts)-1] 368 + } 369 + 370 + meta := public.DocumentMeta{ 371 + RKey: rkey, 372 + CID: output.Cid, 373 + URI: output.Uri, 374 + IsDraft: isDraft, 375 + FetchedAt: time.Now(), 376 + } 377 + 378 + return &DocumentWithMeta{ 379 + Document: doc, 380 + Meta: meta, 381 + }, nil 382 + } 383 + 384 + // PatchDocument updates an existing document in the user's repository 385 + func (s *ATProtoService) PatchDocument(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error) { 386 + if !s.IsAuthenticated() { 387 + return nil, fmt.Errorf("not authenticated") 388 + } 389 + 390 + if rkey == "" { 391 + return nil, fmt.Errorf("rkey is required") 392 + } 393 + 394 + if doc.Title == "" { 395 + return nil, fmt.Errorf("document title is required") 396 + } 397 + 398 + collection := public.TypeDocument 399 + if isDraft { 400 + collection = public.TypeDocumentDraft 401 + } 402 + 403 + doc.Type = collection 404 + 405 + recordBytes, err := json.Marshal(doc) 406 + if err != nil { 407 + return nil, fmt.Errorf("failed to marshal document: %w", err) 408 + } 409 + 410 + record := &lexutil.LexiconTypeDecoder{} 411 + if err := record.UnmarshalJSON(recordBytes); err != nil { 412 + return nil, fmt.Errorf("failed to unmarshal document to lexicon type: %w", err) 413 + } 414 + 415 + input := &atproto.RepoPutRecord_Input{ 416 + Repo: s.session.DID, 417 + Collection: collection, 418 + Rkey: rkey, 419 + Record: record, 420 + } 421 + 422 + output, err := atproto.RepoPutRecord(ctx, s.client, input) 423 + if err != nil { 424 + return nil, fmt.Errorf("failed to update record: %w", err) 425 + } 426 + 427 + uri := fmt.Sprintf("at://%s/%s/%s", s.session.DID, collection, rkey) 428 + 429 + meta := public.DocumentMeta{ 430 + RKey: rkey, 431 + CID: output.Cid, 432 + URI: uri, 433 + IsDraft: isDraft, 434 + FetchedAt: time.Now(), 435 + } 436 + 437 + return &DocumentWithMeta{ 438 + Document: doc, 439 + Meta: meta, 440 + }, nil 441 + } 442 + 443 + // DeleteDocument removes a document from the user's repository 444 + func (s *ATProtoService) DeleteDocument(ctx context.Context, rkey string, isDraft bool) error { 445 + if !s.IsAuthenticated() { 446 + return fmt.Errorf("not authenticated") 447 + } 448 + 449 + if rkey == "" { 450 + return fmt.Errorf("rkey is required") 451 + } 452 + 453 + collection := public.TypeDocument 454 + if isDraft { 455 + collection = public.TypeDocumentDraft 456 + } 457 + 458 + input := &atproto.RepoDeleteRecord_Input{ 459 + Repo: s.session.DID, 460 + Collection: collection, 461 + Rkey: rkey, 462 + } 463 + 464 + _, err := atproto.RepoDeleteRecord(ctx, s.client, input) 465 + if err != nil { 466 + return fmt.Errorf("failed to delete record: %w", err) 467 + } 468 + 469 + return nil 323 470 } 324 471 325 472 // Close cleans up resources
+487
internal/services/atproto_test.go
··· 4 4 "context" 5 5 "testing" 6 6 "time" 7 + 8 + "github.com/stormlightlabs/noteleaf/internal/public" 7 9 ) 8 10 9 11 func TestATProtoService(t *testing.T) { ··· 588 590 err := svc.RefreshToken(ctx) 589 591 if err == nil { 590 592 t.Error("Expected error when context times out") 593 + } 594 + }) 595 + }) 596 + 597 + t.Run("PostDocument", func(t *testing.T) { 598 + t.Run("returns error when not authenticated", func(t *testing.T) { 599 + svc := NewATProtoService() 600 + ctx := context.Background() 601 + 602 + doc := public.Document{ 603 + Title: "Test Document", 604 + } 605 + 606 + result, err := svc.PostDocument(ctx, doc, false) 607 + if err == nil { 608 + t.Error("Expected error when posting document without authentication") 609 + } 610 + if result != nil { 611 + t.Error("Expected nil result when not authenticated") 612 + } 613 + if err.Error() != "not authenticated" { 614 + t.Errorf("Expected 'not authenticated' error, got '%v'", err) 615 + } 616 + }) 617 + 618 + t.Run("returns error when session not authenticated", func(t *testing.T) { 619 + svc := NewATProtoService() 620 + ctx := context.Background() 621 + svc.session = &Session{ 622 + Handle: "test.bsky.social", 623 + Authenticated: false, 624 + } 625 + 626 + doc := public.Document{ 627 + Title: "Test Document", 628 + } 629 + 630 + result, err := svc.PostDocument(ctx, doc, false) 631 + if err == nil { 632 + t.Error("Expected error when posting document with unauthenticated session") 633 + } 634 + if result != nil { 635 + t.Error("Expected nil result when session not authenticated") 636 + } 637 + }) 638 + 639 + t.Run("returns error when document title is empty", func(t *testing.T) { 640 + svc := NewATProtoService() 641 + ctx := context.Background() 642 + svc.session = &Session{ 643 + DID: "did:plc:test123", 644 + Handle: "test.bsky.social", 645 + AccessJWT: "access_token", 646 + RefreshJWT: "refresh_token", 647 + Authenticated: true, 648 + } 649 + 650 + doc := public.Document{ 651 + Title: "", 652 + } 653 + 654 + result, err := svc.PostDocument(ctx, doc, false) 655 + if err == nil { 656 + t.Error("Expected error when document title is empty") 657 + } 658 + if result != nil { 659 + t.Error("Expected nil result when title is empty") 660 + } 661 + if err.Error() != "document title is required" { 662 + t.Errorf("Expected 'document title is required' error, got '%v'", err) 663 + } 664 + }) 665 + 666 + t.Run("returns error when context cancelled", func(t *testing.T) { 667 + svc := NewATProtoService() 668 + svc.session = &Session{ 669 + DID: "did:plc:test123", 670 + Handle: "test.bsky.social", 671 + AccessJWT: "access_token", 672 + RefreshJWT: "refresh_token", 673 + Authenticated: true, 674 + } 675 + 676 + ctx, cancel := context.WithCancel(context.Background()) 677 + cancel() 678 + 679 + doc := public.Document{ 680 + Title: "Test Document", 681 + } 682 + 683 + result, err := svc.PostDocument(ctx, doc, false) 684 + if err == nil { 685 + t.Error("Expected error when context is cancelled") 686 + } 687 + if result != nil { 688 + t.Error("Expected nil result when context is cancelled") 689 + } 690 + }) 691 + 692 + t.Run("returns error when context timeout", func(t *testing.T) { 693 + svc := NewATProtoService() 694 + svc.session = &Session{ 695 + DID: "did:plc:test123", 696 + Handle: "test.bsky.social", 697 + AccessJWT: "access_token", 698 + RefreshJWT: "refresh_token", 699 + Authenticated: true, 700 + } 701 + 702 + ctx, cancel := context.WithTimeout(context.Background(), 1) 703 + defer cancel() 704 + time.Sleep(2 * time.Millisecond) 705 + 706 + doc := public.Document{ 707 + Title: "Test Document", 708 + } 709 + 710 + result, err := svc.PostDocument(ctx, doc, false) 711 + if err == nil { 712 + t.Error("Expected error when context times out") 713 + } 714 + if result != nil { 715 + t.Error("Expected nil result when context times out") 716 + } 717 + }) 718 + 719 + t.Run("validates draft parameter sets correct collection", func(t *testing.T) { 720 + svc := NewATProtoService() 721 + svc.session = &Session{ 722 + DID: "did:plc:test123", 723 + Handle: "test.bsky.social", 724 + AccessJWT: "access_token", 725 + RefreshJWT: "refresh_token", 726 + Authenticated: true, 727 + } 728 + ctx := context.Background() 729 + 730 + doc := public.Document{ 731 + Title: "Test Document", 732 + } 733 + 734 + _, err := svc.PostDocument(ctx, doc, true) 735 + 736 + if err != nil && err.Error() == "not authenticated" { 737 + t.Error("Authentication check should pass, but got authentication error") 738 + } 739 + }) 740 + 741 + t.Run("validates published parameter sets correct collection", func(t *testing.T) { 742 + svc := NewATProtoService() 743 + svc.session = &Session{ 744 + DID: "did:plc:test123", 745 + Handle: "test.bsky.social", 746 + AccessJWT: "access_token", 747 + RefreshJWT: "refresh_token", 748 + Authenticated: true, 749 + } 750 + ctx := context.Background() 751 + 752 + doc := public.Document{ 753 + Title: "Test Document", 754 + } 755 + 756 + _, err := svc.PostDocument(ctx, doc, false) 757 + 758 + if err != nil && err.Error() == "not authenticated" { 759 + t.Error("Authentication check should pass, but got authentication error") 760 + } 761 + }) 762 + }) 763 + 764 + t.Run("PatchDocument", func(t *testing.T) { 765 + t.Run("returns error when not authenticated", func(t *testing.T) { 766 + svc := NewATProtoService() 767 + ctx := context.Background() 768 + 769 + doc := public.Document{ 770 + Title: "Updated Document", 771 + } 772 + 773 + result, err := svc.PatchDocument(ctx, "test-rkey", doc, false) 774 + if err == nil { 775 + t.Error("Expected error when patching document without authentication") 776 + } 777 + if result != nil { 778 + t.Error("Expected nil result when not authenticated") 779 + } 780 + if err.Error() != "not authenticated" { 781 + t.Errorf("Expected 'not authenticated' error, got '%v'", err) 782 + } 783 + }) 784 + 785 + t.Run("returns error when session not authenticated", func(t *testing.T) { 786 + svc := NewATProtoService() 787 + ctx := context.Background() 788 + svc.session = &Session{ 789 + Handle: "test.bsky.social", 790 + Authenticated: false, 791 + } 792 + 793 + doc := public.Document{ 794 + Title: "Updated Document", 795 + } 796 + 797 + result, err := svc.PatchDocument(ctx, "test-rkey", doc, false) 798 + if err == nil { 799 + t.Error("Expected error when patching document with unauthenticated session") 800 + } 801 + if result != nil { 802 + t.Error("Expected nil result when session not authenticated") 803 + } 804 + }) 805 + 806 + t.Run("returns error when rkey is empty", func(t *testing.T) { 807 + svc := NewATProtoService() 808 + ctx := context.Background() 809 + svc.session = &Session{ 810 + DID: "did:plc:test123", 811 + Handle: "test.bsky.social", 812 + AccessJWT: "access_token", 813 + RefreshJWT: "refresh_token", 814 + Authenticated: true, 815 + } 816 + 817 + doc := public.Document{ 818 + Title: "Updated Document", 819 + } 820 + 821 + result, err := svc.PatchDocument(ctx, "", doc, false) 822 + if err == nil { 823 + t.Error("Expected error when rkey is empty") 824 + } 825 + if result != nil { 826 + t.Error("Expected nil result when rkey is empty") 827 + } 828 + if err.Error() != "rkey is required" { 829 + t.Errorf("Expected 'rkey is required' error, got '%v'", err) 830 + } 831 + }) 832 + 833 + t.Run("returns error when document title is empty", func(t *testing.T) { 834 + svc := NewATProtoService() 835 + ctx := context.Background() 836 + svc.session = &Session{ 837 + DID: "did:plc:test123", 838 + Handle: "test.bsky.social", 839 + AccessJWT: "access_token", 840 + RefreshJWT: "refresh_token", 841 + Authenticated: true, 842 + } 843 + 844 + doc := public.Document{ 845 + Title: "", 846 + } 847 + 848 + result, err := svc.PatchDocument(ctx, "test-rkey", doc, false) 849 + if err == nil { 850 + t.Error("Expected error when document title is empty") 851 + } 852 + if result != nil { 853 + t.Error("Expected nil result when title is empty") 854 + } 855 + if err.Error() != "document title is required" { 856 + t.Errorf("Expected 'document title is required' error, got '%v'", err) 857 + } 858 + }) 859 + 860 + t.Run("returns error when context cancelled", func(t *testing.T) { 861 + svc := NewATProtoService() 862 + svc.session = &Session{ 863 + DID: "did:plc:test123", 864 + Handle: "test.bsky.social", 865 + AccessJWT: "access_token", 866 + RefreshJWT: "refresh_token", 867 + Authenticated: true, 868 + } 869 + 870 + ctx, cancel := context.WithCancel(context.Background()) 871 + cancel() 872 + 873 + doc := public.Document{ 874 + Title: "Updated Document", 875 + } 876 + 877 + result, err := svc.PatchDocument(ctx, "test-rkey", doc, false) 878 + if err == nil { 879 + t.Error("Expected error when context is cancelled") 880 + } 881 + if result != nil { 882 + t.Error("Expected nil result when context is cancelled") 883 + } 884 + }) 885 + 886 + t.Run("returns error when context timeout", func(t *testing.T) { 887 + svc := NewATProtoService() 888 + svc.session = &Session{ 889 + DID: "did:plc:test123", 890 + Handle: "test.bsky.social", 891 + AccessJWT: "access_token", 892 + RefreshJWT: "refresh_token", 893 + Authenticated: true, 894 + } 895 + 896 + ctx, cancel := context.WithTimeout(context.Background(), 1) 897 + defer cancel() 898 + time.Sleep(2 * time.Millisecond) 899 + 900 + doc := public.Document{ 901 + Title: "Updated Document", 902 + } 903 + 904 + result, err := svc.PatchDocument(ctx, "test-rkey", doc, false) 905 + if err == nil { 906 + t.Error("Expected error when context times out") 907 + } 908 + if result != nil { 909 + t.Error("Expected nil result when context times out") 910 + } 911 + }) 912 + 913 + t.Run("validates draft parameter sets correct collection", func(t *testing.T) { 914 + svc := NewATProtoService() 915 + svc.session = &Session{ 916 + DID: "did:plc:test123", 917 + Handle: "test.bsky.social", 918 + AccessJWT: "access_token", 919 + RefreshJWT: "refresh_token", 920 + Authenticated: true, 921 + } 922 + ctx := context.Background() 923 + 924 + doc := public.Document{ 925 + Title: "Updated Document", 926 + } 927 + 928 + _, err := svc.PatchDocument(ctx, "test-rkey", doc, true) 929 + 930 + if err != nil && err.Error() == "not authenticated" { 931 + t.Error("Authentication check should pass, but got authentication error") 932 + } 933 + }) 934 + 935 + t.Run("validates published parameter sets correct collection", func(t *testing.T) { 936 + svc := NewATProtoService() 937 + svc.session = &Session{ 938 + DID: "did:plc:test123", 939 + Handle: "test.bsky.social", 940 + AccessJWT: "access_token", 941 + RefreshJWT: "refresh_token", 942 + Authenticated: true, 943 + } 944 + ctx := context.Background() 945 + 946 + doc := public.Document{ 947 + Title: "Updated Document", 948 + } 949 + 950 + _, err := svc.PatchDocument(ctx, "test-rkey", doc, false) 951 + 952 + if err != nil && err.Error() == "not authenticated" { 953 + t.Error("Authentication check should pass, but got authentication error") 954 + } 955 + }) 956 + }) 957 + 958 + t.Run("DeleteDocument", func(t *testing.T) { 959 + t.Run("returns error when not authenticated", func(t *testing.T) { 960 + svc := NewATProtoService() 961 + ctx := context.Background() 962 + 963 + err := svc.DeleteDocument(ctx, "test-rkey", false) 964 + if err == nil { 965 + t.Error("Expected error when deleting document without authentication") 966 + } 967 + if err.Error() != "not authenticated" { 968 + t.Errorf("Expected 'not authenticated' error, got '%v'", err) 969 + } 970 + }) 971 + 972 + t.Run("returns error when session not authenticated", func(t *testing.T) { 973 + svc := NewATProtoService() 974 + ctx := context.Background() 975 + svc.session = &Session{ 976 + Handle: "test.bsky.social", 977 + Authenticated: false, 978 + } 979 + 980 + err := svc.DeleteDocument(ctx, "test-rkey", false) 981 + if err == nil { 982 + t.Error("Expected error when deleting document with unauthenticated session") 983 + } 984 + }) 985 + 986 + t.Run("returns error when rkey is empty", func(t *testing.T) { 987 + svc := NewATProtoService() 988 + ctx := context.Background() 989 + svc.session = &Session{ 990 + DID: "did:plc:test123", 991 + Handle: "test.bsky.social", 992 + AccessJWT: "access_token", 993 + RefreshJWT: "refresh_token", 994 + Authenticated: true, 995 + } 996 + 997 + err := svc.DeleteDocument(ctx, "", false) 998 + if err == nil { 999 + t.Error("Expected error when rkey is empty") 1000 + } 1001 + if err.Error() != "rkey is required" { 1002 + t.Errorf("Expected 'rkey is required' error, got '%v'", err) 1003 + } 1004 + }) 1005 + 1006 + t.Run("returns error when context cancelled", func(t *testing.T) { 1007 + svc := NewATProtoService() 1008 + svc.session = &Session{ 1009 + DID: "did:plc:test123", 1010 + Handle: "test.bsky.social", 1011 + AccessJWT: "access_token", 1012 + RefreshJWT: "refresh_token", 1013 + Authenticated: true, 1014 + } 1015 + 1016 + ctx, cancel := context.WithCancel(context.Background()) 1017 + cancel() 1018 + 1019 + err := svc.DeleteDocument(ctx, "test-rkey", false) 1020 + if err == nil { 1021 + t.Error("Expected error when context is cancelled") 1022 + } 1023 + }) 1024 + 1025 + t.Run("returns error when context timeout", func(t *testing.T) { 1026 + svc := NewATProtoService() 1027 + svc.session = &Session{ 1028 + DID: "did:plc:test123", 1029 + Handle: "test.bsky.social", 1030 + AccessJWT: "access_token", 1031 + RefreshJWT: "refresh_token", 1032 + Authenticated: true, 1033 + } 1034 + 1035 + ctx, cancel := context.WithTimeout(context.Background(), 1) 1036 + defer cancel() 1037 + time.Sleep(2 * time.Millisecond) 1038 + 1039 + err := svc.DeleteDocument(ctx, "test-rkey", false) 1040 + if err == nil { 1041 + t.Error("Expected error when context times out") 1042 + } 1043 + }) 1044 + 1045 + t.Run("validates draft parameter sets correct collection", func(t *testing.T) { 1046 + svc := NewATProtoService() 1047 + svc.session = &Session{ 1048 + DID: "did:plc:test123", 1049 + Handle: "test.bsky.social", 1050 + AccessJWT: "access_token", 1051 + RefreshJWT: "refresh_token", 1052 + Authenticated: true, 1053 + } 1054 + ctx := context.Background() 1055 + 1056 + err := svc.DeleteDocument(ctx, "test-rkey", true) 1057 + 1058 + if err != nil && err.Error() == "not authenticated" { 1059 + t.Error("Authentication check should pass, but got authentication error") 1060 + } 1061 + }) 1062 + 1063 + t.Run("validates published parameter sets correct collection", func(t *testing.T) { 1064 + svc := NewATProtoService() 1065 + svc.session = &Session{ 1066 + DID: "did:plc:test123", 1067 + Handle: "test.bsky.social", 1068 + AccessJWT: "access_token", 1069 + RefreshJWT: "refresh_token", 1070 + Authenticated: true, 1071 + } 1072 + ctx := context.Background() 1073 + 1074 + err := svc.DeleteDocument(ctx, "test-rkey", false) 1075 + 1076 + if err != nil && err.Error() == "not authenticated" { 1077 + t.Error("Authentication check should pass, but got authentication error") 591 1078 } 592 1079 }) 593 1080 })