Files
butcem/src/features/shopping/completion.int.test.ts
T
suphicakir bd5a20ce64 feat(shopping): alışverişi tamamla transaction'ı ve karşılaştırma motoru
- tek transaction: stok alımı, fiş kaynaklı fiyat, mağaza başına bölünmüş gider, fiş ekleri, kalanların taşınması
- muadil, markasız ürün tipi ve plansız ekstra satırları; stok → gider kategorisi eşlemesi ve yedek kategori
- compareShopping saf fonksiyonu: aynı/muadil/alınmadı/ekstra, farkın nedenlere tam ayrışması
- tamamlanan listede rapor görüntüsü; testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:57:50 +03:00

251 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 { Types } from "mongoose";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { AccountModel } from "@/models/account";
import { CategoryModel } from "@/models/category";
import { ProductModel } from "@/models/product";
import { ProductPriceModel } from "@/models/product-price";
import { ShoppingListModel } from "@/models/shopping-list";
import { StockMovementModel } from "@/models/stock-movement";
import { StoreModel } from "@/models/store";
import { TransactionModel } from "@/models/transaction";
import { todayIstanbul } from "@/shared/dates";
import { formatMoneyInput } from "@/shared/money";
import { addDays } from "@/shared/recurrence";
import { shoppingCompletionSchema } from "@/shared/schemas/shopping";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import { completeShopping, getCompletionSheet } from "./completion";
import { getShoppingList } from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let zeynep = "";
let weekly = "";
let monthEnd = "";
const today = todayIstanbul();
const bySeed = (model: typeof ProductModel | typeof StoreModel, seedRef: string) =>
(model as typeof ProductModel)
.findOne({ seedRef })
.lean()
.then((x) => String(x!._id));
const accountId = (name: string) =>
AccountModel.findOne({ name })
.lean()
.then((x) => String(x!._id));
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
zeynep = ids.user("zeynep");
weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
monthEnd = String((await ShoppingListModel.findOne({ seedRef: "ay-sonu" }).lean())!._id);
});
afterAll(teardownDb);
async function scenario() {
const sheet = (await getCompletionSheet(ayse, cakir, weekly))!;
const item = (label: string) => sheet.items.find((i) => i.label === label)!;
const milk = item("Süt");
const eggs = item("Yumurta");
const detergent = item("Toz Deterjan");
const candle = item("Doğum günü mumu");
const tomato = item("Domates");
const sok = await bySeed(StoreModel, "sok");
const migros = await bySeed(StoreModel, "migros");
const a101 = await bySeed(StoreModel, "a101");
const milkPlan = sheet.planned[milk.id]!;
const detPlan = sheet.planned[detergent.id]!;
const market = (await CategoryModel.findOne({
defaultKey: "market",
householdId: cakir,
}).lean())!;
const accounts = {
bank: await accountId("Ziraat Maaş"),
meal: await accountId("Ayşe — Pluxee"),
card: await accountId("Garanti Bonus"),
};
return {
sheet,
milk,
eggs,
detergent,
candle,
tomato,
stores: { sok, migros, a101 },
form: (over: Record<string, unknown> = {}) =>
shoppingCompletionSchema.parse({
date: today,
lines: [
// Planlanan ürün, planlanan mağaza; paket fiyatı plandan 1 TL fazla.
{
itemId: milk.id,
productId: milkPlan.productId,
storeId: milkPlan.storeId,
packages: String(milkPlan.packages),
amount: formatMoneyInput((milkPlan.priceMinor + 100) * milkPlan.packages),
},
// Muadil: 15'li köy yumurtası, A101.
{
itemId: eggs.id,
productId: sheet.products.find((p) => p.label.includes("Köy"))!.id,
storeId: a101,
packages: "2",
amount: "189,80",
},
// Planlanan ürün başka mağazada (Migros, yemek kartı).
{
itemId: detergent.id,
productId: detPlan.productId,
storeId: migros,
packages: "1",
amount: "549,00",
},
// Markasız ürün tipi (pazardan domates): 2,5 kg, fiyat kaydı yok.
{ itemId: tomato.id, productId: "", storeId: sok, packages: "2,5", amount: "60" },
// Serbest metin kalemi: yedek gider kategorisi.
{ itemId: candle.id, productId: "", storeId: sok, packages: "1", amount: "25" },
// Plansız ekstra.
{
itemId: "",
productId: sheet.products.find((p) => p.label.startsWith("Fairy"))!.id,
storeId: sok,
packages: "1",
amount: "84,50",
},
],
stores: [
{ storeId: sok, accountId: accounts.bank, attachmentIds: [] },
{ storeId: migros, accountId: accounts.meal, attachmentIds: [] },
{ storeId: a101, accountId: accounts.card, attachmentIds: [] },
],
fallbackCategoryId: String(market._id),
leftover: { action: "move", targetListId: monthEnd, newListName: "" },
...over,
}),
};
}
describe("tamamlama ekranı", () => {
it("plan, fiyatlar, hesaplar ve eşleme bilgisi; izleyici açamaz", async () => {
const s = await scenario();
expect(s.sheet.items).toHaveLength(9);
expect(Object.keys(s.sheet.planned).length).toBe(7);
expect(Object.keys(s.sheet.prices).length).toBeGreaterThan(20);
expect(s.sheet.accounts.map((a) => a.name)).toContain("Ayşe — Pluxee");
expect(s.sheet.defaultFallbackCategoryId).not.toBeNull();
expect(s.sheet.otherLists.map((l) => l.name)).toEqual(["Ay sonu toptan"]);
expect(await getCompletionSheet(zeynep, cakir, weekly)).toBeNull();
});
});
describe("alışverişi tamamla", () => {
it("doğrulama: muadil aynı tipten, gelecek tarih, izleyici, şema kuralları", async () => {
const s = await scenario();
const wrongType = s.form();
wrongType.lines[0]!.productId = await bySeed(ProductModel, "ariel-5kg");
expect((await completeShopping(ayse, cakir, weekly, wrongType)).ok).toBe(false);
expect(
(await completeShopping(ayse, cakir, weekly, s.form({ date: addDays(today, 1) }))).ok,
).toBe(false);
expect((await completeShopping(zeynep, cakir, weekly, s.form())).ok).toBe(false);
// Mağazası için ödeme hesabı girilmemiş satır.
expect(
shoppingCompletionSchema.safeParse({
date: today,
lines: [
{
itemId: "",
productId: s.milk.productTypeId,
storeId: s.stores.sok,
packages: "1",
amount: "1",
},
],
stores: [],
leftover: { action: "drop" },
}).success,
).toBe(false);
});
it("ek dosya hatasında hiçbir şey yazılmaz (transaction geri alınır)", async () => {
const s = await scenario();
const form = s.form();
form.stores[0]!.attachmentIds = [String(new Types.ObjectId())];
expect((await completeShopping(ayse, cakir, weekly, form)).ok).toBe(false);
expect(await TransactionModel.countDocuments({ shoppingListId: { $ne: null } })).toBe(0);
expect(await StockMovementModel.countDocuments({ shoppingListId: { $ne: null } })).toBe(0);
expect(await ProductPriceModel.countDocuments({ source: "receipt" })).toBe(0);
expect((await ShoppingListModel.findById(weekly).lean())!.status).toBe("active");
});
it("stok, fiyat, mağaza başına bölünmüş gider, kalanların taşınması ve rapor", async () => {
const s = await scenario();
const monthEndBefore = (await ShoppingListModel.findById(monthEnd).lean())!.items.length;
const r = await completeShopping(ayse, cakir, weekly, s.form());
expect(r).toMatchObject({ ok: true, data: { transactions: 3 } });
// Mağaza başına gider; Şok: gıda + temizlik + yedek (mum) bölünmüş.
const txs = await TransactionModel.find({ shoppingListId: new Types.ObjectId(weekly) }).lean();
expect(txs).toHaveLength(3);
const sokTx = txs.find((t) => t.payeeName.startsWith("Şok"))!;
expect(sokTx.splits).toHaveLength(3);
expect(sokTx.splits.reduce((a, x) => a + x.amountMinor, 0)).toBe(sokTx.amountMinor);
const migrosTx = txs.find((t) => t.payeeName.startsWith("Migros"))!;
expect(migrosTx).toMatchObject({ amountMinor: 549_00, splits: [] });
expect(String(migrosTx.accountId)).toBe(await accountId("Ayşe — Pluxee"));
// Stok alımları (serbest metin hariç 5 kalem) ve fiş kaynaklı fiyatlar (markasız domates hariç 4).
expect(
await StockMovementModel.countDocuments({
shoppingListId: new Types.ObjectId(weekly),
kind: "purchase",
}),
).toBe(5);
expect(await ProductPriceModel.countDocuments({ source: "receipt" })).toBe(4);
const eggPrice = await ProductPriceModel.findOne({ source: "receipt", priceMinor: 94_90 });
expect(eggPrice).not.toBeNull();
// Liste kapandı; alınmayan 5 kalem "Ay sonu toptan"a taşındı.
const after = (await getShoppingList(ayse, cakir, weekly))!;
expect(after.list.status).toBe("completed");
expect(after.canWrite).toBe(false);
expect(after.report!.completion.movedTo?.name).toBe("Ay sonu toptan");
expect((await ShoppingListModel.findById(monthEnd).lean())!.items.length).toBe(
monthEndBefore + 4,
);
// Süt alımı: paket × 1 lt.
const milkMove = await StockMovementModel.findOne({
shoppingListId: new Types.ObjectId(weekly),
productTypeId: new Types.ObjectId(s.milk.productTypeId!),
}).lean();
expect(milkMove!.quantity).toBe(s.sheet.planned[s.milk.id]!.packages * 1000);
// Markasız domates: tipin biriminde 2,5 kg.
const tomatoMove = await StockMovementModel.findOne({
shoppingListId: new Types.ObjectId(weekly),
productTypeId: new Types.ObjectId(s.tomato.productTypeId!),
}).lean();
expect(tomatoMove!.quantity).toBe(2500);
// Rapor: aynı/muadil/alınmadı/ekstra; nedenlerin toplamı farka eşit.
const { summary, completion } = after.report!;
expect(summary.counts).toEqual({ same: 4, substitute: 1, skipped: 4, extra: 1 });
const sum = Object.values(summary.reasons).reduce((a, b) => a + b, 0);
expect(sum).toBe(summary.diffMinor);
// Süt: paket başına +1 TL; deterjan: aynı ürün başka mağazada (Migros) farklı fiyat.
expect(summary.reasons.price).toBe(
100 * s.sheet.planned[s.milk.id]!.packages +
(549_00 - s.sheet.planned[s.detergent.id]!.priceMinor),
);
expect(summary.reasons.extra).toBe(84_50);
expect(summary.reasons.unplanned).toBe(25_00 + 60_00);
expect(summary.reasons.skipped).toBeLessThan(0);
expect(completion.stores).toHaveLength(3);
// Tamamlanan liste değiştirilemez, ikinci kez tamamlanamaz.
expect((await completeShopping(ayse, cakir, weekly, s.form())).ok).toBe(false);
});
});