Files
butcem/src/features/recurring/service.int.test.ts
T

285 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});