Add Komoot import with public (bio verification) and authenticated modes
Two-mode import: public mode verifies Komoot account ownership by checking that the user's trails.cool profile URL appears in their Komoot bio — no credentials stored. Authenticated mode uses email + password (AES-256-GCM encrypted) to import private tours as well. Includes unit tests for crypto/komoot client and E2E tests for the full connect + import flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b63fd1a303
commit
03304c354b
22 changed files with 1612 additions and 4 deletions
184
apps/journal/app/lib/komoot.server.test.ts
Normal file
184
apps/journal/app/lib/komoot.server.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
parseKomootUserId,
|
||||
verifyKomootOwnership,
|
||||
fetchPublicProfile,
|
||||
fetchKomootTours,
|
||||
} from "./komoot.server.ts";
|
||||
|
||||
describe("parseKomootUserId", () => {
|
||||
it("accepts a plain numeric ID", () => {
|
||||
expect(parseKomootUserId("27595800585")).toBe("27595800585");
|
||||
});
|
||||
|
||||
it("accepts a full komoot.com URL", () => {
|
||||
expect(parseKomootUserId("https://www.komoot.com/user/27595800585")).toBe("27595800585");
|
||||
});
|
||||
|
||||
it("accepts a locale-prefixed URL (de-de)", () => {
|
||||
expect(parseKomootUserId("https://www.komoot.com/de-de/user/27595800585")).toBe("27595800585");
|
||||
});
|
||||
|
||||
it("accepts komoot.de URL", () => {
|
||||
expect(parseKomootUserId("https://www.komoot.de/user/12345")).toBe("12345");
|
||||
});
|
||||
|
||||
it("returns null for an invalid input", () => {
|
||||
expect(parseKomootUserId("not-a-user")).toBeNull();
|
||||
expect(parseKomootUserId("https://example.com/user/abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchPublicProfile", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
it("maps response fields correctly", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
username: "27595800585",
|
||||
display_name: "Test User",
|
||||
content: { text: "Visit https://trails.cool/users/ullrich for my routes", link: null },
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const profile = await fetchPublicProfile("27595800585");
|
||||
expect(profile.displayName).toBe("Test User");
|
||||
expect(profile.contentText).toBe("Visit https://trails.cool/users/ullrich for my routes");
|
||||
expect(profile.contentLink).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to username when display_name missing", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ username: "99999", content: {} }),
|
||||
} as Response);
|
||||
|
||||
const profile = await fetchPublicProfile("99999");
|
||||
expect(profile.displayName).toBe("99999");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404 } as Response);
|
||||
await expect(fetchPublicProfile("bad")).rejects.toThrow("404");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyKomootOwnership", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
it("returns true when bio contains the trails URL (exact match)", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
username: "123",
|
||||
content: { text: "https://trails.cool/users/ullrich" },
|
||||
}),
|
||||
} as Response);
|
||||
expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for case-insensitive match", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
username: "123",
|
||||
content: { text: "HTTPS://TRAILS.COOL/USERS/ULLRICH" },
|
||||
}),
|
||||
} as Response);
|
||||
expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when bio does not contain the trails URL", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
username: "123",
|
||||
content: { text: "Just a normal bio with no trails link" },
|
||||
}),
|
||||
} as Response);
|
||||
expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when content_text is null", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ username: "123", content: { text: null } }),
|
||||
} as Response);
|
||||
expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when fetch fails", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404 } as Response);
|
||||
expect(await verifyKomootOwnership("bad", "https://trails.cool/users/x")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchKomootTours", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
it("appends status=public when no auth token", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
_embedded: { tours: [] },
|
||||
page: { totalPages: 1, number: 0 },
|
||||
}),
|
||||
} as Response);
|
||||
await fetchKomootTours("123", 1);
|
||||
const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("status=public");
|
||||
});
|
||||
|
||||
it("omits status=public when auth token provided", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
_embedded: { tours: [] },
|
||||
page: { totalPages: 1, number: 0 },
|
||||
}),
|
||||
} as Response);
|
||||
await fetchKomootTours("123", 1, "sometoken");
|
||||
const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit];
|
||||
expect(url).not.toContain("status=public");
|
||||
});
|
||||
|
||||
it("maps tour fields correctly", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
_embedded: {
|
||||
tours: [
|
||||
{
|
||||
id: 987654321,
|
||||
name: "Morning ride",
|
||||
type: "touringbicycle",
|
||||
date: "2024-06-01T08:00:00Z",
|
||||
distance: 42000,
|
||||
duration: 7200,
|
||||
elevation_up: 300,
|
||||
elevation_down: 290,
|
||||
},
|
||||
],
|
||||
},
|
||||
page: { totalPages: 3, number: 0 },
|
||||
}),
|
||||
} as Response);
|
||||
const result = await fetchKomootTours("123", 1);
|
||||
expect(result.tours).toHaveLength(1);
|
||||
expect(result.tours[0]).toMatchObject({
|
||||
id: "987654321",
|
||||
name: "Morning ride",
|
||||
sport: "touringbicycle",
|
||||
distance: 42000,
|
||||
duration: 7200,
|
||||
});
|
||||
expect(result.totalPages).toBe(3);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue