feat(seed): örnek aile kullanıcıları, haneler ve roller (idempotent)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:14:10 +03:00
parent 38e2a7a26d
commit 2b648975ae
7 changed files with 369 additions and 53 deletions
+2
View File
@@ -12,6 +12,8 @@ BETTER_AUTH_URL="http://localhost:3000"
# Faz 4 seed'i için ilk admin hesabı # Faz 4 seed'i için ilk admin hesabı
SEED_ADMIN_EMAIL="admin@butcem.local" SEED_ADMIN_EMAIL="admin@butcem.local"
SEED_ADMIN_PASSWORD="" 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) # Ek dosyalar (canlıda Docker volume: /app/uploads)
UPLOAD_DIR="./uploads" UPLOAD_DIR="./uploads"
+18 -52
View File
@@ -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"). * Oluşturulanlar: admin (SEED_ADMIN_*), 7 örnek kullanıcı (biri askıda, biri ikinci admin),
* Faz 4: örnek kullanıcılar, hane ve aile üyeleri eklenecek. * 2 hane ve hane içi rolleri. Ayrıntı: scripts/seed/fixtures.ts
*
* İ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).
* `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır. * `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır.
*/ */
import { getAuth } from "@/lib/auth"; import { getMongoClient } from "@/lib/db/mongo";
import { getDb, getMongoClient } from "@/lib/db/mongo";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { closeSeedConnections, runSeed } from "./seed/run";
const reset = process.argv.includes("--reset");
async function main() { async function main() {
const e = env(); const summary = await runSeed({ reset: process.argv.includes("--reset") });
if (reset && e.NODE_ENV === "production") { const { users, households, memberships } = summary.created;
throw new Error("seed:reset production ortamında çalıştırılamaz."); console.log(`\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik.`);
}
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 db = getDb(); // Hesap tablosu yalnızca geliştirme ortamında gösterilir.
if (reset) { if (env().NODE_ENV !== "production" && summary.accounts.length) {
for (const name of ["user", "account", "session", "verification", "rateLimit"]) { console.log(
await db.collection(name).deleteMany({}); "\nGeliştirme hesapları (şifreler: admin → SEED_ADMIN_PASSWORD, diğerleri → SEED_USER_PASSWORD):",
} );
console.log("• Kimlik doğrulama koleksiyonları temizlendi."); console.table(summary.accounts);
}
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)",
},
]);
} }
} }
@@ -65,4 +28,7 @@ main()
console.error(err instanceof Error ? err.message : err); console.error(err instanceof Error ? err.message : err);
process.exitCode = 1; process.exitCode = 1;
}) })
.finally(() => getMongoClient().close()); .finally(async () => {
await closeSeedConnections();
await getMongoClient().close();
});
+58
View File
@@ -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" },
],
},
];
+84
View File
@@ -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);
});
});
+201
View File
@@ -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<typeof getAuth>["api"];
async function findUserIdByUsername(username: string): Promise<string | null> {
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<Headers> {
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<SeedSummary> {
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<string, string>([[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<string, string[]>();
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();
}
+2
View File
@@ -19,6 +19,8 @@ export const serverEnvSchema = z.object({
BETTER_AUTH_URL: z.url("BETTER_AUTH_URL geçerli bir URL olmalı"), BETTER_AUTH_URL: z.url("BETTER_AUTH_URL geçerli bir URL olmalı"),
SEED_ADMIN_EMAIL: z.email().optional(), SEED_ADMIN_EMAIL: z.email().optional(),
SEED_ADMIN_PASSWORD: z.string().min(8).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"), UPLOAD_DIR: z.string().min(1).default("./uploads"),
MAX_UPLOAD_MB: z.coerce.number().int().positive().max(100).default(10), MAX_UPLOAD_MB: z.coerce.number().int().positive().max(100).default(10),
APP_VERSION: z.string().optional(), APP_VERSION: z.string().optional(),
+4 -1
View File
@@ -23,7 +23,7 @@ export default defineConfig({
}, },
test: { test: {
environment: "node", environment: "node",
include: ["src/**/*.int.test.ts"], include: ["src/**/*.int.test.ts", "scripts/**/*.int.test.ts"],
fileParallelism: false, fileParallelism: false,
testTimeout: 20_000, testTimeout: 20_000,
hookTimeout: 30_000, hookTimeout: 30_000,
@@ -32,6 +32,9 @@ export default defineConfig({
MONGODB_URI: baseUri, MONGODB_URI: baseUri,
BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-0123", BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-0123",
BETTER_AUTH_URL: "http://localhost:3000", BETTER_AUTH_URL: "http://localhost:3000",
SEED_ADMIN_EMAIL: "admin@test.local",
SEED_ADMIN_PASSWORD: "Test-Admin-12345",
SEED_USER_PASSWORD: "Test-User-12345",
}, },
}, },
}); });