diff --git a/apps/freya-backend/src/auth/index.test.ts b/apps/freya-backend/src/auth/index.test.ts new file mode 100644 index 0000000..ede8747 --- /dev/null +++ b/apps/freya-backend/src/auth/index.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, test } from "bun:test" + +import type { Database } from "../db/index.ts" + +import { DEFAULT_ENABLED_SOURCE_IDS } from "../sources/default-sources.ts" +import { createAuth } from "./index.ts" + +interface UserSourceInsertRow { + sourceId: string +} + +interface RecordingDb { + db: Database + rows: () => UserSourceInsertRow[] | undefined +} + +const originalBetterAuthSecret = process.env.BETTER_AUTH_SECRET + +function createRecordingDb(): RecordingDb { + let insertedRows: UserSourceInsertRow[] | undefined + + const db = { + insert() { + return { + values(rows: UserSourceInsertRow[]) { + insertedRows = rows + + return { + async onConflictDoNothing() {}, + } + }, + } + }, + } as unknown as Database + + return { + db, + rows: () => insertedRows, + } +} + +afterEach(() => { + if (originalBetterAuthSecret === undefined) { + delete process.env.BETTER_AUTH_SECRET + return + } + + process.env.BETTER_AUTH_SECRET = originalBetterAuthSecret +}) + +describe("createAuth", () => { + test("inserts default sources after Better Auth creates a user", async () => { + process.env.BETTER_AUTH_SECRET = "test-secret" + const recording = createRecordingDb() + const auth = createAuth(recording.db) + const afterCreateUser = auth.options.databaseHooks?.user?.create?.after + + if (!afterCreateUser) { + throw new Error("Expected a user create after hook") + } + + const now = new Date() + await afterCreateUser( + { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: false, + image: null, + createdAt: now, + updatedAt: now, + }, + null, + ) + + const rows = recording.rows() + if (!rows) { + throw new Error("Expected the auth hook to insert default sources") + } + + expect(rows.map((row) => row.sourceId)).toEqual([...DEFAULT_ENABLED_SOURCE_IDS]) + }) +}) diff --git a/apps/freya-backend/src/auth/index.ts b/apps/freya-backend/src/auth/index.ts index 0a9b567..a48a5ec 100644 --- a/apps/freya-backend/src/auth/index.ts +++ b/apps/freya-backend/src/auth/index.ts @@ -5,6 +5,7 @@ import { admin } from "better-auth/plugins" import type { Database } from "../db/index.ts" import * as schema from "../db/schema.ts" +import { insertDefaultUserSources } from "../sources/default-sources.ts" export function createAuth(db: Database) { if (!process.env.BETTER_AUTH_SECRET) { @@ -22,6 +23,15 @@ export function createAuth(db: Database) { emailAndPassword: { enabled: true, }, + databaseHooks: { + user: { + create: { + async after(user, _context) { + await insertDefaultUserSources(db, user.id) + }, + }, + }, + }, plugins: [admin()], }) } diff --git a/apps/freya-backend/src/location/provider.ts b/apps/freya-backend/src/location/provider.ts index 5a8fe6d..4dac779 100644 --- a/apps/freya-backend/src/location/provider.ts +++ b/apps/freya-backend/src/location/provider.ts @@ -3,7 +3,7 @@ import { LocationSource } from "@freya/source-location" import type { FeedSourceProvider } from "../session/feed-source-provider.ts" export class LocationSourceProvider implements FeedSourceProvider { - readonly sourceId = "freya.location" + readonly sourceId = LocationSource.id async feedSourceForUser( _userId: string, diff --git a/apps/freya-backend/src/sources/default-sources.test.ts b/apps/freya-backend/src/sources/default-sources.test.ts new file mode 100644 index 0000000..b9aadfc --- /dev/null +++ b/apps/freya-backend/src/sources/default-sources.test.ts @@ -0,0 +1,85 @@ +import { LocationSource } from "@freya/source-location" +import { WebSearchSource } from "@freya/source-web-search" +import { describe, expect, test } from "bun:test" + +import type { Database } from "../db/index.ts" + +import { userSources } from "../db/schema.ts" +import { DEFAULT_ENABLED_SOURCE_IDS, insertDefaultUserSources } from "./default-sources.ts" + +interface UserSourceInsertRow { + userId: string + sourceId: string + enabled: boolean + config: unknown + createdAt: Date + updatedAt: Date +} + +interface RecordingDb { + db: Database + table: () => unknown + rows: () => UserSourceInsertRow[] | undefined + conflictTarget: () => readonly unknown[] | undefined +} + +function createRecordingDb(): RecordingDb { + let insertedTable: unknown + let insertedRows: UserSourceInsertRow[] | undefined + let target: readonly unknown[] | undefined + + const db = { + insert(table: unknown) { + insertedTable = table + + return { + values(rows: UserSourceInsertRow[]) { + insertedRows = rows + + return { + async onConflictDoNothing(options: { target: readonly unknown[] }) { + target = options.target + }, + } + }, + } + }, + } as unknown as Database + + return { + db, + table: () => insertedTable, + rows: () => insertedRows, + conflictTarget: () => target, + } +} + +describe("default user sources", () => { + test("defines location and web search as default enabled sources", () => { + expect(DEFAULT_ENABLED_SOURCE_IDS).toEqual([LocationSource.id, WebSearchSource.id]) + }) + + test("inserts default enabled source rows for a user", async () => { + const recording = createRecordingDb() + + await insertDefaultUserSources(recording.db, "user-1") + + const rows = recording.rows() + if (!rows) { + throw new Error("Expected default source rows to be inserted") + } + + expect(recording.table()).toBe(userSources) + expect(rows).toHaveLength(2) + expect(rows.map((row) => row.sourceId)).toEqual([...DEFAULT_ENABLED_SOURCE_IDS]) + expect(recording.conflictTarget()).toEqual([userSources.userId, userSources.sourceId]) + + for (const row of rows) { + expect(row.userId).toBe("user-1") + expect(row.enabled).toBe(true) + expect(row.config).toEqual({}) + expect(row.createdAt).toBeInstanceOf(Date) + expect(row.updatedAt).toBe(row.createdAt) + } + }) +}) diff --git a/apps/freya-backend/src/sources/default-sources.ts b/apps/freya-backend/src/sources/default-sources.ts new file mode 100644 index 0000000..1a3a9fc --- /dev/null +++ b/apps/freya-backend/src/sources/default-sources.ts @@ -0,0 +1,30 @@ +import { LocationSource } from "@freya/source-location" +import { WebSearchSource } from "@freya/source-web-search" + +import type { Database } from "../db/index.ts" + +import { userSources } from "../db/schema.ts" + +export const DEFAULT_ENABLED_SOURCE_IDS = [LocationSource.id, WebSearchSource.id] as const + +export type DefaultEnabledSourceId = (typeof DEFAULT_ENABLED_SOURCE_IDS)[number] + +export async function insertDefaultUserSources(db: Database, userId: string): Promise { + const now = new Date() + + await db + .insert(userSources) + .values( + DEFAULT_ENABLED_SOURCE_IDS.map((sourceId) => ({ + userId, + sourceId, + enabled: true, + config: {}, + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing({ + target: [userSources.userId, userSources.sourceId], + }) +} diff --git a/apps/freya-backend/src/web-search/provider.ts b/apps/freya-backend/src/web-search/provider.ts index e474603..8023029 100644 --- a/apps/freya-backend/src/web-search/provider.ts +++ b/apps/freya-backend/src/web-search/provider.ts @@ -7,7 +7,7 @@ export type WebSearchSourceProviderOptions = | { apiKey?: never; client: WebSearchClient } export class WebSearchSourceProvider implements FeedSourceProvider { - readonly sourceId = "freya.web-search" + readonly sourceId = WebSearchSource.id private readonly apiKey: string | undefined private readonly client: WebSearchClient | undefined diff --git a/packages/freya-source-location/src/location-source.test.ts b/packages/freya-source-location/src/location-source.test.ts index a3fe138..f2f6af3 100644 --- a/packages/freya-source-location/src/location-source.test.ts +++ b/packages/freya-source-location/src/location-source.test.ts @@ -18,7 +18,7 @@ describe("LocationSource", () => { describe("FeedSource interface", () => { test("has correct id", () => { const source = new LocationSource() - expect(source.id).toBe("freya.location") + expect(source.id).toBe(LocationSource.id) }) test("fetchItems always returns empty array", async () => { diff --git a/packages/freya-source-location/src/location-source.ts b/packages/freya-source-location/src/location-source.ts index a767553..8405182 100644 --- a/packages/freya-source-location/src/location-source.ts +++ b/packages/freya-source-location/src/location-source.ts @@ -5,8 +5,6 @@ import { type } from "arktype" import { Location, type LocationSourceOptions } from "./types.ts" -export const LocationKey: ContextKey = contextKey("freya.location", "location") - /** * A FeedSource that provides location context. * @@ -16,7 +14,9 @@ export const LocationKey: ContextKey = contextKey("freya.location", "l * Does not produce feed items - always returns empty array from `fetchItems`. */ export class LocationSource implements FeedSource { - readonly id = "freya.location" + static readonly id = "freya.location" + + readonly id = LocationSource.id private readonly historySize: number private locations: Location[] = [] @@ -97,3 +97,5 @@ export class LocationSource implements FeedSource { return [] } } + +export const LocationKey: ContextKey = contextKey(LocationSource.id, "location") diff --git a/packages/freya-source-web-search/src/web-search-source.test.ts b/packages/freya-source-web-search/src/web-search-source.test.ts index f760ca6..ce4887c 100644 --- a/packages/freya-source-web-search/src/web-search-source.test.ts +++ b/packages/freya-source-web-search/src/web-search-source.test.ts @@ -37,7 +37,7 @@ describe("WebSearchSource", () => { test("has correct id", () => { const source = new WebSearchSource({ client: new RecordingSearchClient() }) - expect(source.id).toBe("freya.web-search") + expect(source.id).toBe(WebSearchSource.id) }) test("does not provide context or feed items", async () => { diff --git a/packages/freya-source-web-search/src/web-search-source.ts b/packages/freya-source-web-search/src/web-search-source.ts index 786f1d9..87c1451 100644 --- a/packages/freya-source-web-search/src/web-search-source.ts +++ b/packages/freya-source-web-search/src/web-search-source.ts @@ -41,7 +41,9 @@ const SearchInput = type({ * action and receive structured web results. */ export class WebSearchSource implements FeedSource { - readonly id = "freya.web-search" + static readonly id = "freya.web-search" + + readonly id = WebSearchSource.id private readonly client: WebSearchClient