learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1import "fake-indexeddb/auto";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3import { db, type LocalDeck } from "../db";
4
5vi.mock(
6 "../api",
7 () => ({
8 api: {
9 pushDeck: vi.fn().mockResolvedValue({
10 ok: true,
11 json: async () => ({ pds_cid: "cid123", pds_uri: "at://test" }),
12 }),
13 pushNote: vi.fn().mockResolvedValue({
14 ok: true,
15 json: async () => ({ pds_cid: "cid456", pds_uri: "at://test" }),
16 }),
17 getSyncStatus: vi.fn().mockResolvedValue({
18 ok: true,
19 json: async () => ({ pending_count: 0, conflict_count: 0 }),
20 }),
21 resolveConflict: vi.fn().mockResolvedValue({ ok: true }),
22 },
23 }),
24);
25
26vi.mock("../store", () => ({ authStore: { isAuthenticated: () => true, accessJwt: () => "test-token" } }));
27
28import { syncStore } from "../sync-store";
29
30describe("syncStore", () => {
31 beforeEach(async () => {
32 await db.decks.clear();
33 await db.cards.clear();
34 await db.notes.clear();
35 await db.syncQueue.clear();
36 });
37
38 afterEach(async () => {
39 await db.decks.clear();
40 await db.cards.clear();
41 await db.notes.clear();
42 await db.syncQueue.clear();
43 });
44
45 describe("state signals", () => {
46 it("should have initial idle sync state", () => {
47 expect(["idle", "offline"]).toContain(syncStore.syncState());
48 });
49
50 it("should have online status signal", () => {
51 expect(typeof syncStore.isOnline()).toBe("boolean");
52 });
53 });
54
55 describe("saveDeckLocally", () => {
56 it("should save a new deck with local_only status", async () => {
57 const deck = await syncStore.saveDeckLocally({
58 ownerDid: "did:plc:test",
59 title: "New Deck",
60 description: "Test description",
61 tags: ["test"],
62 visibility: { type: "Private" },
63 });
64
65 expect(deck.id).toMatch(/^local_/);
66 expect(deck.syncStatus).toBe("local_only");
67 expect(deck.localVersion).toBe(1);
68
69 const stored = await db.decks.get(deck.id);
70 expect(stored?.title).toBe("New Deck");
71 });
72
73 it("should update existing deck with pending_push status", async () => {
74 const deck = await syncStore.saveDeckLocally({
75 ownerDid: "did:plc:test",
76 title: "Original Title",
77 description: "Test",
78 tags: [],
79 visibility: { type: "Private" },
80 });
81
82 const updated = await syncStore.saveDeckLocally({
83 id: deck.id,
84 ownerDid: "did:plc:test",
85 title: "Updated Title",
86 description: "Test",
87 tags: [],
88 visibility: { type: "Private" },
89 });
90
91 expect(updated.id).toBe(deck.id);
92 expect(updated.title).toBe("Updated Title");
93 expect(updated.syncStatus).toBe("pending_push");
94 expect(updated.localVersion).toBe(2);
95 });
96 });
97
98 describe("saveNoteLocally", () => {
99 it("should save a new note with local_only status", async () => {
100 const note = await syncStore.saveNoteLocally({
101 ownerDid: "did:plc:test",
102 title: "New Note",
103 body: "Note content",
104 tags: ["test"],
105 visibility: { type: "Private" },
106 links: [],
107 });
108
109 expect(note.id).toMatch(/^local_/);
110 expect(note.syncStatus).toBe("local_only");
111 expect(note.localVersion).toBe(1);
112 });
113 });
114
115 describe("getLocalDecks", () => {
116 it("should return decks for a specific owner", async () => {
117 await db.decks.bulkPut([
118 {
119 id: "deck-1",
120 ownerDid: "did:alice",
121 title: "Alice Deck",
122 description: "",
123 tags: [],
124 visibility: { type: "Private" },
125 syncStatus: "synced",
126 localVersion: 1,
127 updatedAt: new Date().toISOString(),
128 } satisfies LocalDeck,
129 {
130 id: "deck-2",
131 ownerDid: "did:bob",
132 title: "Bob Deck",
133 description: "",
134 tags: [],
135 visibility: { type: "Private" },
136 syncStatus: "synced",
137 localVersion: 1,
138 updatedAt: new Date().toISOString(),
139 } satisfies LocalDeck,
140 ]);
141
142 const aliceDecks = await syncStore.getLocalDecks("did:alice");
143 expect(aliceDecks).toHaveLength(1);
144 expect(aliceDecks[0].title).toBe("Alice Deck");
145 });
146 });
147
148 describe("queueForSync", () => {
149 it("should add item to sync queue", async () => {
150 await syncStore.queueForSync("deck", "deck-123", "push");
151
152 const queue = await db.syncQueue.toArray();
153 expect(queue).toHaveLength(1);
154 expect(queue[0].entityType).toBe("deck");
155 expect(queue[0].entityId).toBe("deck-123");
156 expect(queue[0].operation).toBe("push");
157 });
158
159 it("should not duplicate queue entries", async () => {
160 await syncStore.queueForSync("deck", "deck-123", "push");
161 await syncStore.queueForSync("deck", "deck-123", "push");
162
163 const queue = await db.syncQueue.toArray();
164 expect(queue).toHaveLength(1);
165 });
166 });
167
168 describe("refreshCounts", () => {
169 it("should update pending and conflict counts", async () => {
170 await db.syncQueue.add({
171 entityType: "deck",
172 entityId: "deck-1",
173 operation: "push",
174 createdAt: new Date().toISOString(),
175 retryCount: 0,
176 });
177
178 await db.decks.put({
179 id: "deck-2",
180 ownerDid: "did:test",
181 title: "Conflict Deck",
182 description: "",
183 tags: [],
184 visibility: { type: "Private" },
185 syncStatus: "conflict",
186 localVersion: 1,
187 updatedAt: new Date().toISOString(),
188 });
189
190 await syncStore.refreshCounts();
191
192 expect(syncStore.pendingCount()).toBe(1);
193 expect(syncStore.conflictCount()).toBe(1);
194 });
195 });
196
197 describe("clearAll", () => {
198 it("should clear all local data", async () => {
199 await db.decks.put({
200 id: "deck-1",
201 ownerDid: "did:test",
202 title: "Test",
203 description: "",
204 tags: [],
205 visibility: { type: "Private" },
206 syncStatus: "synced",
207 localVersion: 1,
208 updatedAt: new Date().toISOString(),
209 });
210
211 await syncStore.clearAll();
212
213 expect(await db.decks.count()).toBe(0);
214 expect(await db.notes.count()).toBe(0);
215 expect(await db.syncQueue.count()).toBe(0);
216 });
217 });
218
219 describe("saveCardLocally", () => {
220 it("should save a new card with local_only status", async () => {
221 const card = await syncStore.saveCardLocally({
222 deckId: "deck-1",
223 front: "Question",
224 back: "Answer",
225 cardType: "basic",
226 hints: [],
227 });
228
229 expect(card.id).toMatch(/^local_/);
230 expect(card.syncStatus).toBe("local_only");
231 expect(card.localVersion).toBe(1);
232
233 const stored = await db.cards.get(card.id);
234 expect(stored?.front).toBe("Question");
235 });
236
237 it("should update existing card with pending_push status", async () => {
238 const card = await syncStore.saveCardLocally({
239 deckId: "deck-1",
240 front: "Original",
241 back: "Answer",
242 cardType: "basic",
243 hints: [],
244 });
245
246 const updated = await syncStore.saveCardLocally({
247 id: card.id,
248 deckId: "deck-1",
249 front: "Updated",
250 back: "Answer",
251 cardType: "basic",
252 hints: [],
253 });
254
255 expect(updated.id).toBe(card.id);
256 expect(updated.front).toBe("Updated");
257 expect(updated.syncStatus).toBe("pending_push");
258 expect(updated.localVersion).toBe(2);
259 });
260 });
261
262 describe("getLocalCards", () => {
263 it("should return cards for a specific deck", async () => {
264 await syncStore.saveCardLocally({ deckId: "deck-1", front: "Q1", back: "A1", cardType: "basic", hints: [] });
265 await syncStore.saveCardLocally({ deckId: "deck-1", front: "Q2", back: "A2", cardType: "basic", hints: [] });
266 await syncStore.saveCardLocally({ deckId: "deck-2", front: "Q3", back: "A3", cardType: "basic", hints: [] });
267
268 const cards = await syncStore.getLocalCards("deck-1");
269 expect(cards).toHaveLength(2);
270 expect(cards.map((c) => c.front)).toContain("Q1");
271 expect(cards.map((c) => c.front)).toContain("Q2");
272 });
273 });
274
275 describe("deleteLocalCard", () => {
276 it("should delete a card by id", async () => {
277 const card = await syncStore.saveCardLocally({
278 deckId: "deck-1",
279 front: "To Delete",
280 back: "Answer",
281 cardType: "basic",
282 hints: [],
283 });
284
285 await syncStore.deleteLocalCard(card.id);
286
287 const stored = await db.cards.get(card.id);
288 expect(stored).toBeUndefined();
289 });
290 });
291
292 describe("getAllLocalData", () => {
293 it("should return all local decks, notes, cards, and queue items", async () => {
294 await syncStore.saveDeckLocally({
295 ownerDid: "did:test",
296 title: "Deck",
297 description: "",
298 tags: [],
299 visibility: { type: "Private" },
300 });
301 await syncStore.saveNoteLocally({
302 ownerDid: "did:test",
303 title: "Note",
304 body: "Body",
305 tags: [],
306 visibility: { type: "Private" },
307 links: [],
308 });
309 await syncStore.saveCardLocally({ deckId: "deck-1", front: "Q", back: "A", cardType: "basic", hints: [] });
310
311 const data = await syncStore.getAllLocalData();
312
313 expect(data.decks).toHaveLength(1);
314 expect(data.notes).toHaveLength(1);
315 expect(data.cards).toHaveLength(1);
316 expect(data.queue.length).toBeGreaterThanOrEqual(0);
317 });
318 });
319});