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>; 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 = {}) => 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); }); });