Implements the activity-sport-type change (specs/activity-sport-type): - db: nullable `sport_type` column on journal.activities + SportType / SPORT_TYPES (text().$type<> convention). - api: optional sportType on the activity read + create schemas (mirrored SPORT_TYPES; @trails-cool/api stays zod-only). - write: ActivityInput + createActivity persist it; mapSportType() normalizes provider strings (Komoot bulk import passes tour.sport; Garmin unset); threaded through the unified importActivity. - read/display: sportType added to the detail/feed/profile loaders and the v1 REST endpoints; shared SportBadge (glyph + i18n label) on detail, feed, and profile; sport-aware feed verb; create-form <select>. - i18n: journal.activities.sport.* (labels + verbs) in en + de. - federation: `sport` PropertyValue on the Note when set. Tests: mapSportType unit table; federation asserts the sport attachment is present when set and omitted when unset. typecheck + lint + unit all green. E2E (create→badge) still to add. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
3.6 KiB
TypeScript
95 lines
3.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
activityToNote,
|
|
activityToCreate,
|
|
activityToDelete,
|
|
activityObjectIri,
|
|
type FederatableActivity,
|
|
} from "./federation-objects.server.ts";
|
|
|
|
const ACTIVITY: FederatableActivity = {
|
|
id: "act-1",
|
|
name: "Morning ride <3",
|
|
description: 'Through the "forest" & hills',
|
|
sportType: "ride",
|
|
distance: 42_195,
|
|
elevationGain: 512.4,
|
|
duration: 2 * 3600 + 30 * 60,
|
|
startedAt: new Date("2026-06-01T08:00:00Z"),
|
|
createdAt: new Date("2026-06-01T12:00:00Z"),
|
|
};
|
|
|
|
describe("activityToNote", () => {
|
|
it("builds a public Note addressed at the activity page", async () => {
|
|
const note = activityToNote(ACTIVITY, "bruno");
|
|
expect(note.id?.href).toBe("http://localhost:3000/activities/act-1");
|
|
expect(note.attributionId?.href).toBe("http://localhost:3000/users/bruno");
|
|
expect(note.toIds.map((u) => u.href)).toContain(
|
|
"https://www.w3.org/ns/activitystreams#Public",
|
|
);
|
|
expect(note.published?.toString()).toBe("2026-06-01T08:00:00Z");
|
|
});
|
|
|
|
it("escapes HTML in user content and includes stats + link", () => {
|
|
const note = activityToNote(ACTIVITY, "bruno");
|
|
const content = String(note.content);
|
|
expect(content).toContain("Morning ride <3");
|
|
expect(content).toContain(""forest" & hills");
|
|
expect(content).toContain("42.2 km");
|
|
expect(content).toContain("↗ 512 m");
|
|
expect(content).toContain("2h 30m");
|
|
expect(content).toContain('href="http://localhost:3000/activities/act-1"');
|
|
});
|
|
|
|
it("carries structured metadata as PropertyValue attachments", async () => {
|
|
const note = activityToNote(ACTIVITY, "bruno");
|
|
const attachments = [];
|
|
for await (const a of note.getAttachments()) attachments.push(a);
|
|
const byName = new Map(
|
|
attachments.map((a) => [String((a as { name: unknown }).name), String((a as { value: unknown }).value)]),
|
|
);
|
|
expect(byName.get("distance-m")).toBe("42195");
|
|
expect(byName.get("elevation-gain-m")).toBe("512");
|
|
expect(byName.get("duration-s")).toBe("9000");
|
|
expect(byName.get("sport")).toBe("ride");
|
|
});
|
|
|
|
it("omits the sport attachment when sport is unset", async () => {
|
|
const note = activityToNote({ ...ACTIVITY, sportType: null }, "bruno");
|
|
const names = [];
|
|
for await (const a of note.getAttachments()) names.push(String((a as { name: unknown }).name));
|
|
expect(names).not.toContain("sport");
|
|
});
|
|
|
|
it("omits stats it doesn't have", () => {
|
|
const bare = activityToNote(
|
|
{ ...ACTIVITY, distance: null, elevationGain: null, duration: null, description: null },
|
|
"bruno",
|
|
);
|
|
const content = String(bare.content);
|
|
expect(content).not.toContain("km");
|
|
expect(content).not.toContain("↗");
|
|
});
|
|
|
|
it("falls back to createdAt when startedAt is missing", () => {
|
|
const note = activityToNote({ ...ACTIVITY, startedAt: null }, "bruno");
|
|
expect(note.published?.toString()).toBe("2026-06-01T12:00:00Z");
|
|
});
|
|
});
|
|
|
|
describe("activityToCreate", () => {
|
|
it("derives a stable id from the object and attributes the actor", () => {
|
|
const create = activityToCreate(ACTIVITY, "bruno");
|
|
expect(create.id?.href).toBe("http://localhost:3000/activities/act-1#create");
|
|
expect(create.actorId?.href).toBe("http://localhost:3000/users/bruno");
|
|
});
|
|
});
|
|
|
|
describe("activityToDelete", () => {
|
|
it("wraps a Tombstone for the deleted object", async () => {
|
|
const del = activityToDelete(activityObjectIri("act-1"), "bruno");
|
|
expect(del.actorId?.href).toBe("http://localhost:3000/users/bruno");
|
|
const tombstone = await del.getObject();
|
|
expect(tombstone?.id?.href).toBe("http://localhost:3000/activities/act-1");
|
|
});
|
|
});
|