5d370a062e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
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<ReturnType<typeof setupSeededDb>>;
|
||
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<RecurringRuleFormInput> = {}) {
|
||
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);
|
||
});
|
||
});
|