diff --git a/.env.example b/.env.example index 4b5488b..88fc94f 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,8 @@ BETTER_AUTH_URL="http://localhost:3000" # Faz 4 seed'i için ilk admin hesabı SEED_ADMIN_EMAIL="admin@butcem.local" SEED_ADMIN_PASSWORD="" +# Örnek aile kullanıcılarının ortak şifresi (yalnızca geliştirme; production'da örnek kullanıcı oluşturulmaz) +SEED_USER_PASSWORD="" # Ek dosyalar (canlıda Docker volume: /app/uploads) UPLOAD_DIR="./uploads" diff --git a/scripts/seed.ts b/scripts/seed.ts index 38aa3e8..901e8c0 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -1,62 +1,25 @@ /** - * Seed betiği: npm run seed | Sıfırlayıp doldurma: npm run seed:reset + * Seed: npm run seed | Sıfırlayıp doldurma: npm run seed:reset (production'da reddedilir) * - * Faz 3: yalnızca ilk admin (SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD, kullanıcı adı "admin"). - * Faz 4: örnek kullanıcılar, hane ve aile üyeleri eklenecek. - * - * İdempotent: kullanıcı e-postasına göre kontrol eder, tekrar çalıştırınca kopya oluşturmaz. - * Kullanıcılar Better Auth API'siyle oluşturulur (şifre doğru hashlenir; koleksiyona elle yazılmaz). + * Oluşturulanlar: admin (SEED_ADMIN_*), 7 örnek kullanıcı (biri askıda, biri ikinci admin), + * 2 hane ve hane içi rolleri. Ayrıntı: scripts/seed/fixtures.ts * `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır. */ -import { getAuth } from "@/lib/auth"; -import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { getMongoClient } from "@/lib/db/mongo"; import { env } from "@/lib/env"; - -const reset = process.argv.includes("--reset"); +import { closeSeedConnections, runSeed } from "./seed/run"; async function main() { - const e = env(); - if (reset && e.NODE_ENV === "production") { - throw new Error("seed:reset production ortamında çalıştırılamaz."); - } - if (!e.SEED_ADMIN_EMAIL || !e.SEED_ADMIN_PASSWORD) { - throw new Error("SEED_ADMIN_EMAIL ve SEED_ADMIN_PASSWORD tanımlı olmalı (.env.local)."); - } + const summary = await runSeed({ reset: process.argv.includes("--reset") }); + const { users, households, memberships } = summary.created; + console.log(`\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik.`); - const db = getDb(); - if (reset) { - for (const name of ["user", "account", "session", "verification", "rateLimit"]) { - await db.collection(name).deleteMany({}); - } - console.log("• Kimlik doğrulama koleksiyonları temizlendi."); - } - - const email = e.SEED_ADMIN_EMAIL.toLowerCase(); - const existing = await db.collection("user").findOne({ email }); - if (existing) { - console.log(`• Admin zaten var: ${email}`); - } else { - await getAuth().api.createUser({ - body: { - email, - password: e.SEED_ADMIN_PASSWORD, - name: "Yönetici", - role: "admin", - data: { username: "admin" }, - }, - }); - console.log(`✓ Admin oluşturuldu: ${email} (kullanıcı adı: admin)`); - } - - if (e.NODE_ENV !== "production") { - console.table([ - { - rol: "admin", - kullaniciAdi: "admin", - eposta: email, - sifre: "SEED_ADMIN_PASSWORD (.env.local)", - }, - ]); + // Hesap tablosu yalnızca geliştirme ortamında gösterilir. + if (env().NODE_ENV !== "production" && summary.accounts.length) { + console.log( + "\nGeliştirme hesapları (şifreler: admin → SEED_ADMIN_PASSWORD, diğerleri → SEED_USER_PASSWORD):", + ); + console.table(summary.accounts); } } @@ -65,4 +28,7 @@ main() console.error(err instanceof Error ? err.message : err); process.exitCode = 1; }) - .finally(() => getMongoClient().close()); + .finally(async () => { + await closeSeedConnections(); + await getMongoClient().close(); + }); diff --git a/scripts/seed/fixtures.ts b/scripts/seed/fixtures.ts new file mode 100644 index 0000000..c46fa81 --- /dev/null +++ b/scripts/seed/fixtures.ts @@ -0,0 +1,58 @@ +import type { HouseholdRole } from "@/features/households/types"; +import type { SystemRole } from "@/shared/schemas/common"; + +/** Örnek (geliştirme) kullanıcıları. E-postası olmayanlar kullanıcı adıyla giriş yapar. */ +export type SeedUser = { + username: string; + name: string; + email?: string; + role: SystemRole; + banReason?: string; +}; + +export const SEED_USERS: SeedUser[] = [ + { username: "ayse", name: "Ayşe Çakır", email: "ayse@butcem.local", role: "user" }, + { username: "emre", name: "Emre Çakır", role: "user" }, + { username: "zeynep", name: "Zeynep Çakır", role: "user" }, + { username: "fatma", name: "Fatma Demir", email: "fatma@butcem.local", role: "user" }, + { username: "mehmet", name: "Mehmet Demir", email: "mehmet@butcem.local", role: "user" }, + // İkinci admin: hiçbir haneye üye değil → hane verisini göremez (Faz 0 kararı). + { username: "burak", name: "Burak Öztürk", email: "burak@butcem.local", role: "admin" }, + { + username: "ozan", + name: "Ozan Kaya", + email: "ozan@butcem.local", + role: "user", + banReason: "Ev arkadaşlığı sona erdi; erişim kaldırıldı.", + }, +]; + +/** Seed admin'in kullanıcı adı (SEED_ADMIN_EMAIL ile oluşturulur). */ +export const ADMIN_USERNAME = "admin"; + +export type SeedHousehold = { + name: string; + members: { username: string; role: HouseholdRole }[]; +}; + +export const SEED_HOUSEHOLDS: SeedHousehold[] = [ + { + name: "Çakır Ailesi", + members: [ + { username: ADMIN_USERNAME, role: "owner" }, + { username: "ayse", role: "owner" }, + { username: "emre", role: "member" }, + { username: "zeynep", role: "viewer" }, + { username: "ozan", role: "member" }, + ], + }, + { + name: "Annemler", + members: [ + { username: "fatma", role: "owner" }, + { username: "mehmet", role: "member" }, + // Bir kullanıcı birden çok haneye üye olabilir. + { username: "ayse", role: "viewer" }, + ], + }, +]; diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts new file mode 100644 index 0000000..187daf5 --- /dev/null +++ b/scripts/seed/run.int.test.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getAuth } from "@/lib/auth"; +import { authErrorCode } from "@/lib/auth-errors"; +import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { HouseholdModel } from "@/models/household"; +import { HouseholdMemberModel } from "@/models/household-member"; +import { closeSeedConnections, runSeed } from "./run"; +import { SEED_HOUSEHOLDS, SEED_USERS } from "./fixtures"; + +const silent = () => {}; + +beforeAll(async () => { + expect(getDb().databaseName).toBe("butcem_test"); + await getDb().dropDatabase(); +}); + +afterAll(async () => { + await getDb().dropDatabase(); + await closeSeedConnections(); + await getMongoClient().close(); +}); + +describe("seed", () => { + it("ilk çalıştırmada admin, örnek kullanıcılar, haneler ve üyelikleri oluşturur", async () => { + const summary = await runSeed({ log: silent }); + const memberCount = SEED_HOUSEHOLDS.reduce((n, h) => n + h.members.length, 0); + expect(summary.created).toEqual({ + users: SEED_USERS.length + 1, + households: SEED_HOUSEHOLDS.length, + memberships: memberCount, + }); + expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); + expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); + }); + + it("idempotenttir: ikinci çalıştırma kopya oluşturmaz", async () => { + const summary = await runSeed({ log: silent }); + expect(summary.created).toEqual({ users: 0, households: 0, memberships: 0 }); + expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); + expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); + }); + + it("roller ve askı durumu doğru", async () => { + const users = await getDb() + .collection("user") + .find({}, { projection: { username: 1, role: 1, banned: 1 } }) + .toArray(); + const by = new Map(users.map((u) => [u.username as string, u])); + expect(by.get("admin")?.role).toBe("admin"); + expect(by.get("burak")?.role).toBe("admin"); + expect(by.get("ozan")?.banned).toBe(true); + expect(users.filter((u) => u.banned)).toHaveLength(1); + + // Bir kullanıcı birden çok hanede, farklı rollerle. + const ayse = by.get("ayse")!; + const roles = await HouseholdMemberModel.find({ userId: ayse._id }).lean(); + expect(roles.map((m) => m.role).sort()).toEqual(["owner", "viewer"]); + + // Hanesi olmayan admin. + expect(await HouseholdMemberModel.countDocuments({ userId: by.get("burak")!._id })).toBe(0); + }); + + it("e-postası olmayan kullanıcı kullanıcı adıyla girer; askıdaki kullanıcı giremez", async () => { + const api = getAuth().api; + const emre = await api.signInUsername({ + body: { username: "emre", password: "Test-User-12345" }, + }); + expect(emre.user.name).toBe("Emre Çakır"); + + const error = await api + .signInUsername({ body: { username: "ozan", password: "Test-User-12345" } }) + .then( + () => null, + (e: unknown) => e, + ); + expect(authErrorCode(error)).toBe("BANNED_USER"); + }); + + it("reset koleksiyonları temizleyip yeniden doldurur", async () => { + const summary = await runSeed({ reset: true, log: silent }); + expect(summary.created.users).toBe(SEED_USERS.length + 1); + expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); + }); +}); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts new file mode 100644 index 0000000..815964c --- /dev/null +++ b/scripts/seed/run.ts @@ -0,0 +1,201 @@ +import mongoose, { Types } from "mongoose"; +import { getAuth } from "@/lib/auth"; +import { getDb } from "@/lib/db/mongo"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { env } from "@/lib/env"; +import { HouseholdModel } from "@/models/household"; +import { HouseholdMemberModel } from "@/models/household-member"; +import { storedEmail } from "@/shared/account-email"; +import { ADMIN_USERNAME, SEED_HOUSEHOLDS, SEED_USERS, type SeedUser } from "./fixtures"; + +/** + * İdempotent seed. Kullanıcılar Better Auth API'siyle oluşturulur (şifreler doğru hashlenir); + * tekrar çalıştırınca kopya oluşturmaz (kullanıcı adı / hane adı / üyelik çiftine göre). + * Production'da yalnızca admin oluşturulur; örnek kullanıcı ve hane oluşturulmaz. + */ + +export type SeedOptions = { reset?: boolean; log?: (line: string) => void }; + +export type SeedAccountRow = { + rol: string; + kullaniciAdi: string; + eposta: string; + durum: string; + haneler: string; +}; + +export type SeedSummary = { + created: { users: number; households: number; memberships: number }; + accounts: SeedAccountRow[]; +}; + +const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"]; +const DOMAIN_COLLECTIONS = ["households", "householdMembers"]; + +type AuthApi = ReturnType["api"]; + +async function findUserIdByUsername(username: string): Promise { + const doc = await getDb() + .collection("user") + .findOne({ username }, { projection: { _id: 1 } }); + return doc ? String(doc._id) : null; +} + +/** Admin olarak giriş yapıp oturum başlıklarını döner (ban gibi oturum isteyen admin API'leri için). */ +async function adminHeaders(api: AuthApi, password: string): Promise { + const { headers } = await api.signInUsername({ + body: { username: ADMIN_USERNAME, password }, + returnHeaders: true, + }); + const cookie = headers + .getSetCookie() + .map((c) => c.split(";")[0]) + .join("; "); + return new Headers({ cookie }); +} + +export async function runSeed({ + reset = false, + log = console.log, +}: SeedOptions = {}): Promise { + const e = env(); + const isProd = e.NODE_ENV === "production"; + if (reset && isProd) throw new Error("seed:reset production ortamında çalıştırılamaz."); + if (!e.SEED_ADMIN_EMAIL || !e.SEED_ADMIN_PASSWORD) { + throw new Error("SEED_ADMIN_EMAIL ve SEED_ADMIN_PASSWORD tanımlı olmalı."); + } + if (!isProd && !e.SEED_USER_PASSWORD) { + throw new Error("SEED_USER_PASSWORD tanımlı olmalı (örnek kullanıcıların ortak şifresi)."); + } + + const api = getAuth().api; + const db = getDb(); + await connectMongoose(); + const summary: SeedSummary = { + created: { users: 0, households: 0, memberships: 0 }, + accounts: [], + }; + + if (reset) { + for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) { + await db.collection(name).deleteMany({}); + } + log("• Kimlik doğrulama ve hane koleksiyonları temizlendi."); + } + + // Index'ler (production'da Mongoose autoIndex kapalı olduğu için açıkça oluşturulur). + await HouseholdModel.createIndexes(); + await HouseholdMemberModel.createIndexes(); + + // 1) Admin + const adminEmail = e.SEED_ADMIN_EMAIL.toLowerCase(); + let adminId = await findUserIdByUsername(ADMIN_USERNAME); + if (!adminId) { + const res = await api.createUser({ + body: { + email: adminEmail, + password: e.SEED_ADMIN_PASSWORD, + name: "Yönetici", + role: "admin", + data: { username: ADMIN_USERNAME }, + }, + }); + adminId = res.user.id; + summary.created.users++; + log(`✓ Admin oluşturuldu: ${adminEmail} (kullanıcı adı: ${ADMIN_USERNAME})`); + } else { + log(`• Admin zaten var: ${ADMIN_USERNAME}`); + } + + if (isProd) { + log("• Production: örnek kullanıcı ve hane oluşturulmadı."); + return summary; + } + + // 2) Örnek kullanıcılar + const ids = new Map([[ADMIN_USERNAME, adminId]]); + const toBan: SeedUser[] = []; + for (const u of SEED_USERS) { + let id = await findUserIdByUsername(u.username); + if (!id) { + const res = await api.createUser({ + body: { + email: storedEmail(u.username, u.email), + password: e.SEED_USER_PASSWORD!, + name: u.name, + role: u.role, + data: { username: u.username }, + }, + }); + id = res.user.id; + summary.created.users++; + log(`✓ Kullanıcı: ${u.name} (@${u.username})`); + } + ids.set(u.username, id); + if (u.banReason) toBan.push(u); + } + + // 3) Askıdaki kullanıcı(lar) — admin API'si oturum ister. + const pendingBans: { user: SeedUser; id: string }[] = []; + for (const u of toBan) { + const id = ids.get(u.username)!; + const doc = await db + .collection("user") + .findOne({ _id: new Types.ObjectId(id) }, { projection: { banned: 1 } }); + if (!doc?.banned) pendingBans.push({ user: u, id }); + } + if (pendingBans.length) { + const headers = await adminHeaders(api, e.SEED_ADMIN_PASSWORD); + for (const { user, id } of pendingBans) { + await api.banUser({ headers, body: { userId: id, banReason: user.banReason } }); + log(`✓ Askıya alındı: @${user.username}`); + } + } + + // 4) Haneler ve üyelikler + const memberships = new Map(); + const actor = new Types.ObjectId(adminId); + for (const h of SEED_HOUSEHOLDS) { + let household = await HouseholdModel.findOne({ name: h.name }); + if (!household) { + household = await HouseholdModel.create({ name: h.name, createdBy: actor, updatedBy: actor }); + summary.created.households++; + log(`✓ Hane: ${h.name}`); + } + for (const m of h.members) { + const userId = new Types.ObjectId(ids.get(m.username)!); + const result = await HouseholdMemberModel.updateOne( + { householdId: household._id, userId }, + { + $setOnInsert: { householdId: household._id, userId, createdBy: actor }, + $set: { role: m.role, updatedBy: actor }, + }, + { upsert: true }, + ); + if (result.upsertedCount) summary.created.memberships++; + memberships.set(m.username, [ + ...(memberships.get(m.username) ?? []), + `${h.name} (${m.role})`, + ]); + } + } + + // 5) Geliştirme hesapları tablosu + const all = [ + { username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const }, + ...SEED_USERS, + ]; + summary.accounts = all.map((u) => ({ + rol: u.role, + kullaniciAdi: u.username, + eposta: u.email ?? "—", + durum: "banReason" in u && u.banReason ? "askıda" : "aktif", + haneler: (memberships.get(u.username) ?? ["—"]).join(", "), + })); + return summary; +} + +/** Seed sonrası bağlantıları kapatır (CLI ve testler). */ +export async function closeSeedConnections() { + await mongoose.disconnect(); +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 0ecadac..0bed312 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -19,6 +19,8 @@ export const serverEnvSchema = z.object({ BETTER_AUTH_URL: z.url("BETTER_AUTH_URL geçerli bir URL olmalı"), SEED_ADMIN_EMAIL: z.email().optional(), SEED_ADMIN_PASSWORD: z.string().min(8).optional(), + /** Örnek (geliştirme) kullanıcılarının ortak şifresi; production'da kullanılmaz. */ + SEED_USER_PASSWORD: z.string().min(8).optional(), UPLOAD_DIR: z.string().min(1).default("./uploads"), MAX_UPLOAD_MB: z.coerce.number().int().positive().max(100).default(10), APP_VERSION: z.string().optional(), diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 6b388c5..6c1a0ee 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -23,7 +23,7 @@ export default defineConfig({ }, test: { environment: "node", - include: ["src/**/*.int.test.ts"], + include: ["src/**/*.int.test.ts", "scripts/**/*.int.test.ts"], fileParallelism: false, testTimeout: 20_000, hookTimeout: 30_000, @@ -32,6 +32,9 @@ export default defineConfig({ MONGODB_URI: baseUri, BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-0123", BETTER_AUTH_URL: "http://localhost:3000", + SEED_ADMIN_EMAIL: "admin@test.local", + SEED_ADMIN_PASSWORD: "Test-Admin-12345", + SEED_USER_PASSWORD: "Test-User-12345", }, }, });