an app to share curated trails
sidetrail.app
1/**
2 * User Profile Tests
3 *
4 * Scenarios covering user profiles: published trails, completed trails,
5 * and how activity affects what's shown.
6 */
7
8import { describe, it, expect } from "vitest";
9import * as queries from "../queries";
10import { emit, emitDelete, ALICE, BOB, CAROL, generateTid } from "./helpers";
11
12const now = () => new Date().toISOString();
13
14describe("Viewing a user's published trails", () => {
15 it("shows all trails created by the user", async () => {
16 await emit("app.sidetrail.trail", ALICE.did, {
17 $type: "app.sidetrail.trail",
18 title: "Alice's First Trail",
19 description: "Her debut",
20 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
21 accentColor: "#ff0000",
22 backgroundColor: "#ffffff",
23 createdAt: now(),
24 });
25
26 await emit("app.sidetrail.trail", ALICE.did, {
27 $type: "app.sidetrail.trail",
28 title: "Alice's Second Trail",
29 description: "Another one",
30 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
31 accentColor: "#00ff00",
32 backgroundColor: "#f0f0f0",
33 createdAt: now(),
34 });
35
36 const publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle);
37 expect(publishedTrails).toHaveLength(2);
38 });
39
40 it("orders published trails by createdAt descending", async () => {
41 await emit("app.sidetrail.trail", ALICE.did, {
42 $type: "app.sidetrail.trail",
43 title: "Old Trail",
44 description: "First",
45 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
46 accentColor: "#111",
47 backgroundColor: "#eee",
48 createdAt: "2024-01-01T10:00:00Z",
49 });
50
51 await emit("app.sidetrail.trail", ALICE.did, {
52 $type: "app.sidetrail.trail",
53 title: "New Trail",
54 description: "Last",
55 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
56 accentColor: "#222",
57 backgroundColor: "#ddd",
58 createdAt: "2024-06-01T10:00:00Z",
59 });
60
61 const publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle);
62 expect(publishedTrails[0].title).toBe("New Trail");
63 expect(publishedTrails[1].title).toBe("Old Trail");
64 });
65
66 it("published trail cards show active walkers", async () => {
67 const stopTid = generateTid();
68 const trail = await emit("app.sidetrail.trail", ALICE.did, {
69 $type: "app.sidetrail.trail",
70 title: "Popular Trail",
71 description: "Many walkers",
72 stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
73 accentColor: "#333",
74 backgroundColor: "#ccc",
75 createdAt: now(),
76 });
77
78 await emit("app.sidetrail.walk", BOB.did, {
79 $type: "app.sidetrail.walk",
80 trail: { uri: trail.uri, cid: trail.cid },
81 visitedStops: [stopTid],
82 createdAt: now(),
83 updatedAt: now(),
84 });
85
86 const publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle);
87 const activeWalkers = await queries.loadTrailActiveWalkers(publishedTrails[0].uri);
88 expect(activeWalkers).toHaveLength(1);
89 expect(activeWalkers[0].handle).toBe(BOB.handle);
90 });
91
92 it("does not show other users' trails", async () => {
93 await emit("app.sidetrail.trail", ALICE.did, {
94 $type: "app.sidetrail.trail",
95 title: "Alice's Trail",
96 description: "By Alice",
97 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
98 accentColor: "#aaa",
99 backgroundColor: "#bbb",
100 createdAt: now(),
101 });
102
103 await emit("app.sidetrail.trail", BOB.did, {
104 $type: "app.sidetrail.trail",
105 title: "Bob's Trail",
106 description: "By Bob",
107 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
108 accentColor: "#ccc",
109 backgroundColor: "#ddd",
110 createdAt: now(),
111 });
112
113 const aliceTrails = await queries.loadUserPublishedTrails(ALICE.handle);
114 expect(aliceTrails).toHaveLength(1);
115 expect(aliceTrails[0].title).toBe("Alice's Trail");
116
117 const bobTrails = await queries.loadUserPublishedTrails(BOB.handle);
118 expect(bobTrails).toHaveLength(1);
119 expect(bobTrails[0].title).toBe("Bob's Trail");
120 });
121});
122
123describe("Viewing a user's completed trails", () => {
124 it("shows trails the user has completed", async () => {
125 const trail = await emit("app.sidetrail.trail", ALICE.did, {
126 $type: "app.sidetrail.trail",
127 title: "Completable Trail",
128 description: "Finish this",
129 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
130 accentColor: "#ff0000",
131 backgroundColor: "#ffffff",
132 createdAt: now(),
133 });
134
135 await emit("app.sidetrail.completion", BOB.did, {
136 $type: "app.sidetrail.completion",
137 trail: { uri: trail.uri, cid: trail.cid },
138 createdAt: now(),
139 });
140
141 const completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
142 expect(completedTrails).toHaveLength(1);
143 expect(completedTrails[0].title).toBe("Completable Trail");
144 expect(completedTrails[0].creator.handle).toBe(ALICE.handle);
145 });
146
147 it("orders completed trails by completion date descending", async () => {
148 const trail1 = await emit("app.sidetrail.trail", ALICE.did, {
149 $type: "app.sidetrail.trail",
150 title: "First Completed",
151 description: "Test",
152 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
153 accentColor: "#111",
154 backgroundColor: "#eee",
155 createdAt: now(),
156 });
157
158 const trail2 = await emit("app.sidetrail.trail", ALICE.did, {
159 $type: "app.sidetrail.trail",
160 title: "Last Completed",
161 description: "Test",
162 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
163 accentColor: "#222",
164 backgroundColor: "#ddd",
165 createdAt: now(),
166 });
167
168 await emit("app.sidetrail.completion", BOB.did, {
169 $type: "app.sidetrail.completion",
170 trail: { uri: trail1.uri, cid: trail1.cid },
171 createdAt: "2024-01-01T10:00:00Z",
172 });
173
174 await emit("app.sidetrail.completion", BOB.did, {
175 $type: "app.sidetrail.completion",
176 trail: { uri: trail2.uri, cid: trail2.cid },
177 createdAt: "2024-06-01T10:00:00Z",
178 });
179
180 const completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
181 expect(completedTrails[0].title).toBe("Last Completed");
182 expect(completedTrails[1].title).toBe("First Completed");
183 });
184
185 it("dedupes multiple completions of same trail (shows most recent)", async () => {
186 const trail = await emit("app.sidetrail.trail", ALICE.did, {
187 $type: "app.sidetrail.trail",
188 title: "Re-completable",
189 description: "Completed twice",
190 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
191 accentColor: "#333",
192 backgroundColor: "#ccc",
193 createdAt: now(),
194 });
195
196 // First completion
197 await emit("app.sidetrail.completion", BOB.did, {
198 $type: "app.sidetrail.completion",
199 trail: { uri: trail.uri, cid: trail.cid },
200 createdAt: "2024-01-01T10:00:00Z",
201 });
202
203 // Second completion (newer)
204 await emit("app.sidetrail.completion", BOB.did, {
205 $type: "app.sidetrail.completion",
206 trail: { uri: trail.uri, cid: trail.cid },
207 createdAt: "2024-06-01T10:00:00Z",
208 });
209
210 const completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
211 expect(completedTrails).toHaveLength(1);
212 expect(completedTrails[0].completedAt.toISOString()).toBe("2024-06-01T10:00:00.000Z");
213 });
214
215 it("excludes completions where trail was deleted", async () => {
216 const trail = await emit("app.sidetrail.trail", ALICE.did, {
217 $type: "app.sidetrail.trail",
218 title: "Soon Deleted",
219 description: "Will be orphaned",
220 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
221 accentColor: "#444",
222 backgroundColor: "#bbb",
223 createdAt: now(),
224 });
225
226 await emit("app.sidetrail.completion", BOB.did, {
227 $type: "app.sidetrail.completion",
228 trail: { uri: trail.uri, cid: trail.cid },
229 createdAt: now(),
230 });
231
232 let completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
233 expect(completedTrails).toHaveLength(1);
234
235 // Trail gets deleted
236 await emitDelete("app.sidetrail.trail", trail.uri);
237
238 completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
239 expect(completedTrails).toHaveLength(0);
240 });
241});
242
243describe("Profile showing both published and completed", () => {
244 it("user can have both published and completed trails", async () => {
245 // Alice publishes a trail
246 await emit("app.sidetrail.trail", ALICE.did, {
247 $type: "app.sidetrail.trail",
248 title: "Alice's Published Trail",
249 description: "Created by Alice",
250 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
251 accentColor: "#ff0000",
252 backgroundColor: "#ffffff",
253 createdAt: now(),
254 });
255
256 // Bob publishes a trail
257 const bobTrail = await emit("app.sidetrail.trail", BOB.did, {
258 $type: "app.sidetrail.trail",
259 title: "Bob's Trail",
260 description: "Created by Bob",
261 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
262 accentColor: "#00ff00",
263 backgroundColor: "#f0f0f0",
264 createdAt: now(),
265 });
266
267 // Alice completes Bob's trail
268 await emit("app.sidetrail.completion", ALICE.did, {
269 $type: "app.sidetrail.completion",
270 trail: { uri: bobTrail.uri, cid: bobTrail.cid },
271 createdAt: now(),
272 });
273
274 const publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle);
275 expect(publishedTrails).toHaveLength(1);
276 expect(publishedTrails[0].title).toBe("Alice's Published Trail");
277
278 const completedTrails = await queries.loadUserCompletedTrails(ALICE.handle);
279 expect(completedTrails).toHaveLength(1);
280 expect(completedTrails[0].title).toBe("Bob's Trail");
281 });
282});
283
284describe("Deleting a completion", () => {
285 it("removes completion from user's profile", async () => {
286 const trail = await emit("app.sidetrail.trail", ALICE.did, {
287 $type: "app.sidetrail.trail",
288 title: "Uncomplete Trail",
289 description: "Remove completion",
290 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
291 accentColor: "#555",
292 backgroundColor: "#aaa",
293 createdAt: now(),
294 });
295
296 const completion = await emit("app.sidetrail.completion", BOB.did, {
297 $type: "app.sidetrail.completion",
298 trail: { uri: trail.uri, cid: trail.cid },
299 createdAt: now(),
300 });
301
302 let completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
303 expect(completedTrails).toHaveLength(1);
304
305 await emitDelete("app.sidetrail.completion", completion.uri);
306
307 completedTrails = await queries.loadUserCompletedTrails(BOB.handle);
308 expect(completedTrails).toHaveLength(0);
309 });
310});
311
312describe("Empty profile states", () => {
313 it("user with no activity has empty lists", async () => {
314 const publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle);
315 const completedTrails = await queries.loadUserCompletedTrails(ALICE.handle);
316 expect(publishedTrails).toHaveLength(0);
317 expect(completedTrails).toHaveLength(0);
318 });
319});
320
321describe("Profile trail deletion cascade", () => {
322 it("deleting a trail removes it from author's published trails", async () => {
323 const trail = await emit("app.sidetrail.trail", ALICE.did, {
324 $type: "app.sidetrail.trail",
325 title: "Ephemeral Trail",
326 description: "Will be deleted",
327 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
328 accentColor: "#666",
329 backgroundColor: "#999",
330 createdAt: now(),
331 });
332
333 let publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle);
334 expect(publishedTrails).toHaveLength(1);
335
336 await emitDelete("app.sidetrail.trail", trail.uri);
337
338 publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle);
339 expect(publishedTrails).toHaveLength(0);
340 });
341});
342
343describe("Profile with multiple completers", () => {
344 it("each user's profile shows their own completions", async () => {
345 const trail = await emit("app.sidetrail.trail", ALICE.did, {
346 $type: "app.sidetrail.trail",
347 title: "Popular Trail",
348 description: "Many completers",
349 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
350 accentColor: "#777",
351 backgroundColor: "#888",
352 createdAt: now(),
353 });
354
355 await emit("app.sidetrail.completion", BOB.did, {
356 $type: "app.sidetrail.completion",
357 trail: { uri: trail.uri, cid: trail.cid },
358 createdAt: now(),
359 });
360
361 await emit("app.sidetrail.completion", CAROL.did, {
362 $type: "app.sidetrail.completion",
363 trail: { uri: trail.uri, cid: trail.cid },
364 createdAt: now(),
365 });
366
367 const bobCompleted = await queries.loadUserCompletedTrails(BOB.handle);
368 expect(bobCompleted).toHaveLength(1);
369
370 const carolCompleted = await queries.loadUserCompletedTrails(CAROL.handle);
371 expect(carolCompleted).toHaveLength(1);
372
373 // Alice (author) has no completions
374 const aliceCompleted = await queries.loadUserCompletedTrails(ALICE.handle);
375 expect(aliceCompleted).toHaveLength(0);
376
377 const alicePublished = await queries.loadUserPublishedTrails(ALICE.handle);
378 expect(alicePublished).toHaveLength(1);
379 });
380});