search and/or read your saved and liked bluesky posts
wails
go
svelte
sqlite
desktop
bluesky
1package main
2
3import (
4 "context"
5 "database/sql"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "github.com/bluesky-social/indigo/atproto/auth/oauth"
11 "github.com/bluesky-social/indigo/atproto/syntax"
12 _ "modernc.org/sqlite"
13)
14
15func openTestDB(t *testing.T) {
16 t.Helper()
17
18 dbPath := filepath.Join(t.TempDir(), "test.db")
19 if err := Open(dbPath); err != nil {
20 t.Fatalf("Open() error = %v", err)
21 }
22
23 t.Cleanup(func() {
24 if err := Close(); err != nil {
25 t.Fatalf("Close() error = %v", err)
26 }
27 })
28}
29
30func TestSearchPostsBrowseMode(t *testing.T) {
31 openTestDB(t)
32
33 posts := []*Post{
34 {
35 URI: "at://did:plc:test/app.bsky.feed.post/1",
36 CID: "cid-1",
37 AuthorDID: "did:plc:test",
38 AuthorHandle: "alice.test",
39 Text: "older saved post",
40 CreatedAt: time.Date(2026, 3, 14, 12, 0, 0, 0, time.UTC),
41 Source: "saved",
42 },
43 {
44 URI: "at://did:plc:test/app.bsky.feed.post/2",
45 CID: "cid-2",
46 AuthorDID: "did:plc:test",
47 AuthorHandle: "alice.test",
48 Text: "newer liked post",
49 CreatedAt: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC),
50 Source: "liked",
51 },
52 }
53
54 for _, post := range posts {
55 if err := InsertPost(post); err != nil {
56 t.Fatalf("InsertPost() error = %v", err)
57 }
58 }
59
60 results, err := SearchPosts("", "")
61 if err != nil {
62 t.Fatalf("SearchPosts(empty) error = %v", err)
63 }
64 if len(results) != 2 {
65 t.Fatalf("SearchPosts(empty) len = %d, want 2", len(results))
66 }
67 if results[0].URI != posts[1].URI {
68 t.Fatalf("SearchPosts(empty) first URI = %q, want %q", results[0].URI, posts[1].URI)
69 }
70 if results[0].CreatedAt.IsZero() {
71 t.Fatal("SearchPosts(empty) CreatedAt is zero, want parsed timestamp")
72 }
73
74 starResults, err := SearchPosts("*", "saved")
75 if err != nil {
76 t.Fatalf("SearchPosts(*) error = %v", err)
77 }
78 if len(starResults) != 1 {
79 t.Fatalf("SearchPosts(*) len = %d, want 1", len(starResults))
80 }
81 if starResults[0].Source != "saved" {
82 t.Fatalf("SearchPosts(*) source = %q, want %q", starResults[0].Source, "saved")
83 }
84}
85
86func TestSQLiteOAuthStorePersistsSession(t *testing.T) {
87 openTestDB(t)
88
89 store := NewSQLiteOAuthStore()
90 did, err := syntax.ParseDID("did:plc:xg2vq45muivyy3xwatcehspu")
91 if err != nil {
92 t.Fatalf("ParseDID() error = %v", err)
93 }
94
95 session := oauth.ClientSessionData{
96 AccountDID: did,
97 SessionID: "session-123",
98 HostURL: "https://bsky.social",
99 AuthServerURL: "https://auth.example.com",
100 AuthServerTokenEndpoint: "https://auth.example.com/token",
101 AuthServerRevocationEndpoint: "https://auth.example.com/revoke",
102 Scopes: append([]string(nil), oauthScopes...),
103 AccessToken: "access-1",
104 RefreshToken: "refresh-1",
105 DPoPAuthServerNonce: "auth-nonce",
106 DPoPHostNonce: "host-nonce",
107 DPoPPrivateKeyMultibase: "private-key",
108 }
109
110 if err := store.SaveSession(context.Background(), session); err != nil {
111 t.Fatalf("SaveSession() error = %v", err)
112 }
113
114 auth, err := GetAuthByDID(did.String())
115 if err != nil {
116 t.Fatalf("GetAuthByDID() error = %v", err)
117 }
118 if auth == nil {
119 t.Fatal("GetAuthByDID() = nil, want auth")
120 }
121 if auth.RefreshJWT != session.RefreshToken {
122 t.Fatalf("RefreshJWT = %q, want %q", auth.RefreshJWT, session.RefreshToken)
123 }
124 if auth.DPoPHostNonce != session.DPoPHostNonce {
125 t.Fatalf("DPoPHostNonce = %q, want %q", auth.DPoPHostNonce, session.DPoPHostNonce)
126 }
127
128 got, err := store.GetSession(context.Background(), did, session.SessionID)
129 if err != nil {
130 t.Fatalf("GetSession() error = %v", err)
131 }
132 if got.AccessToken != session.AccessToken {
133 t.Fatalf("AccessToken = %q, want %q", got.AccessToken, session.AccessToken)
134 }
135
136 if err := store.DeleteSession(context.Background(), did, session.SessionID); err != nil {
137 t.Fatalf("DeleteSession() error = %v", err)
138 }
139
140 deleted, err := store.GetSession(context.Background(), did, session.SessionID)
141 if err == nil || deleted != nil {
142 t.Fatalf("GetSession() after delete = (%v, %v), want error", deleted, err)
143 }
144}
145
146func TestOpenMigratesLegacyPostsTableWithoutFacets(t *testing.T) {
147 dbPath := filepath.Join(t.TempDir(), "legacy.db")
148
149 legacyDB, err := sql.Open("sqlite", dbPath)
150 if err != nil {
151 t.Fatalf("sql.Open() error = %v", err)
152 }
153
154 legacySchema := `
155 CREATE TABLE posts (
156 uri TEXT PRIMARY KEY,
157 cid TEXT NOT NULL,
158 author_did TEXT NOT NULL,
159 author_handle TEXT NOT NULL,
160 text TEXT NOT NULL DEFAULT '',
161 created_at DATETIME NOT NULL,
162 like_count INTEGER DEFAULT 0,
163 repost_count INTEGER DEFAULT 0,
164 reply_count INTEGER DEFAULT 0,
165 source TEXT NOT NULL CHECK(source IN ('saved', 'liked')),
166 indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP
167 );
168 `
169
170 if _, err := legacyDB.Exec(legacySchema); err != nil {
171 t.Fatalf("creating legacy schema failed: %v", err)
172 }
173 if err := legacyDB.Close(); err != nil {
174 t.Fatalf("legacyDB.Close() error = %v", err)
175 }
176
177 if err := Open(dbPath); err != nil {
178 t.Fatalf("Open() error = %v", err)
179 }
180 t.Cleanup(func() {
181 if err := Close(); err != nil {
182 t.Fatalf("Close() error = %v", err)
183 }
184 })
185
186 hasColumn, err := columnExists("posts", "facets")
187 if err != nil {
188 t.Fatalf("columnExists() error = %v", err)
189 }
190 if !hasColumn {
191 t.Fatal("posts.facets missing after migration")
192 }
193
194 post := &Post{
195 URI: "at://did:plc:test/app.bsky.feed.post/legacy",
196 CID: "cid-legacy",
197 AuthorDID: "did:plc:test",
198 AuthorHandle: "legacy.test",
199 Text: "legacy post",
200 CreatedAt: time.Now().UTC(),
201 Source: "saved",
202 Facets: `[]`,
203 }
204
205 if err := InsertPost(post); err != nil {
206 t.Fatalf("InsertPost() after migration error = %v", err)
207 }
208}