diff --git a/src/server/features/legislation/legislation.test.ts b/src/server/features/legislation/legislation.test.ts new file mode 100644 index 0000000..a39d198 --- /dev/null +++ b/src/server/features/legislation/legislation.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("../../shared/db/client", () => { + const mockDb = { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + } + // chain builder pattern + const chain = (result: unknown) => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(result), + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(result), + }), + }), + leftJoin: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(result), + }), + }), + }), + }) + mockDb.select.mockReturnValue(chain([])) + mockDb.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), + returning: vi.fn().mockResolvedValue([{ id: 1 }]), + }), + }) + return { db: mockDb } +}) + +const { getLegislation, castVote, getUserVote } = await import("./service") +const { db } = await import("../../shared/db/client") + +describe("legislation service", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("getLegislation", () => { + it("returns null when legislation not found", async () => { + const result = await getLegislation(999) + expect(result).toBeNull() + expect(db.select).toHaveBeenCalled() + }) + }) + + describe("castVote", () => { + it("upserts a user vote", async () => { + await castVote(1, { + deviceId: "550e8400-e29b-41d4-a716-446655440000", + vote: "ja", + }) + expect(db.insert).toHaveBeenCalled() + }) + }) + + describe("getUserVote", () => { + it("returns null when no vote exists", async () => { + const result = await getUserVote( + 1, + "550e8400-e29b-41d4-a716-446655440000", + ) + expect(result).toBeNull() + }) + + it("returns vote when it exists", async () => { + const mockVote = { + id: 1, + deviceId: "550e8400-e29b-41d4-a716-446655440000", + legislationId: 1, + vote: "ja", + votedAt: new Date("2026-03-10"), + } + const mockChain = { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([mockVote]), + }), + }), + } + vi.mocked(db.select).mockReturnValueOnce( + mockChain as ReturnType, + ) + + const result = await getUserVote( + 1, + "550e8400-e29b-41d4-a716-446655440000", + ) + expect(result).not.toBeNull() + expect(result?.vote).toBe("ja") + }) + }) +}) diff --git a/src/server/features/legislation/schema.ts b/src/server/features/legislation/schema.ts new file mode 100644 index 0000000..c4b664f --- /dev/null +++ b/src/server/features/legislation/schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const castVoteSchema = z.object({ + deviceId: z.string().uuid(), + vote: z.enum(["ja", "nein", "enthaltung"]), +}) + +export type CastVoteRequest = z.infer diff --git a/src/server/features/legislation/service.ts b/src/server/features/legislation/service.ts new file mode 100644 index 0000000..9798f59 --- /dev/null +++ b/src/server/features/legislation/service.ts @@ -0,0 +1,107 @@ +import { and, desc, eq } from "drizzle-orm" +import { db } from "../../shared/db/client" +import { + legislationSummaries, + legislationTexts, + userVotes, +} from "../../shared/db/schema/legislation" +import type { CastVoteRequest } from "./schema" + +export async function getUpcomingLegislation() { + const rows = await db + .select({ + id: legislationTexts.id, + title: legislationTexts.title, + beratungsstand: legislationTexts.beratungsstand, + summary: legislationSummaries.summary, + }) + .from(legislationTexts) + .leftJoin( + legislationSummaries, + eq(legislationSummaries.legislationId, legislationTexts.id), + ) + .orderBy(desc(legislationTexts.fetchedAt)) + .limit(20) + + return rows.map((r) => ({ + id: r.id, + title: r.title, + beratungsstand: r.beratungsstand, + summary: r.summary ?? null, + })) +} + +export async function getLegislation(id: number) { + const rows = await db + .select() + .from(legislationTexts) + .where(eq(legislationTexts.id, id)) + .limit(1) + + if (rows.length === 0) return null + + const leg = rows[0] + + // fetch summary if available + const summaries = await db + .select() + .from(legislationSummaries) + .where(eq(legislationSummaries.legislationId, id)) + .limit(1) + + return { + id: leg.id, + dipVorgangsId: leg.dipVorgangsId, + title: leg.title, + abstract: leg.abstract, + fullText: leg.fullText, + drucksacheUrl: leg.drucksacheUrl, + beratungsstand: leg.beratungsstand, + summary: summaries[0]?.summary ?? null, + fetchedAt: leg.fetchedAt.toISOString(), + } +} + +export async function getLegislationText(id: number) { + const rows = await db + .select({ fullText: legislationTexts.fullText }) + .from(legislationTexts) + .where(eq(legislationTexts.id, id)) + .limit(1) + + return rows[0]?.fullText ?? null +} + +export async function castVote(legislationId: number, data: CastVoteRequest) { + await db + .insert(userVotes) + .values({ + deviceId: data.deviceId, + legislationId, + vote: data.vote, + }) + .onConflictDoUpdate({ + target: [userVotes.deviceId, userVotes.legislationId], + set: { vote: data.vote, votedAt: new Date() }, + }) +} + +export async function getUserVote(legislationId: number, deviceId: string) { + const rows = await db + .select() + .from(userVotes) + .where( + and( + eq(userVotes.legislationId, legislationId), + eq(userVotes.deviceId, deviceId), + ), + ) + .limit(1) + + if (rows.length === 0) return null + return { + legislationId: rows[0].legislationId, + vote: rows[0].vote, + votedAt: rows[0].votedAt.toISOString(), + } +}