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