feat: seed default user sources

This commit is contained in:
2026-06-14 14:24:17 +01:00
parent efd7537008
commit 8532d6dbac
10 changed files with 220 additions and 8 deletions

View File

@@ -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])
})
})

View File

@@ -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()],
})
}

View File

@@ -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,

View File

@@ -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)
}
})
})

View File

@@ -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<void> {
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],
})
}

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -5,8 +5,6 @@ import { type } from "arktype"
import { Location, type LocationSourceOptions } from "./types.ts"
export const LocationKey: ContextKey<Location> = contextKey("freya.location", "location")
/**
* A FeedSource that provides location context.
*
@@ -16,7 +14,9 @@ export const LocationKey: ContextKey<Location> = 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<Location> = contextKey(LocationSource.id, "location")

View File

@@ -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 () => {

View File

@@ -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