5d370a062e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
438 lines
15 KiB
TypeScript
438 lines
15 KiB
TypeScript
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);
|
||
});
|
||
});
|