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

438 lines
15 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 { Types } from "mongoose";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createAccount, listAccounts } from "@/features/accounts/service";
import { setCategoryArchived } from "@/features/categories/service";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { TransactionModel } from "@/models/transaction";
import { transactionFilterSchema, type TransactionForm } from "@/shared/schemas/transaction";
import { transactionEffects } from "@/shared/transactions";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
createTransaction,
exportTransactions,
getTransaction,
listTransactions,
searchPayees,
setTransactionDeleted,
updateTransaction,
} 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 acc = (name: string, householdId = cakir) =>
AccountModel.findOne({ householdId, name })
.lean()
.then((a) => String(a!._id));
const cat = (key: string, householdId = cakir) =>
CategoryModel.findOne({ householdId, defaultKey: key })
.lean()
.then((c) => String(c!._id));
const filters = (x: Record<string, string> = {}) => transactionFilterSchema.parse(x);
function form(over: Partial<TransactionForm>): TransactionForm {
return {
type: "expense",
amount: 100_00,
date: "2026-06-15",
accountId: "",
toAccountId: null,
categoryId: null,
splits: [],
payee: "",
description: "",
tags: [],
attachmentIds: [],
installments: 1,
...over,
};
}
async function mustCreate(user: string, f: TransactionForm, householdId = cakir) {
const r = await createTransaction(ids.user(user), householdId, f);
if (!r.ok) throw new Error(`oluşturulamadı: ${r.error} ${JSON.stringify(r.fieldErrors)}`);
return r.data.id;
}
describe("bakiye tutarlılığı", () => {
let bank = "";
let card = "";
it("gelir, gider, kart harcaması, karta transfer ve bölünmüş işlem bakiyeye doğru yansır", async () => {
const base = {
institution: "",
mealCardProvider: null,
openingDate: "2026-01-01",
color: "blue" as const,
icon: "landmark" as const,
ownerUserId: null,
isPrivate: false,
overdraftEnabled: false,
overdraftLimit: 0,
overdraftMonthlyRate: null,
overdraftStatementDay: null,
cardLimit: 0,
cardStatementDay: 1,
cardDueKind: "daysAfter" as const,
cardDueValue: 10,
cardPaymentAccountId: null,
cardMinimumRate: null,
};
const b = await createAccount(ids.user("ayse"), cakir, {
...base,
name: "T Banka",
type: "bank",
openingBalance: 1_000_00,
});
const c = await createAccount(ids.user("ayse"), cakir, {
...base,
name: "T Kart",
type: "credit_card",
openingBalance: 0,
});
bank = b.ok ? b.data.id : "";
card = c.ok ? c.data.id : "";
const gida = await cat("market.gida");
const temizlik = await cat("market.temizlik");
await mustCreate(
"ayse",
form({ type: "income", amount: 500_00, accountId: bank, categoryId: await cat("ek-gelir") }),
);
await mustCreate("ayse", form({ amount: 200_00, accountId: bank, categoryId: gida }));
await mustCreate("ayse", form({ amount: 300_00, accountId: card, categoryId: gida }));
await mustCreate(
"ayse",
form({ type: "transfer", amount: 300_00, accountId: bank, toAccountId: card }),
);
await mustCreate(
"ayse",
form({
amount: 120_00,
accountId: bank,
splits: [
{ categoryId: gida, amount: 80_00, note: "" },
{ categoryId: temizlik, amount: 40_00, note: "Deterjan" },
],
}),
);
const balances = (await listAccounts(ids.user("ayse"), cakir))!.accounts;
expect(balances.find((a) => a.id === bank)?.balanceMinor).toBe(
1_000_00 + 500_00 - 200_00 - 300_00 - 120_00,
);
expect(balances.find((a) => a.id === card)?.balanceMinor).toBe(-300_00 + 300_00);
});
it("silme bakiyeden düşer, geri alma geri getirir", async () => {
const id = await mustCreate(
"emre",
form({ amount: 50_00, accountId: bank, categoryId: await cat("market.gida") }),
);
const bal = async () =>
(await listAccounts(ids.user("ayse"), cakir))!.accounts.find((a) => a.id === bank)!
.balanceMinor;
const before = await bal();
expect((await setTransactionDeleted(ids.user("emre"), cakir, id, true)).ok).toBe(true);
expect(await bal()).toBe(before! + 50_00);
expect(await getTransaction(ids.user("emre"), cakir, id)).toBeNull();
expect((await setTransactionDeleted(ids.user("emre"), cakir, id, false)).ok).toBe(true);
expect(await bal()).toBe(before);
});
it("tüm hesaplarda bakiye = açılış + Σ transactionEffects (bağımsız doğrulama)", async () => {
const accounts = await AccountModel.find({ householdId: cakir }).lean();
const txs = await TransactionModel.find({ householdId: cakir }).lean();
const expected = new Map(accounts.map((a) => [String(a._id), a.openingBalanceMinor]));
for (const tx of txs) {
for (const e of transactionEffects({
type: tx.type,
status: tx.status,
amountMinor: tx.amountMinor,
accountId: String(tx.accountId),
toAccountId: tx.toAccountId ? String(tx.toAccountId) : null,
deleted: Boolean(tx.deletedAt),
})) {
expected.set(e.accountId, (expected.get(e.accountId) ?? 0) + e.deltaMinor);
}
}
// ayse "yalnızca ben" hesabının sahibi; tüm bakiyeleri görür.
for (const a of (await listAccounts(ids.user("ayse"), cakir, { includeArchived: true }))!
.accounts) {
expect(a.balanceMinor, a.name).toBe(expected.get(a.id));
}
});
});
describe("doğrulama ve izolasyon", () => {
it("gider için gelir kategorisi, başka hanenin hesabı/kategorisi reddedilir", async () => {
const ziraat = await acc("Ziraat Maaş");
const r1 = await createTransaction(
ids.user("ayse"),
cakir,
form({ accountId: ziraat, categoryId: await cat("maas") }),
);
expect(r1.ok).toBe(false);
const r2 = await createTransaction(
ids.user("ayse"),
cakir,
form({ accountId: await acc("Nakit", annemler), categoryId: await cat("market.gida") }),
);
expect(r2.ok).toBe(false);
const r3 = await createTransaction(
ids.user("ayse"),
cakir,
form({ accountId: ziraat, categoryId: await cat("market.gida", annemler) }),
);
expect(r3.ok).toBe(false);
const r4 = await createTransaction(
ids.user("ayse"),
cakir,
form({
accountId: ziraat,
splits: [
{ categoryId: await cat("market.gida"), amount: 50_00, note: "" },
{ categoryId: await cat("market.gida", annemler), amount: 50_00, note: "" },
],
}),
);
expect(r4.ok).toBe(false);
const r5 = await createTransaction(
ids.user("ayse"),
cakir,
form({
type: "transfer",
accountId: ziraat,
toAccountId: await acc("Vakıfbank Emekli", annemler),
}),
);
expect(r5.ok).toBe(false);
});
it("izleyici işlem giremez; başka hane üyesi işlemleri göremez", async () => {
const r = await createTransaction(
ids.user("zeynep"),
cakir,
form({ accountId: await acc("Cüzdan"), categoryId: await cat("market.gida") }),
);
expect(r.ok).toBe(false);
expect(await listTransactions(ids.user("fatma"), cakir, filters())).toBeNull();
const cakirTx = await TransactionModel.findOne({ householdId: cakir }).lean();
// fatma kendi hane kimliğiyle Çakır'ın işlemini isterse bulunamaz.
expect(await getTransaction(ids.user("fatma"), annemler, String(cakirTx!._id))).toBeNull();
expect(
(await setTransactionDeleted(ids.user("fatma"), annemler, String(cakirTx!._id), true)).ok,
).toBe(false);
});
});
describe("“yalnızca ben” hesabı işlemleri", () => {
it("başkasından tamamen gizlenir; o hesaba yalnızca sahibi işlem girer", async () => {
const pluxee = await acc("Ayşe — Pluxee");
const tx = await TransactionModel.findOne({ householdId: cakir, accountId: pluxee }).lean();
const ofEmre = await listTransactions(ids.user("emre"), cakir, filters({ hesap: pluxee }));
expect(ofEmre!.total).toBe(0);
expect(await getTransaction(ids.user("emre"), cakir, String(tx!._id))).toBeNull();
const ofAyse = await listTransactions(ids.user("ayse"), cakir, filters({ hesap: pluxee }));
expect(ofAyse!.total).toBeGreaterThan(0);
const exportEmre = await exportTransactions(ids.user("emre"), cakir, filters());
expect(exportEmre!.some((r) => r.account === "Ayşe — Pluxee")).toBe(false);
const kafe = await cat("eglence.kafe");
expect(
(
await createTransaction(
ids.user("emre"),
cakir,
form({ accountId: pluxee, categoryId: kafe }),
)
).ok,
).toBe(false);
expect(
(
await updateTransaction(
ids.user("emre"),
cakir,
String(tx!._id),
form({ accountId: pluxee, categoryId: kafe }),
)
).ok,
).toBe(false);
expect(
(
await createTransaction(
ids.user("ayse"),
cakir,
form({ accountId: pluxee, categoryId: kafe }),
)
).ok,
).toBe(true);
});
});
describe("arşivli kategori", () => {
it("yeni işlemde kullanılamaz, mevcut işlemde korunur", async () => {
const giyim = await cat("giyim");
const cuzdan = await acc("Cüzdan");
const id = await mustCreate("ayse", form({ accountId: cuzdan, categoryId: giyim }));
expect((await setCategoryArchived(ids.user("ayse"), cakir, giyim, true)).ok).toBe(true);
expect(
(
await createTransaction(
ids.user("ayse"),
cakir,
form({ accountId: cuzdan, categoryId: giyim }),
)
).ok,
).toBe(false);
expect(
(
await updateTransaction(
ids.user("ayse"),
cakir,
id,
form({ accountId: cuzdan, categoryId: giyim, description: "düzeltme" }),
)
).ok,
).toBe(true);
await setCategoryArchived(ids.user("ayse"), cakir, giyim, false);
});
});
describe("filtreler", () => {
it("üst kategori filtresi alt kategorileri ve bölünmüş kalemleri kapsar", async () => {
const market = await cat("market");
const page = await listTransactions(ids.user("ayse"), cakir, filters({ kategori: market }));
expect(page!.total).toBe(
await TransactionModel.countDocuments({
householdId: cakir,
deletedAt: null,
effectiveCategoryIds: {
$in: await CategoryModel.find({
householdId: cakir,
$or: [{ _id: market }, { ancestors: new Types.ObjectId(market) }],
}).distinct("_id"),
},
}),
);
expect(page!.items.some((i) => i.splitCount > 0)).toBe(true);
});
it("tarih aralığı İstanbul gününe göre (gün dahil)", async () => {
const id = await mustCreate(
"ayse",
form({
date: "2026-01-15",
accountId: await acc("Cüzdan"),
categoryId: await cat("market.gida"),
payee: "Tarih Testi",
}),
);
const same = await listTransactions(
ids.user("ayse"),
cakir,
filters({ from: "2026-01-15", to: "2026-01-15", q: "tarih testi" }),
);
expect(same!.items.map((i) => i.id)).toEqual([id]);
const after = await listTransactions(
ids.user("ayse"),
cakir,
filters({ from: "2026-01-16", q: "tarih testi" }),
);
expect(after!.total).toBe(0);
});
it("Türkçe arama, tutar aralığı, kişi, etiket ve hesap (transfer hedefi dahil)", async () => {
const q = await listTransactions(ids.user("ayse"), cakir, filters({ q: "MİGROS" }));
expect(q!.total).toBeGreaterThan(0);
expect(q!.items.every((i) => i.payeeName === "Migros")).toBe(true);
const range = await listTransactions(
ids.user("ayse"),
cakir,
filters({ min: "1.000", max: "2.000" }),
);
expect(range!.items.every((i) => i.amountMinor >= 1_000_00 && i.amountMinor <= 2_000_00)).toBe(
true,
);
const byEmre = await listTransactions(
ids.user("ayse"),
cakir,
filters({ kisi: ids.user("emre") }),
);
expect(byEmre!.items.every((i) => i.createdBy.id === ids.user("emre"))).toBe(true);
const tagged = await listTransactions(ids.user("ayse"), cakir, filters({ etiket: "İndirim" }));
expect(tagged!.items.every((i) => i.tags.includes("indirim"))).toBe(true);
const cuzdan = await acc("Cüzdan");
const byAccount = await listTransactions(
ids.user("ayse"),
cakir,
filters({ hesap: cuzdan, type: "transfer" }),
);
expect(byAccount!.total).toBeGreaterThan(0);
expect(
byAccount!.items.every((i) => i.toAccount?.id === cuzdan || i.account!.id === cuzdan),
).toBe(true);
});
it("geçersiz filtre değerleri yok sayılır (enjeksiyon yok)", async () => {
const f = transactionFilterSchema.parse({
hesap: "{$ne:null}",
kategori: "x",
min: "abc",
sayfa: "-3",
q: ".*",
});
expect(f.hesap).toBeUndefined();
expect(f.kategori).toBeUndefined();
expect(f.min).toBeUndefined();
expect(f.sayfa).toBe(1);
// ".*" düz metin olarak aranır (regex kaçışı).
expect((await listTransactions(ids.user("ayse"), cakir, f))!.total).toBe(0);
});
});
describe("satıcı kuralları", () => {
it("son kullanılan kategori ve hesap önerilir", async () => {
const cuzdan = await acc("Cüzdan");
const ilac = await cat("saglik.ilac");
await mustCreate("ayse", form({ accountId: cuzdan, categoryId: ilac, payee: "Şifa Eczanesi" }));
const [s] = await searchPayees(ids.user("ayse"), cakir, "sifa");
expect(s).toMatchObject({
name: "Şifa Eczanesi",
lastAccountId: cuzdan,
lastCategoryId: ilac,
lastType: "expense",
});
// Başka hane satıcıyı görmez.
expect(await searchPayees(ids.user("fatma"), annemler, "sifa")).toEqual([]);
});
});
describe("CSV", () => {
it("bölünmüş işlem kalem başına satır üretir, işaret türe göre", async () => {
const rows = (await exportTransactions(ids.user("ayse"), cakir, filters()))!;
const split = await TransactionModel.findOne({
householdId: cakir,
"splits.1": { $exists: true },
deletedAt: null,
}).lean();
const lines = rows.filter((r) => r.no === String(split!._id));
expect(lines).toHaveLength(split!.splits.length);
expect(lines.reduce((a, r) => a + r.amountMinor, 0)).toBe(-split!.amountMinor);
expect(rows.find((r) => r.type === "income")!.amountMinor).toBeGreaterThan(0);
});
});