import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { listAccounts } from "@/features/accounts/service"; import { listTransactions, updateTransaction, getTransaction, } from "@/features/transactions/service"; import { AccountModel } from "@/models/account"; import { CategoryModel } from "@/models/category"; import { RecurringRuleModel } from "@/models/recurring-rule"; import { TransactionModel } from "@/models/transaction"; import { todayIstanbul } from "@/shared/dates"; import { addDays } from "@/shared/recurrence"; import { recurringRuleFormSchema, type RecurringRuleFormInput } from "@/shared/schemas/recurring"; import { transactionFilterSchema } from "@/shared/schemas/transaction"; import { setupSeededDb, teardownDb } from "@/test/seeded-db"; import { confirmOccurrence, createRule, deleteRule, ensureHorizon, getPlannedOverview, postponeOccurrence, skipOccurrence, updateRule, } from "./service"; let ids: Awaited>; let cakir = ""; let ayse = ""; let emre = ""; let zeynep = ""; let fatma = ""; const today = todayIstanbul(); beforeAll(async () => { ids = await setupSeededDb(); cakir = ids.household("Çakır Ailesi"); ayse = ids.user("ayse"); emre = ids.user("emre"); zeynep = ids.user("zeynep"); fatma = ids.user("fatma"); }); afterAll(teardownDb); const acc = (name: string) => AccountModel.findOne({ householdId: cakir, name }) .lean() .then((a) => String(a!._id)); const cat = (key: string) => CategoryModel.findOne({ householdId: cakir, defaultKey: key }) .lean() .then((c) => String(c!._id)); async function form(over: Partial = {}) { return recurringRuleFormSchema.parse({ type: "expense", amount: "1.000,00", accountId: await acc("Ziraat Maaş"), categoryId: await cat("faturalar.elektrik"), payee: "Elektrik", startDate: addDays(today, -40), frequency: "monthly", ...over, }); } const occurrences = (ruleId: string) => TransactionModel.find({ recurringRuleId: ruleId }).sort({ occurrenceIndex: 1 }).lean(); async function balance(accountId: string) { const list = await listAccounts(ayse, cakir); return list!.accounts.find((a) => a.id === accountId)!.balanceMinor!; } describe("kural ve planlı kayıtlar", () => { let ruleId = ""; it("kural 12 ay ileriye planlı kayıt üretir; planlı kayıtlar bakiyeyi etkilemez", async () => { const ziraat = await acc("Ziraat Maaş"); const before = await balance(ziraat); const r = await createRule(ayse, cakir, await form({ isEstimate: true })); expect(r.ok).toBe(true); ruleId = r.ok ? r.data.id : ""; const occ = await occurrences(ruleId); // Başlangıç 40 gün önce: 2 geçmiş + 12 ay ileri (13 ya da 14 kayıt, ay uzunluğuna göre). expect(occ.length).toBeGreaterThanOrEqual(13); expect(occ.every((o) => o.status === "planned" && o.isEstimate)).toBe(true); expect(await balance(ziraat)).toBe(before); expect(await ensureHorizon((await RecurringRuleModel.findById(ruleId))!.householdId)).toBe(0); }); it("genel bakış: vadesi geçmiş ve yaklaşan; işlem listesi planlıları varsayılan göstermez", async () => { const o = (await getPlannedOverview(ayse, cakir))!; const mine = o.overdue.filter((x) => x.recurringRuleId === ruleId); expect(mine.length).toBeGreaterThanOrEqual(1); expect(o.upcoming.every((x) => x.date >= today)).toBe(true); expect(o.rules.find((x) => x.id === ruleId)).toMatchObject({ recurrence: expect.stringContaining("Her ay"), remaining: null, }); const list = await listTransactions(ayse, cakir, transactionFilterSchema.parse({})); expect(list!.items.some((i) => i.status === "planned")).toBe(false); const planned = await listTransactions( ayse, cakir, transactionFilterSchema.parse({ durum: "planned" }), ); expect(planned!.total).toBeGreaterThan(0); }); it("onayla (tutar/tarih düzeltilerek) bakiyeye yansır; atla iptal eder", async () => { const ziraat = await acc("Ziraat Maaş"); const before = await balance(ziraat); const [first, second] = await occurrences(ruleId); const c = await confirmOccurrence(ayse, cakir, String(first!._id), { amount: 1_234_56, date: today, }); expect(c.ok).toBe(true); const posted = (await TransactionModel.findById(first!._id).lean())!; expect(posted).toMatchObject({ status: "posted", amountMinor: 1_234_56, isEstimate: false }); expect(await balance(ziraat)).toBe(before - 1_234_56); // İkinci kez onaylanamaz. expect( (await confirmOccurrence(ayse, cakir, String(first!._id), { amount: 1, date: today })).ok, ).toBe(false); expect((await skipOccurrence(emre, cakir, String(second!._id))).ok).toBe(true); expect((await TransactionModel.findById(second!._id).lean())!.status).toBe("void"); expect(await balance(ziraat)).toBe(before - 1_234_56); }); it("ertele: tarih ileri alınır, kayıt seriden ayrılır; geçmişe ertelenemez", async () => { const occ = await occurrences(ruleId); const third = occ[2]!; const current = third.date.toISOString(); expect((await postponeOccurrence(ayse, cakir, String(third._id), "2000-01-01")).ok).toBe(false); const target = addDays(today, 200); expect((await postponeOccurrence(ayse, cakir, String(third._id), target)).ok).toBe(true); const after = (await TransactionModel.findById(third._id).lean())!; expect(after.detached).toBe(true); expect(after.date.toISOString()).not.toBe(current); }); it("tümünü güncelle: planlılar yeni tutarla yeniden üretilir; gerçekleşen, atlanan ve ayrılan korunur", async () => { const r = await updateRule(ayse, cakir, ruleId, await form({ amount: "1.500,00" }), "all"); expect(r.ok).toBe(true); const occ = await occurrences(ruleId); expect(occ[0]).toMatchObject({ status: "posted", amountMinor: 1_234_56 }); expect(occ[1]).toMatchObject({ status: "void" }); expect(occ[2]).toMatchObject({ detached: true, amountMinor: 1_000_00 }); expect(occ.slice(3).every((o) => o.status === "planned" && o.amountMinor === 1_500_00)).toBe( true, ); // Sıra numaraları benzersiz. expect(new Set(occ.map((o) => o.occurrenceIndex)).size).toBe(occ.length); }); it("bu ve sonrakiler: seri bölünür", async () => { const occ = await occurrences(ruleId); const k = 5; const from = occ.find((o) => o.occurrenceIndex === k)!; const r = await updateRule( ayse, cakir, ruleId, await form({ amount: "2.000,00", startDate: addDays(today, 100) }), "following", String(from._id), ); expect(r.ok).toBe(true); const newId = r.ok ? r.data.id : ""; expect(newId).not.toBe(ruleId); expect((await RecurringRuleModel.findById(ruleId).lean())!.count).toBe(k); const old = await occurrences(ruleId); expect(old.every((o) => o.occurrenceIndex! < k)).toBe(true); const fresh = await occurrences(newId); expect(fresh.length).toBeGreaterThan(0); expect(fresh.every((o) => o.amountMinor === 2_000_00)).toBe(true); }); it("tek kaydı düzenlemek (yalnızca bu) kaydı seriden ayırır", async () => { const r = await createRule( ayse, cakir, await form({ startDate: addDays(today, 3), endKind: "count", count: 3 }), ); const id = r.ok ? r.data.id : ""; const occ = await occurrences(id); expect(occ).toHaveLength(3); const d = (await getTransaction(ayse, cakir, String(occ[1]!._id)))!; expect(d.status).toBe("planned"); const u = await updateTransaction(ayse, cakir, d.id, { type: "expense", amount: 777_00, date: d.date, accountId: d.account!.id, toAccountId: null, categoryId: d.categoryId, splits: [], payee: d.payeeName, description: "", tags: [], attachmentIds: [], installments: 1, }); expect(u.ok).toBe(true); expect(await TransactionModel.findById(d.id).lean()).toMatchObject({ detached: true, status: "planned", amountMinor: 777_00, }); // Silme: bu ve sonrakiler (index 1'den) → yalnızca ilk kalır, kural 1 tekrarla biter. expect((await deleteRule(ayse, cakir, id, "following", d.id)).ok).toBe(true); expect(await occurrences(id)).toHaveLength(1); expect((await deleteRule(ayse, cakir, id, "all")).ok).toBe(true); expect(await occurrences(id)).toHaveLength(0); expect((await RecurringRuleModel.findById(id).lean())!.deletedAt).toBeInstanceOf(Date); }); }); describe("yetki ve izolasyon", () => { it("izleyici kural oluşturamaz/onaylayamaz; hane dışı göremez", async () => { expect((await createRule(zeynep, cakir, await form())).ok).toBe(false); const o = (await getPlannedOverview(zeynep, cakir))!; const any = o.upcoming[0]!; expect((await confirmOccurrence(zeynep, cakir, any.id, { amount: 1, date: today })).ok).toBe( false, ); expect(await getPlannedOverview(fatma, cakir)).toBeNull(); expect((await skipOccurrence(fatma, cakir, any.id)).ok).toBe(false); }); it("gizli hesap kuralı ve kayıtları yalnızca sahibine", async () => { const pluxee = await acc("Ayşe — Pluxee"); expect( ( await createRule( emre, cakir, await form({ accountId: pluxee, startDate: addDays(today, 1) }), ) ).ok, ).toBe(false); const r = await createRule( ayse, cakir, await form({ accountId: pluxee, startDate: addDays(today, 1), payee: "Gizli kural" }), ); const id = r.ok ? r.data.id : ""; const mine = (await getPlannedOverview(ayse, cakir))!; expect(mine.rules.some((x) => x.id === id)).toBe(true); const theirs = (await getPlannedOverview(emre, cakir))!; expect(theirs.rules.some((x) => x.id === id)).toBe(false); expect(theirs.upcoming.some((x) => x.recurringRuleId === id)).toBe(false); expect((await deleteRule(emre, cakir, id, "all")).ok).toBe(false); }); it("form doğrulaması: bitiş tarihi başlangıçtan önce olamaz; transferde hedef hesap", () => { expect( recurringRuleFormSchema.safeParse({ type: "expense", amount: "10", accountId: "a".repeat(24), categoryId: "b".repeat(24), startDate: "2026-05-01", frequency: "monthly", endKind: "date", endDate: "2026-04-01", }).success, ).toBe(false); expect( recurringRuleFormSchema.safeParse({ type: "transfer", amount: "10", accountId: "a".repeat(24), startDate: "2026-05-01", frequency: "monthly", }).success, ).toBe(false); }); });