feat(categories): kategori ağacı, varsayılan Türkçe set ve yönetim ekranı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:57:58 +03:00
parent eaf80c6bb4
commit 380ddaf303
10 changed files with 1469 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
import { Types } from "mongoose";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { CategoryModel } from "@/models/category";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
createCategory,
installDefaultCategories,
listCategories,
setCategoryArchived,
updateCategory,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let annemler = "";
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
annemler = ids.household("Annemler");
});
afterAll(teardownDb);
const byKey = (key: string, householdId = cakir) =>
CategoryModel.findOne({ householdId, defaultKey: key })
.lean()
.then((c) => String(c!._id));
const form = (over: Partial<Parameters<typeof createCategory>[2]> = {}) => ({
kind: "expense" as const,
name: "Test",
parentId: null,
icon: "tag" as const,
color: "blue" as const,
...over,
});
describe("varsayılan kategoriler", () => {
it("tekrar yüklemede kopya oluşmaz", async () => {
const before = await CategoryModel.countDocuments({ householdId: cakir });
const added = await installDefaultCategories(
new Types.ObjectId(cakir),
new Types.ObjectId(ids.user("admin")),
);
expect(added).toBe(0);
expect(await CategoryModel.countDocuments({ householdId: cakir })).toBe(before);
});
it("alt kategoriler doğru ancestors zinciriyle yüklenir", async () => {
const elektrik = await CategoryModel.findOne({
householdId: cakir,
defaultKey: "faturalar.elektrik",
}).lean();
const faturalar = await byKey("faturalar");
expect(elektrik?.ancestors.map(String)).toEqual([faturalar]);
});
});
describe("izolasyon ve roller", () => {
it("başka hanenin kategorilerini okuyamaz ve değiştiremez", async () => {
expect(await listCategories(ids.user("fatma"), cakir)).toBeNull();
const kira = await byKey("kira");
// fatma kendi hanesinin kimliğiyle, Çakır'ın kategorisini hedefler → bulunamaz.
const r = await updateCategory(
ids.user("fatma"),
annemler,
kira,
form({ name: "Ele geçirildi" }),
);
expect(r.ok).toBe(false);
expect((await CategoryModel.findById(kira).lean())?.name).toBe("Kira");
const r2 = await setCategoryArchived(ids.user("fatma"), annemler, kira, true);
expect(r2.ok).toBe(false);
});
it("izleyici ekleyemez, üye ekler", async () => {
expect((await createCategory(ids.user("zeynep"), cakir, form({ name: "Harçlık" }))).ok).toBe(
false,
);
expect((await createCategory(ids.user("emre"), cakir, form({ name: "Harçlık" }))).ok).toBe(
true,
);
});
it("başka hanenin kategorisini üst kategori yapamaz", async () => {
const annemlerMarket = await byKey("market", annemler);
const r = await createCategory(
ids.user("ayse"),
cakir,
form({ name: "Sızma", parentId: annemlerMarket }),
);
expect(r.ok).toBe(false);
});
});
describe("ağaç kuralları", () => {
it("aynı düzeyde aynı ad (Türkçe normalize) reddedilir", async () => {
const r = await createCategory(ids.user("ayse"), cakir, form({ name: " KİRA " }));
expect(r.ok).toBe(false);
// farklı düzeyde aynı ad serbest
const market = await byKey("market");
expect(
(await createCategory(ids.user("ayse"), cakir, form({ name: "Kira", parentId: market }))).ok,
).toBe(true);
});
it("taşıma alt ağacın ancestors alanlarını günceller", async () => {
const ulasim = await byKey("ulasim");
const yakit = await byKey("ulasim.yakit");
const created = await createCategory(
ids.user("ayse"),
cakir,
form({ name: "LPG", parentId: yakit }),
);
const lpg = created.ok ? created.data.id : "";
const ev = await byKey("ev");
// Yakıt'ı (alt kategorisi LPG ile) "Ev ve mobilya" altına taşı.
const r = await updateCategory(
ids.user("ayse"),
cakir,
yakit,
form({ name: "Yakıt", parentId: ev, icon: "fuel" }),
);
expect(r.ok).toBe(true);
expect((await CategoryModel.findById(yakit).lean())?.ancestors.map(String)).toEqual([ev]);
expect((await CategoryModel.findById(lpg).lean())?.ancestors.map(String)).toEqual([ev, yakit]);
expect((await CategoryModel.findById(yakit).lean())?.parentId?.toString()).not.toBe(ulasim);
});
it("döngü ve tür uyuşmazlığı reddedilir", async () => {
const market = await byKey("market");
const gida = await byKey("market.gida");
expect(
(
await updateCategory(
ids.user("ayse"),
cakir,
market,
form({ name: "Market", parentId: gida }),
)
).ok,
).toBe(false);
const maas = await byKey("maas");
expect(
(await updateCategory(ids.user("ayse"), cakir, gida, form({ name: "Gıda", parentId: maas })))
.ok,
).toBe(false);
});
it("arşivleme alt ağaçla birlikte yapılır; üstü arşivliyken alt kategori geri alınamaz", async () => {
const saglik = await byKey("saglik");
const ilac = await byKey("saglik.ilac");
expect((await setCategoryArchived(ids.user("emre"), cakir, saglik, true)).ok).toBe(true);
const active = (await listCategories(ids.user("emre"), cakir))!.map((c) => c.id);
expect(active).not.toContain(saglik);
expect(active).not.toContain(ilac);
expect((await setCategoryArchived(ids.user("emre"), cakir, ilac, false)).ok).toBe(false);
expect((await setCategoryArchived(ids.user("emre"), cakir, saglik, false)).ok).toBe(true);
});
});