8ab2244755
- shoppingLists koleksiyonu (ürün tipi / marka / serbest metin kalemleri), migration 015 - planShopping saf fonksiyonu: paket yuvarlama, zahmet ve hacimli cezası, teslimat, yemek kartı önceliği, ek mağaza eşiği - eşik önerileri onay/red (red stok yenilenene kadar), ürün tipine hacimli işareti - fiyat servisinde ortak loadTypeOffers; seed listeleri ve testler Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
8.5 KiB
TypeScript
203 lines
8.5 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||
import { AuditLogModel } from "@/models/audit-log";
|
||
import { ProductModel } from "@/models/product";
|
||
import { ProductTypeModel } from "@/models/product-type";
|
||
import { ShoppingListModel } from "@/models/shopping-list";
|
||
import { shoppingItemFormSchema, shoppingItemUpdateSchema } from "@/shared/schemas/shopping";
|
||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||
import {
|
||
addShoppingItem,
|
||
approveSuggestions,
|
||
createShoppingList,
|
||
deleteShoppingList,
|
||
dismissSuggestion,
|
||
getShoppingList,
|
||
listShoppingLists,
|
||
moveShoppingItem,
|
||
removeShoppingItem,
|
||
setShoppingItemChecked,
|
||
updateShoppingItem,
|
||
} from "./service";
|
||
|
||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||
let cakir = "";
|
||
let ayse = "";
|
||
let zeynep = "";
|
||
let fatma = "";
|
||
let weekly = "";
|
||
let monthEnd = "";
|
||
|
||
const typeId = (seedRef: string) =>
|
||
ProductTypeModel.findOne({ seedRef })
|
||
.lean()
|
||
.then((x) => String(x!._id));
|
||
const productId = (seedRef: string) =>
|
||
ProductModel.findOne({ seedRef })
|
||
.lean()
|
||
.then((x) => String(x!._id));
|
||
const item = (input: Record<string, unknown>) => shoppingItemFormSchema.parse(input);
|
||
|
||
beforeAll(async () => {
|
||
ids = await setupSeededDb();
|
||
cakir = ids.household("Çakır Ailesi");
|
||
ayse = ids.user("ayse");
|
||
zeynep = ids.user("zeynep");
|
||
fatma = ids.user("fatma");
|
||
weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
|
||
monthEnd = String((await ShoppingListModel.findOne({ seedRef: "ay-sonu" }).lean())!._id);
|
||
});
|
||
afterAll(teardownDb);
|
||
|
||
describe("liste okuma ve akıllı öneri", () => {
|
||
it("aktif listeler, bekleyen öneri sayısı; başka hane göremez", async () => {
|
||
const data = (await listShoppingLists(ayse, cakir))!;
|
||
expect(data.active.map((l) => l.name).sort()).toEqual(["Ay sonu toptan", "Haftalık market"]);
|
||
expect(data.suggestions).toBeGreaterThanOrEqual(3);
|
||
expect(data.canWrite).toBe(true);
|
||
expect(await listShoppingLists(fatma, cakir)).toBeNull();
|
||
expect(await getShoppingList(fatma, cakir, weekly)).toBeNull();
|
||
});
|
||
|
||
it("kalem görünümleri, eşik önerileri ve plan", async () => {
|
||
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||
expect(d.items).toHaveLength(9);
|
||
const milk = d.items.find((i) => i.label === "Süt")!;
|
||
expect(milk).toMatchObject({ kind: "type", unit: "lt", quantityLabel: "4 lt" });
|
||
expect(milk.stock).not.toBeNull();
|
||
const yumos = d.items.find((i) => i.kind === "product")!;
|
||
expect(yumos.quantityLabel).toBe("1 paket");
|
||
const candle = d.items.find((i) => i.kind === "text")!;
|
||
expect(candle).toMatchObject({ label: "Doğum günü mumu", note: "Rakam mum: 7" });
|
||
|
||
// Eşiğin altındaki mercimek listede yok: öneri olarak gelir.
|
||
expect(d.suggestions.map((s) => s.name)).toContain("Kırmızı Mercimek");
|
||
expect(d.suggestions.every((s) => s.suggestedQuantity >= 0)).toBe(true);
|
||
|
||
// Plan: domatesin fiyatı yok; serbest metin plana katılmaz; diğerleri karşılanır.
|
||
const tomato = d.items.find((i) => i.label === "Domates")!;
|
||
expect(d.plan.unpriced).toEqual([tomato.id]);
|
||
const rec = d.plan.recommended!;
|
||
expect(rec.missing).toEqual([]);
|
||
const planned = rec.stores.flatMap((g) => g.lines.map((l) => l.itemId));
|
||
expect(planned).toHaveLength(7);
|
||
expect(planned).not.toContain(candle.id);
|
||
for (const g of rec.stores) {
|
||
expect(d.plan.stores[g.storeId]).toBeDefined();
|
||
for (const l of g.lines) expect(d.plan.products[l.productId]).toBeDefined();
|
||
}
|
||
// Belirli marka kalemi yalnızca o ürünle planlanır.
|
||
const yumosLine = rec.stores.flatMap((g) => g.lines).find((l) => l.itemId === yumos.id)!;
|
||
expect(yumosLine.productId).toBe(yumos.productId);
|
||
expect(d.plan.single).not.toBeNull();
|
||
expect(d.plan.thresholdMinor).toBe(10_000);
|
||
// Ayşe'nin yemek kartı bakiyesi plana girer (seed: Multinet).
|
||
expect(d.plan.mealCardBalanceMinor).toBeGreaterThan(0);
|
||
expect(d.options.types.length).toBeGreaterThan(10);
|
||
expect(d.otherLists.map((o) => o.name)).toEqual(["Ay sonu toptan"]);
|
||
});
|
||
});
|
||
|
||
describe("kalem işlemleri", () => {
|
||
it("ekle (çift kayıt reddi, izleyici yazamaz), düzenle, işaretle, taşı, çıkar", async () => {
|
||
const milk = await typeId("sut");
|
||
expect(shoppingItemFormSchema.safeParse({ kind: "type", productTypeId: "" }).success).toBe(
|
||
false,
|
||
);
|
||
expect(shoppingItemFormSchema.safeParse({ kind: "text", text: "" }).success).toBe(false);
|
||
expect(
|
||
(await addShoppingItem(ayse, cakir, weekly, item({ kind: "type", productTypeId: milk }))).ok,
|
||
).toBe(false);
|
||
const text = item({ kind: "text", text: "Pil", quantity: "4", unit: "adet" });
|
||
expect((await addShoppingItem(zeynep, cakir, weekly, text)).ok).toBe(false);
|
||
|
||
const added = await addShoppingItem(ayse, cakir, weekly, text);
|
||
expect(added.ok).toBe(true);
|
||
const id = added.ok ? added.data.id : "";
|
||
const brand = await addShoppingItem(
|
||
ayse,
|
||
cakir,
|
||
weekly,
|
||
item({ kind: "product", productId: await productId("ariel-5kg"), quantity: "2" }),
|
||
);
|
||
expect(brand.ok).toBe(true);
|
||
|
||
expect(
|
||
(
|
||
await updateShoppingItem(
|
||
ayse,
|
||
cakir,
|
||
weekly,
|
||
id,
|
||
shoppingItemUpdateSchema.parse({ quantity: "6", text: "AA pil", unit: "adet" }),
|
||
)
|
||
).ok,
|
||
).toBe(true);
|
||
let d = (await getShoppingList(ayse, cakir, weekly))!;
|
||
expect(d.items.find((i) => i.id === id)).toMatchObject({
|
||
label: "AA pil",
|
||
quantityLabel: "6 adet",
|
||
});
|
||
const ariel = d.items.find((i) => i.label.startsWith("Ariel"))!;
|
||
expect(ariel.quantityLabel).toBe("2 paket");
|
||
|
||
expect((await setShoppingItemChecked(ayse, cakir, weekly, id, true)).ok).toBe(true);
|
||
d = (await getShoppingList(ayse, cakir, weekly))!;
|
||
// Sepete alınanlar sona.
|
||
expect(d.items.at(-1)).toMatchObject({ id, checked: true });
|
||
expect(d.list.checkedCount).toBe(1);
|
||
|
||
expect((await moveShoppingItem(ayse, cakir, weekly, id, weekly)).ok).toBe(false);
|
||
expect((await moveShoppingItem(ayse, cakir, weekly, id, monthEnd)).ok).toBe(true);
|
||
const target = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||
expect(target.items.find((i) => i.id === id)).toMatchObject({ checked: false });
|
||
|
||
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(true);
|
||
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(false);
|
||
expect((await removeShoppingItem(ayse, cakir, weekly, ariel.id)).ok).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("eşik önerileri", () => {
|
||
it("onayla: önerilen miktarla eklenir, öneriden düşer; tekrar onay reddedilir", async () => {
|
||
const lentil = await typeId("kirmizi-mercimek");
|
||
const r = await approveSuggestions(ayse, cakir, weekly, [lentil]);
|
||
expect(r).toMatchObject({ ok: true, data: { added: 1 } });
|
||
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||
const row = d.items.find((i) => i.productTypeId === lentil)!;
|
||
expect(row.source).toBe("suggestion");
|
||
expect(row.quantity).toBeGreaterThan(0);
|
||
expect(d.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||
expect((await approveSuggestions(ayse, cakir, weekly, [lentil])).ok).toBe(false);
|
||
// Başka aktif listede de öneri olarak görünmez.
|
||
const other = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||
expect(other.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||
await removeShoppingItem(ayse, cakir, weekly, row.id);
|
||
});
|
||
|
||
it("reddet: stok yenilenene kadar gizli; izleyici reddedemez", async () => {
|
||
const tablet = await typeId("tablet-deterjan");
|
||
expect((await dismissSuggestion(zeynep, cakir, tablet)).ok).toBe(false);
|
||
expect((await dismissSuggestion(ayse, cakir, tablet)).ok).toBe(true);
|
||
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||
expect(d.suggestions.some((s) => s.productTypeId === tablet)).toBe(false);
|
||
await ProductTypeModel.updateOne(
|
||
{ seedRef: "tablet-deterjan" },
|
||
{ $set: { suggestionDismissedAt: null } },
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("liste yönetimi", () => {
|
||
it("oluştur ve sil (soft delete + denetim)", async () => {
|
||
const r = await createShoppingList(ayse, cakir, { name: "Deneme", note: "" });
|
||
expect(r.ok).toBe(true);
|
||
const id = r.ok ? r.data.id : "";
|
||
expect((await deleteShoppingList(zeynep, cakir, id)).ok).toBe(false);
|
||
expect((await deleteShoppingList(ayse, cakir, id)).ok).toBe(true);
|
||
expect(await getShoppingList(ayse, cakir, id)).toBeNull();
|
||
expect(
|
||
await AuditLogModel.exists({ action: "shoppingList.delete", targetId: id }),
|
||
).toBeTruthy();
|
||
});
|
||
});
|