19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -0,0 +1,46 @@
import { test, expect } from "@odoo/hoot";
import { expectFormattedPrice, setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
import { getFilledOrderForPriceCheck } from "./utils";
definePosModels();
test("Taxes object should contain no discount values", async () => {
const store = await setupPosEnv();
const order = await getFilledOrderForPriceCheck(store);
order.lines[0].setDiscount(10);
order.lines[1].setDiscount(20);
const details = order.prices.taxDetails;
const line1 = order.lines[0].prices;
const line2 = order.lines[1].prices;
// Order prices
expect(details.base_amount).toBe(980); // Base amount is 980 = (1000 - 10%) + (100 - 20%)
expect(details.tax_amount).toBe(257); // Tax amount is 257 = (250 - 10%) + (15 - 20%) + (25 - 20%)
expect(details.total_amount).toBe(1237); // Total amount is 1237 = 980 + 257
// Formatted prices
expectFormattedPrice(order.currencyDisplayPrice, "$ 1,237.00");
expectFormattedPrice(order.currencyAmountTaxes, "$ 257.00");
expectFormattedPrice(order.lines[0].currencyDisplayPrice, "$ 1,125.00");
expectFormattedPrice(order.lines[0].currencyDisplayPriceUnit, "$ 1,125.00");
expectFormattedPrice(order.lines[0].currencyDisplayPriceUnitExcl, "$ 900.00");
expectFormattedPrice(order.lines[1].currencyDisplayPrice, "$ 112.00");
expectFormattedPrice(order.lines[1].currencyDisplayPriceUnit, "$ 112.00");
expectFormattedPrice(order.lines[1].currencyDisplayPriceUnitExcl, "$ 80.00");
// First line (25% on 1000) - no discount
expect(line1.no_discount_total_included).toBe(1250);
expect(line1.no_discount_total_excluded).toBe(1000);
expect(line1.no_discount_taxes_data[0].tax_amount).toBe(250);
expect(line1.no_discount_taxes_data[0].tax.amount).toBe(25);
// Second line (15% + 25% on 100) - no discount
expect(line2.no_discount_total_included).toBe(140);
expect(line2.no_discount_total_excluded).toBe(100);
expect(line2.no_discount_taxes_data[0].tax_amount).toBe(15);
expect(line2.no_discount_taxes_data[0].tax.amount).toBe(15);
expect(line2.no_discount_taxes_data[1].tax_amount).toBe(25);
expect(line2.no_discount_taxes_data[1].tax.amount).toBe(25);
});

View file

@ -0,0 +1,363 @@
/**
* This file contains old tour tests related to accounting that were migrated to Hoot.
* These tours were not checking anything on the Python side, so they were simply
* converted to Hoot tests without any additional checks.
*/
import { test, expect } from "@odoo/hoot";
import { setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
import { prepareRoundingVals } from "./utils";
definePosModels();
test("[Old Tour] pos_basic_order_01_multi_payment_and_change", async () => {
const store = await setupPosEnv();
const product1 = store.models["product.template"].get(15);
product1.list_price = 5.1;
product1.product_variant_ids[0].lst_price = 5.1;
product1.taxes_id = [];
const cashPm = store.models["pos.payment.method"].find((pm) => pm.is_cash_count);
const cardPm = store.models["pos.payment.method"].find((pm) => !pm.is_cash_count);
const order = store.addNewOrder();
order.pricelist_id = false;
// Add products
await store.addLineToOrder({ product_tmpl_id: product1, qty: 2 }, order);
order.addPaymentline(cashPm);
order.payment_ids[0].setAmount(5);
expect(order.remainingDue).toBe(5.2);
order.addPaymentline(cardPm);
order.payment_ids[1].setAmount(6);
expect(order.change).toBe(-0.8);
});
test("[Old Tour] PaymentScreenRoundingHalfUp", async () => {
const store = await setupPosEnv();
const product1_2 = store.models["product.template"].get(12);
const product1_25 = store.models["product.template"].get(13);
const product1_40 = store.models["product.template"].get(14);
const { cashPm } = prepareRoundingVals(store, 0.5, "HALF-UP", true);
product1_2.list_price = 1.2;
product1_2.product_variant_ids[0].lst_price = 1.2;
product1_2.taxes_id = [];
product1_25.list_price = 1.25;
product1_25.product_variant_ids[0].lst_price = 1.25;
product1_25.taxes_id = [];
product1_40.list_price = 1.4;
product1_40.product_variant_ids[0].lst_price = 1.4;
product1_40.taxes_id = [];
const order = store.addNewOrder();
order.pricelist_id = false;
await store.addLineToOrder({ product_tmpl_id: product1_2, qty: 1 }, order);
expect(order.totalDue).toBe(1.2);
order.addPaymentline(cashPm);
expect(order.amountPaid).toBe(1.0);
expect(order.appliedRounding).toBe(-0.2);
expect(order.change).toBe(0.0);
const order2 = store.addNewOrder();
order2.pricelist_id = false;
await store.addLineToOrder({ product_tmpl_id: product1_25, qty: 1 }, order2);
expect(order2.totalDue).toBe(1.25);
order2.addPaymentline(cashPm);
expect(order2.amountPaid).toBe(1.5);
expect(order2.appliedRounding).toBe(0.25);
expect(order2.change).toBe(0.0);
const order3 = store.addNewOrder();
order3.pricelist_id = false;
await store.addLineToOrder({ product_tmpl_id: product1_40, qty: 1 }, order3);
expect(order3.totalDue).toBe(1.4);
order3.addPaymentline(cashPm);
expect(order3.amountPaid).toBe(1.5);
expect(order3.appliedRounding).toBe(0.1);
expect(order3.change).toBe(0.0);
const order4 = store.addNewOrder();
order4.pricelist_id = false;
await store.addLineToOrder({ product_tmpl_id: product1_2, qty: 1 }, order4);
expect(order4.totalDue).toBe(1.2);
order4.addPaymentline(cashPm);
order4.payment_ids[0].setAmount(2);
expect(order4.amountPaid).toBe(2.0);
expect(order4.change).toBe(-1.0);
});
const prepareProduct = (store) => {
const product = store.models["product.template"].get(15);
const tax15 = store.models["account.tax"].get(1);
product.list_price = 13.67;
product.product_variant_ids[0].lst_price = 13.67;
product.taxes_id = [tax15];
return product;
};
test("[Old Tour] test_cash_rounding_halfup_add_invoice_line_not_only_round_cash_method", async () => {
const store = await setupPosEnv();
const { cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false);
const product = prepareProduct(store);
const order = store.addNewOrder();
order.pricelist_id = false;
await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order);
expect(order.displayPrice).toBe(15.72);
expect(order.priceExcl).toBe(13.67);
expect(order.totalDue).toBe(15.7);
order.addPaymentline(cardPm);
expect(order.amountPaid).toBe(15.7);
expect(order.appliedRounding).toBe(-0.02);
expect(order.change).toBe(0.0);
const order2 = store.addNewOrder();
order2.pricelist_id = false;
order.is_refund = true;
await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2);
expect(order2.displayPrice).toBe(-15.72);
expect(order2.priceExcl).toBe(-13.67);
expect(order2.totalDue).toBe(-15.7);
order2.addPaymentline(cardPm);
expect(order2.amountPaid).toBe(-15.7);
expect(order2.appliedRounding).toBe(0.02);
expect(order2.change).toBe(0.0);
});
test("[Old Tour] test_cash_rounding_halfup_add_invoice_line_not_only_round_cash_method_pay_by_bank_and_cash", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false);
const product = prepareProduct(store);
const order = store.addNewOrder();
order.pricelist_id = false;
await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order);
expect(order.displayPrice).toBe(15.72);
expect(order.priceExcl).toBe(13.67);
expect(order.totalDue).toBe(15.7);
order.addPaymentline(cardPm);
order.payment_ids[0].setAmount(0.68);
expect(order.amountPaid).toBe(0.68);
expect(order.remainingDue).toBe(15.02); // Order is rounded globaly so remaining due is rounded
order.addPaymentline(cashPm);
expect(order.payment_ids[1].amount).toBe(15.02);
expect(order.amountPaid).toBe(15.7);
expect(order.appliedRounding).toBe(-0.02);
expect(order.change).toBe(0.0);
const order2 = store.addNewOrder();
order2.pricelist_id = false;
order2.is_refund = true;
await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2);
expect(order2.displayPrice).toBe(-15.72);
expect(order2.priceExcl).toBe(-13.67);
expect(order2.totalDue).toBe(-15.7);
order2.addPaymentline(cardPm);
order2.payment_ids[0].setAmount(-0.68);
expect(order2.amountPaid).toBe(-0.68);
expect(order2.remainingDue).toBe(-15.02); // Order is rounded globaly so remaining due is rounded
order2.addPaymentline(cashPm);
expect(order2.payment_ids[1].amount).toBe(-15.02);
expect(order2.amountPaid).toBe(-15.7);
expect(order2.appliedRounding).toBe(0.02);
expect(order2.change).toBe(0.0);
});
test("[Old Tour] test_cash_rounding_down_add_invoice_line_not_only_round_cash_method_no_rounding_left", async () => {
const store = await setupPosEnv();
const { cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false);
const product = prepareProduct(store);
const order = store.addNewOrder();
order.pricelist_id = false;
await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order);
expect(order.displayPrice).toBe(15.72);
expect(order.priceExcl).toBe(13.67);
expect(order.totalDue).toBe(15.7);
order.addPaymentline(cardPm);
order.payment_ids[0].setAmount(0.67);
expect(order.amountPaid).toBe(0.67);
expect(order.remainingDue).toBe(15.03);
order.addPaymentline(cardPm);
expect(order.payment_ids[1].amount).toBe(15.03);
expect(order.amountPaid).toBe(15.7);
expect(order.appliedRounding).toBe(-0.02);
expect(order.change).toBe(0.0);
const order2 = store.addNewOrder();
order2.pricelist_id = false;
order2.is_refund = true;
await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2);
expect(order2.displayPrice).toBe(-15.72);
expect(order2.priceExcl).toBe(-13.67);
expect(order2.totalDue).toBe(-15.7);
order2.addPaymentline(cardPm);
order2.payment_ids[0].setAmount(-0.67);
expect(order2.amountPaid).toBe(-0.67);
expect(order2.remainingDue).toBe(-15.03);
order2.addPaymentline(cardPm);
expect(order2.payment_ids[1].amount).toBe(-15.03);
expect(order2.amountPaid).toBe(-15.7);
expect(order2.appliedRounding).toBe(0.02);
expect(order2.change).toBe(0.0);
});
test("[Old Tour] test_cash_rounding_halfup_add_invoice_line_only_round_cash_method", async () => {
const store = await setupPosEnv();
const { cashPm } = prepareRoundingVals(store, 0.05, "HALF-UP", true);
const product = prepareProduct(store);
const order = store.addNewOrder();
order.pricelist_id = false;
await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order);
expect(order.displayPrice).toBe(15.72);
expect(order.priceExcl).toBe(13.67);
expect(order.totalDue).toBe(15.72);
order.addPaymentline(cashPm);
expect(order.amountPaid).toBe(15.7);
expect(order.appliedRounding).toBe(-0.02);
expect(order.change).toBe(0.0);
const order2 = store.addNewOrder();
order2.pricelist_id = false;
order2.is_refund = true;
await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2);
expect(order2.displayPrice).toBe(-15.72);
expect(order2.priceExcl).toBe(-13.67);
expect(order2.totalDue).toBe(-15.72);
order2.addPaymentline(cashPm);
expect(order2.amountPaid).toBe(-15.7);
expect(order2.appliedRounding).toBe(0.02);
expect(order2.change).toBe(0.0);
});
test("[Old Tour] test_cash_rounding_halfup_add_invoice_line_only_round_cash_method_pay_by_bank_and_cash", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", true);
const product = prepareProduct(store);
const order = store.addNewOrder();
order.pricelist_id = false;
await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order);
expect(order.displayPrice).toBe(15.72);
expect(order.priceExcl).toBe(13.67);
expect(order.totalDue).toBe(15.72);
order.addPaymentline(cardPm);
order.payment_ids[0].setAmount(0.68);
expect(order.amountPaid).toBe(0.68);
expect(order.remainingDue).toBe(15.04);
order.addPaymentline(cashPm);
expect(order.payment_ids[1].amount).toBe(15.05);
expect(order.amountPaid).toBe(15.73);
expect(order.appliedRounding).toBe(0.01);
expect(order.change).toBe(0.0);
const order2 = store.addNewOrder();
order2.pricelist_id = false;
order2.is_refund = true;
await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2);
expect(order2.displayPrice).toBe(-15.72);
expect(order2.priceExcl).toBe(-13.67);
expect(order2.totalDue).toBe(-15.72);
order2.addPaymentline(cardPm);
order2.payment_ids[0].setAmount(-0.68);
expect(order2.amountPaid).toBe(-0.68);
expect(order2.remainingDue).toBe(-15.04);
order2.addPaymentline(cashPm);
expect(order2.payment_ids[1].amount).toBe(-15.05);
expect(order2.amountPaid).toBe(-15.73);
expect(order2.appliedRounding).toBe(-0.01);
expect(order2.change).toBe(0.0);
});
test("[Old Tour] test_cash_rounding_with_change", async () => {
const store = await setupPosEnv();
const { cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false);
const product = prepareProduct(store);
const order = store.addNewOrder();
order.pricelist_id = false;
await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order);
expect(order.displayPrice).toBe(15.72);
expect(order.priceExcl).toBe(13.67);
expect(order.totalDue).toBe(15.7);
order.addPaymentline(cardPm);
order.payment_ids[0].setAmount(20);
expect(order.amountPaid).toBe(20);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(-4.3);
const order2 = store.addNewOrder();
order2.pricelist_id = false;
order2.is_refund = true;
await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2);
expect(order2.displayPrice).toBe(-15.72);
expect(order2.priceExcl).toBe(-13.67);
expect(order2.totalDue).toBe(-15.7);
order2.addPaymentline(cardPm);
order2.payment_ids[0].setAmount(-20);
expect(order2.amountPaid).toBe(-20);
expect(order2.appliedRounding).toBe(0);
expect(order2.change).toBe(4.3);
});
test("[Old Tour] test_cash_rounding_only_cash_method_with_change", async () => {
const store = await setupPosEnv();
const { cashPm } = prepareRoundingVals(store, 0.05, "HALF-UP", true);
const product = prepareProduct(store);
const order = store.addNewOrder();
order.pricelist_id = false;
await store.addLineToOrder({ product_tmpl_id: product, qty: 1 }, order);
expect(order.displayPrice).toBe(15.72);
expect(order.priceExcl).toBe(13.67);
expect(order.totalDue).toBe(15.72);
order.addPaymentline(cashPm);
order.payment_ids[0].setAmount(20);
expect(order.amountPaid).toBe(20);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(-4.3);
const order2 = store.addNewOrder();
order2.pricelist_id = false;
order2.is_refund = true;
await store.addLineToOrder({ product_tmpl_id: product, qty: -1 }, order2);
expect(order2.displayPrice).toBe(-15.72);
expect(order2.priceExcl).toBe(-13.67);
expect(order2.totalDue).toBe(-15.72);
order2.addPaymentline(cashPm);
order2.payment_ids[0].setAmount(-20);
expect(order2.amountPaid).toBe(-20);
expect(order2.appliedRounding).toBe(0);
expect(order2.change).toBe(4.3);
});
test(["[Old Tour] test_cash_rounding_up_with_change"], async () => {
const store = await setupPosEnv();
const { cashPm } = prepareRoundingVals(store, 1, "UP", true);
const order = store.addNewOrder();
order.pricelist_id = false;
const tax = store.models["account.tax"].get(3);
const productA = store.models["product.template"].get(15);
const productB = store.models["product.template"].get(16);
productA.list_price = 95;
productA.product_variant_ids[0].lst_price = 95;
productA.taxes_id = [tax];
productB.list_price = 42;
productB.product_variant_ids[0].lst_price = 42;
productB.taxes_id = [tax];
await store.addLineToOrder({ product_tmpl_id: productA, qty: 1 }, order);
await store.addLineToOrder({ product_tmpl_id: productB, qty: 2 }, order);
expect(order.displayPrice).toBe(179);
expect(order.totalDue).toBe(179);
order.addPaymentline(cashPm);
order.payment_ids[0].setAmount(200);
expect(order.amountPaid).toBe(200);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(-21);
});

View file

@ -0,0 +1,82 @@
import { test, expect } from "@odoo/hoot";
import { expectFormattedPrice, setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
import { getFilledOrderForPriceCheck } from "./utils";
definePosModels();
test("Prices includes", async () => {
const store = await setupPosEnv();
const order = await getFilledOrderForPriceCheck(store);
const details = order.prices.taxDetails;
const line1 = order.lines[0].prices;
const line2 = order.lines[1].prices;
// Order prices
expect(details.base_amount).toBe(1100);
expect(details.tax_amount).toBe(290);
expect(details.total_amount).toBe(1390);
// First line (25% on 1000)
expect(line1.total_included).toBe(1250);
expect(line1.total_excluded).toBe(1000);
expect(line1.taxes_data[0].tax_amount).toBe(250);
expect(line1.taxes_data[0].tax.amount).toBe(25);
// Second line (15% + 25% on 100)
expect(line2.total_included).toBe(140);
expect(line2.total_excluded).toBe(100);
expect(line2.taxes_data[0].tax_amount).toBe(15);
expect(line2.taxes_data[0].tax.amount).toBe(15);
expect(line2.taxes_data[1].tax_amount).toBe(25);
expect(line2.taxes_data[1].tax.amount).toBe(25);
// Formatted prices
expectFormattedPrice(order.currencyDisplayPrice, "$ 1,390.00");
expectFormattedPrice(order.currencyAmountTaxes, "$ 290.00");
expectFormattedPrice(order.lines[0].currencyDisplayPrice, "$ 1,250.00");
expectFormattedPrice(order.lines[0].currencyDisplayPriceUnit, "$ 1,250.00");
expectFormattedPrice(order.lines[0].currencyDisplayPriceUnitExcl, "$ 1,000.00");
expectFormattedPrice(order.lines[1].currencyDisplayPrice, "$ 140.00");
expectFormattedPrice(order.lines[1].currencyDisplayPriceUnit, "$ 140.00");
expectFormattedPrice(order.lines[1].currencyDisplayPriceUnitExcl, "$ 100.00");
});
test("Prices excludes", async () => {
const store = await setupPosEnv();
store.config.iface_tax_included = "subtotal";
const order = await getFilledOrderForPriceCheck(store);
// Formatted prices
expectFormattedPrice(order.currencyDisplayPrice, "$ 1,100.00");
expectFormattedPrice(order.lines[0].currencyDisplayPrice, "$ 1,000.00");
expectFormattedPrice(order.lines[0].currencyDisplayPriceUnit, "$ 1,000.00");
expectFormattedPrice(order.lines[1].currencyDisplayPrice, "$ 100.00");
expectFormattedPrice(order.lines[1].currencyDisplayPriceUnit, "$ 100.00");
});
test("Combo prices incl and excl", async () => {
const store = await setupPosEnv();
const order = store.addNewOrder();
const template = store.models["product.template"].get(7);
const comboProduct = store.models["product.combo.item"].get(1);
await store.addLineToOrder(
{
product_tmpl_id: template,
payload: [[{ combo_item_id: comboProduct, qty: 1 }]],
qty: 1,
},
order
);
order.setOrderPrices();
const [comboParentLine, comboChildLine] = order.lines;
expect(comboParentLine.comboTotalPrice).toBe(3.75);
expect(comboParentLine.comboTotalPriceWithoutTax).toBe(3);
expect(comboChildLine.comboTotalPrice).toBe(3.75);
expect(comboChildLine.comboTotalPriceWithoutTax).toBe(3);
});

View file

@ -0,0 +1,150 @@
import { test, expect } from "@odoo/hoot";
import { setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
test("Pricelist: Precedence Rules (Variant > Template > Category > Global)", async () => {
const store = await setupPosEnv();
const pricelist = store.models["product.pricelist"].create({
name: "Test Pricelist",
});
const category = store.models["product.category"].create({ name: "Test Category" });
const productTemplate = store.models["product.template"].create({
name: "Test Template",
list_price: 100,
categ_id: category,
});
const product = store.models["product.product"].create({
product_tmpl_id: productTemplate,
lst_price: 100,
});
// 1. Global Rule
const globalRule = store.models["product.pricelist.item"].create({
pricelist_id: pricelist,
compute_price: "fixed",
fixed_price: 90,
});
pricelist.update({ item_ids: [globalRule] });
pricelist.computeRuleIndexes();
expect(product.getPrice(pricelist, 1, 0, false, product)).toBe(90);
// 2. Category Rule (should win over Global)
const categoryRule = store.models["product.pricelist.item"].create({
pricelist_id: pricelist,
categ_id: category,
compute_price: "fixed",
fixed_price: 80,
});
pricelist.update({ item_ids: [globalRule, categoryRule] });
pricelist.computeRuleIndexes();
expect(product.getPrice(pricelist, 1, 0, false, product)).toBe(80);
// 3. Template Rule (should win over Category)
const templateRule = store.models["product.pricelist.item"].create({
pricelist_id: pricelist,
product_tmpl_id: productTemplate,
compute_price: "fixed",
fixed_price: 70,
});
pricelist.update({ item_ids: [globalRule, categoryRule, templateRule] });
pricelist.computeRuleIndexes();
expect(product.getPrice(pricelist, 1, 0, false, product)).toBe(70);
// 4. Variant Rule (should win over Template)
const variantRule = store.models["product.pricelist.item"].create({
pricelist_id: pricelist,
product_id: product,
compute_price: "fixed",
fixed_price: 60,
});
pricelist.update({ item_ids: [globalRule, categoryRule, templateRule, variantRule] });
pricelist.computeRuleIndexes();
expect(product.getPrice(pricelist, 1, 0, false, product)).toBe(60);
});
test("Pricelist: Min Quantity logic", async () => {
const store = await setupPosEnv();
const pricelist = store.models["product.pricelist"].create({
name: "Qty Pricelist",
});
const productTemplate = store.models["product.template"].create({
name: "Qty Product",
list_price: 100,
});
const product = store.models["product.product"].create({
product_tmpl_id: productTemplate,
lst_price: 100,
});
const ruleSmall = store.models["product.pricelist.item"].create({
pricelist_id: pricelist,
product_id: product,
compute_price: "fixed",
fixed_price: 50,
min_quantity: 0,
});
const ruleLarge = store.models["product.pricelist.item"].create({
pricelist_id: pricelist,
product_id: product,
compute_price: "fixed",
fixed_price: 20,
min_quantity: 10,
});
pricelist.update({ item_ids: [ruleSmall, ruleLarge] });
pricelist.computeRuleIndexes();
// Qty 1 -> should use ruleSmall (50)
expect(product.getPrice(pricelist, 1, 0, false, product)).toBe(50);
// Qty 10 -> should use ruleLarge (20)
expect(product.getPrice(pricelist, 10, 0, false, product)).toBe(20);
// Qty 15 -> should use ruleLarge (20)
expect(product.getPrice(pricelist, 15, 0, false, product)).toBe(20);
});
test("Pricelist: Nested Pricelists (Pricelist of Pricelist)", async () => {
const store = await setupPosEnv();
// Base Pricelist: -10% discount
const basePricelist = store.models["product.pricelist"].create({
name: "Base Pricelist",
});
const baseRule = store.models["product.pricelist.item"].create({
pricelist_id: basePricelist,
compute_price: "percentage",
percent_price: 10,
base: "list_price",
});
basePricelist.update({ item_ids: [baseRule] });
basePricelist.computeRuleIndexes();
// Nested Pricelist: Base + another -5$ surcharge
const nestedPricelist = store.models["product.pricelist"].create({
name: "Nested Pricelist",
});
const nestedRule = store.models["product.pricelist.item"].create({
pricelist_id: nestedPricelist,
compute_price: "formula", // formula to use base + surcharge
base: "pricelist",
base_pricelist_id: basePricelist,
price_surcharge: 5,
});
nestedPricelist.update({ item_ids: [nestedRule] });
nestedPricelist.computeRuleIndexes();
const productTemplate = store.models["product.template"].create({
name: "Nested Test Product",
list_price: 100,
});
const product = store.models["product.product"].create({
product_tmpl_id: productTemplate,
lst_price: 100,
});
// Calculation: (100 - 10%) + 5 = 90 + 5 = 95
expect(product.getPrice(nestedPricelist, 1, 0, false, product)).toBe(95);
});

View file

@ -0,0 +1,376 @@
import { test, expect } from "@odoo/hoot";
import { setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
import { getFilledOrderForPriceCheck, prepareRoundingVals } from "./utils";
definePosModels();
test("Rounding sale HALF-UP 0.05 (cash only)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", true);
const order = await getFilledOrderForPriceCheck(store);
expect(order.displayPrice).toBe(52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(52.54);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(52.55);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0.01);
expect(order.change).toBe(0);
});
test("Rounding sale HALF-UP 0.05 (all methods)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false);
const order = await getFilledOrderForPriceCheck(store);
expect(order.displayPrice).toBe(52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(52.55);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0.01);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(52.55);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0.01);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
order.addPaymentline(cashPm);
order.payment_ids[0].setAmount(52.5);
expect(order.payment_ids[0].amount).toBe(52.5);
expect(order.appliedRounding).toBe(0);
expect(order.remainingDue).toBe(0.05);
expect(order.canBeValidated()).toBe(false);
});
test("Rounding sale UP 10 (cash only)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 10, "UP", true);
const order = await getFilledOrderForPriceCheck(store);
expect(order.displayPrice).toBe(52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(52.54);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(60);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(7.46);
expect(order.change).toBe(0);
});
test("Rounding sale UP 10 (all methods)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 10, "UP", false);
const order = await getFilledOrderForPriceCheck(store);
expect(order.displayPrice).toBe(52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(60);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(7.46);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(60);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(7.46);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(60);
order.payment_ids[0].setAmount(70);
expect(order.payment_ids[0].amount).toBe(70);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(-10);
});
test("Rounding sale DOWN 10 (all methods)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 10, "DOWN", false);
const order = await getFilledOrderForPriceCheck(store);
expect(order.displayPrice).toBe(52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(50);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(-2.54);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(50);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(-2.54);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(50);
order.payment_ids[0].setAmount(70);
expect(order.payment_ids[0].amount).toBe(70);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(-20);
});
test("Rounding sale DOWN 1 (cash only)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 1, "DOWN", true);
const order = await getFilledOrderForPriceCheck(store);
expect(order.displayPrice).toBe(52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(52.54);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(52);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(-0.54);
expect(order.change).toBe(0);
});
test("Rounding sale DOWN 1 (all methods)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 1, "DOWN", false);
const order = await getFilledOrderForPriceCheck(store);
expect(order.displayPrice).toBe(52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(52);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(-0.54);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(52);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(-0.54);
expect(order.change).toBe(0);
});
test("Rounding refund HALF-UP 0.05 (cash only)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", true);
const order = await getFilledOrderForPriceCheck(store);
order.is_refund = true;
order.lines.map((line) => line.setQuantity(-line.qty));
expect(order.displayPrice).toBe(-52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(-52.54);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(-52.55);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(-0.01);
expect(order.change).toBe(0);
});
test("Rounding refund HALF-UP 0.05 (all methods)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false);
const order = await getFilledOrderForPriceCheck(store);
order.is_refund = true;
order.lines.map((line) => line.setQuantity(-line.qty));
expect(order.displayPrice).toBe(-52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(-52.55);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(-0.01);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(-52.55);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(-0.01);
expect(order.change).toBe(0);
});
test("Rounding refund UP 10 (cash only)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 10, "UP", true);
const order = await getFilledOrderForPriceCheck(store);
order.is_refund = true;
order.lines.map((line) => line.setQuantity(-line.qty));
expect(order.displayPrice).toBe(-52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(-52.54);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(-60);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(-7.46);
expect(order.change).toBe(0);
});
test("Rounding refund UP 10 (all methods)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 10, "UP", false);
const order = await getFilledOrderForPriceCheck(store);
order.is_refund = true;
order.lines.map((line) => line.setQuantity(-line.qty));
expect(order.displayPrice).toBe(-52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(-60);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(-7.46);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(-60);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(-7.46);
expect(order.change).toBe(0);
});
test("Rounding refund DOWN 1 (cash only)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 1, "DOWN", true);
const order = await getFilledOrderForPriceCheck(store);
order.is_refund = true;
order.lines.map((line) => line.setQuantity(-line.qty));
expect(order.displayPrice).toBe(-52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(-52.54);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(-52);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0.54);
expect(order.change).toBe(0);
});
test("Rounding refund DOWN 1 (all methods)", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 1, "DOWN", false);
const order = await getFilledOrderForPriceCheck(store);
order.is_refund = true;
order.lines.map((line) => line.setQuantity(-line.qty));
expect(order.displayPrice).toBe(-52.54);
order.addPaymentline(cardPm);
expect(order.payment_ids[0].amount).toBe(-52);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0.54);
expect(order.change).toBe(0);
order.payment_ids[0].delete();
expect(order.canBeValidated()).toBe(false);
order.addPaymentline(cashPm);
expect(order.payment_ids[0].amount).toBe(-52);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0.54);
expect(order.change).toBe(0);
});
test("Rouding sale HALF-UP 0.05 with two payment method", async () => {
const store = await setupPosEnv();
const { cashPm, cardPm } = prepareRoundingVals(store, 0.05, "HALF-UP", false);
const order = await getFilledOrderForPriceCheck(store);
expect(order.displayPrice).toBe(52.54);
// only_round_cash_method is false so the order due is 52.55
order.addPaymentline(cardPm);
order.payment_ids[0].setAmount(2.54);
expect(order.payment_ids[0].amount).toBe(2.54);
expect(order.canBeValidated()).toBe(false);
expect(order.remainingDue).toBe(50.01);
order.addPaymentline(cashPm);
// Cash rounding is not applied on the cash payment line but on the order due
expect(order.payment_ids[1].amount).toBe(50.01);
expect(order.remainingDue).toBe(0);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0.01);
expect(order.change).toBe(0);
// Set only_round_cash_method to true and check that the order due is now 52.54
order.config_id.only_round_cash_method = true;
order.payment_ids = [];
order.addPaymentline(cardPm);
order.payment_ids[0].setAmount(2.54);
expect(order.payment_ids[0].amount).toBe(2.54);
expect(order.canBeValidated()).toBe(false);
expect(order.remainingDue).toBe(50);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(0);
order.addPaymentline(cashPm);
expect(order.payment_ids[1].amount).toBe(50);
expect(order.remainingDue).toBe(0);
expect(order.canBeValidated()).toBe(true);
expect(order.appliedRounding).toBe(0);
expect(order.change).toBe(0);
});

View file

@ -0,0 +1,66 @@
const { DateTime } = luxon;
/**
* We use a dedicated method for the price check because we don't want to use
* getFilledOrder in case of modification of this method breaks the price test.
*
* This method is a copy of getFilledOrder from utils.js
*/
export const getFilledOrderForPriceCheck = async (store, data = {}) => {
const order = store.addNewOrder(data);
// This product1 has a 25% tax with a 100.0 price
// This product2 has a 15% + 25% tax with a 1000.0 price
const product1 = store.models["product.template"].get(15);
const product2 = store.models["product.template"].get(16);
const date = DateTime.now();
order.write_date = date;
order.create_date = date;
order.pricelist_id = false;
await store.addLineToOrder(
{
product_tmpl_id: product1,
qty: 1,
write_date: date,
create_date: date,
},
order
);
await store.addLineToOrder(
{
product_tmpl_id: product2,
qty: 1,
write_date: date,
create_date: date,
},
order
);
store.addPendingOrder([order.id]);
return order;
};
export const prepareRoundingVals = (store, roundingAmount, roundingMethod, onlyCash = true) => {
const config = store.config;
const product1 = store.models["product.template"].get(15);
const product2 = store.models["product.template"].get(16);
const cashPm = store.models["pos.payment.method"].find((pm) => pm.is_cash_count);
const cardPm = store.models["pos.payment.method"].find((pm) => !pm.is_cash_count);
// Changes prices to have a non rounded change
product1.list_price = 15.73;
product2.list_price = 23.49;
product1.product_variant_ids[0].lst_price = 15.73;
product2.product_variant_ids[0].lst_price = 23.49;
config.cash_rounding = true;
config.only_round_cash_method = onlyCash;
config.rounding_method = store.models["account.cash.rounding"].create({
name: "roudning",
rounding: roundingAmount,
rounding_method: roundingMethod,
strategy: "add_invoice_line",
});
return { config, cashPm, cardPm };
};

View file

@ -0,0 +1,37 @@
import { test, expect, animationFrame } from "@odoo/hoot";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { OrderSummary } from "@point_of_sale/app/screens/product_screen/order_summary/order_summary";
import { setupPosEnv, getFilledOrder } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
import { queryAll, queryOne } from "@odoo/hoot-dom";
definePosModels();
test("getNewLine", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const orderSummary = await mountWithCleanup(OrderSummary, {});
order.getSelectedOrderline().uiState.savedQuantity = 5;
const newLine = orderSummary.getNewLine();
expect(newLine.order_id.id).toBe(order.id);
expect(newLine.qty).toBe(0);
});
test("Display tax include/exclude subtotal label", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
order.config.iface_tax_included = "total";
await mountWithCleanup(OrderSummary, {});
const total = queryOne(".total");
const subtotal = queryAll(".subtotal");
expect(subtotal).toHaveLength(0);
expect(total.innerHTML).toBe("$ 17.85");
order.config.iface_tax_included = "subtotal";
await animationFrame();
const total2 = queryOne(".total");
const subtotal2 = queryOne(".subtotal");
expect(total2.innerHTML).toBe("$ 17.85");
expect(subtotal2.innerHTML).toBe("$ 15.00");
});

View file

@ -0,0 +1,38 @@
import { test, expect } from "@odoo/hoot";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { Orderline } from "@point_of_sale/app/components/orderline/orderline";
import { expectFormattedPrice, setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
test("orderline.js", async () => {
const store = await setupPosEnv();
const order = store.addNewOrder();
const product = store.models["product.template"].get(5);
const line = await store.addLineToOrder(
{
product_tmpl_id: product,
qty: 3,
note: '[{"text":"Test 1","colorIndex":0},{"text":"Test 2","colorIndex":0}]',
},
order
);
const comp = await mountWithCleanup(Orderline, {
props: { line },
});
const lineData = comp.lineScreenValues;
expect(comp.line.id).toEqual(line.id);
expectFormattedPrice(comp.line.currencyDisplayPrice, "$ 10.35");
expect(lineData.internalNote).toEqual([
{
text: "Test 1",
colorIndex: 0,
},
{
text: "Test 2",
colorIndex: 0,
},
]);
});

View file

@ -0,0 +1,54 @@
import { expect, test } from "@odoo/hoot";
import { setupPosEnv, dialogActions } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { click } from "@odoo/hoot-dom";
import { InternalNoteButton } from "@point_of_sale/app/screens/product_screen/control_buttons/orderline_note_button/orderline_note_button";
import { OrderSummary } from "@point_of_sale/app/screens/product_screen/order_summary/order_summary";
definePosModels();
test("orderline_note_button.js", async () => {
const store = await setupPosEnv();
const order = store.addNewOrder();
const productTmplCombo = store.models["product.template"].get(7);
const productComboSteps = [
() => click("#article_product_8"), // Wood Chair 1/2
() => click("#article_product_8"), // Wood Chair 2/2
() => click("#article_product_10"), // Wood desk
() => click(".confirm"), // Confirm combo configuration
];
const lineAction = async () =>
await store.addLineToCurrentOrder({
product_tmpl_id: productTmplCombo,
qty: 1,
});
const line = await dialogActions(lineAction, productComboSteps);
expect(order.lines[0].qty).toBe(1);
expect(order.lines[1].qty).toBe(1);
expect(order.lines[2].qty).toBe(2);
const orderSummary = await mountWithCleanup(OrderSummary, { props: {} });
orderSummary._setValue(4);
expect(order.lines[0].qty).toBe(4);
expect(order.lines[1].qty).toBe(4);
expect(order.lines[2].qty).toBe(8);
const comp = await mountWithCleanup(InternalNoteButton, { props: { label: "" } });
await comp.setChanges(line, '[{"1":"Test","colorIndex":0}]');
order.updateLastOrderChange();
orderSummary._setValue(9);
const noteAction = async () => await comp.setChanges(line, '[{"2":"Test","colorIndex":0}]');
await dialogActions(noteAction, productComboSteps);
// Check quantity
expect(order.lines[0].qty).toBe(4);
expect(order.lines[1].qty).toBe(4);
expect(order.lines[2].qty).toBe(8);
expect(order.lines[3].qty).toBe(5);
expect(order.lines[4].qty).toBe(5);
expect(order.lines[5].qty).toBe(10);
// Check notes (only on parent lines)
expect(order.lines[0].note).toBe('[{"1":"Test","colorIndex":0}]');
expect(order.lines[3].note).toBe('[{"2":"Test","colorIndex":0}]');
});

View file

@ -0,0 +1,27 @@
import { test, animationFrame } from "@odoo/hoot";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { setupPosEnv, getFilledOrder, expectFormattedPrice } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
import { queryOne } from "@odoo/hoot-dom";
import { PaymentScreen } from "@point_of_sale/app/screens/payment_screen/payment_screen";
definePosModels();
test("Change always incl", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const firstPm = store.models["pos.payment.method"].getFirst();
order.config.iface_tax_included = "total";
const comp = await mountWithCleanup(PaymentScreen, {
props: { orderUuid: order.uuid },
});
await comp.addNewPaymentLine(firstPm);
order.payment_ids[0].setAmount(20);
await animationFrame();
const total = queryOne(".amount");
expectFormattedPrice(total.attributes.amount.value, "$ -2.15");
order.config.iface_tax_included = "subtotal";
await animationFrame();
const subtotal = queryOne(".amount");
expectFormattedPrice(subtotal.attributes.amount.value, "$ -2.15");
});

View file

@ -0,0 +1,43 @@
import { test, expect } from "@odoo/hoot";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { setupPosEnv } from "../utils";
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
test("_getProductByBarcode", async () => {
const store = await setupPosEnv();
store.addNewOrder();
const order = store.getOrder();
const comp = await mountWithCleanup(ProductScreen, { props: { orderUuid: order.uuid } });
await comp.addProductToOrder(store.models["product.template"].get(5));
expect(order.displayPrice).toBe(3.45);
expect(comp.total).toBe("$\u00a03.45");
expect(comp.items).toBe("1");
const productByBarcode = await comp._getProductByBarcode({ base_code: "test_test" });
expect(productByBarcode.id).toEqual(5);
});
test("fastValidate", async () => {
const store = await setupPosEnv();
store.addNewOrder();
const order = store.getOrder();
const fastPaymentMethod = order.config.fast_payment_method_ids[0];
const productScreen = await mountWithCleanup(ProductScreen, {
props: { orderUuid: order.uuid },
});
await productScreen.addProductToOrder(store.models["product.template"].get(5));
expect(order.displayPrice).toBe(3.45);
expect(productScreen.total).toBe("$\u00a03.45");
expect(productScreen.items).toBe("1");
await productScreen.fastValidate(fastPaymentMethod);
expect(order.payment_ids[0].payment_method_id).toEqual(fastPaymentMethod);
expect(order.state).toBe("paid");
expect(order.amount_paid).toBe(3.45);
});

View file

@ -0,0 +1,22 @@
import { test, expect } from "@odoo/hoot";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { setupPosEnv, getFilledOrder } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
import { queryOne } from "@odoo/hoot-dom";
import { ReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/receipt_screen";
definePosModels();
test("Total on receipt always incl", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
order.config.iface_tax_included = "total";
await mountWithCleanup(ReceiptScreen, {
props: { orderUuid: order.uuid },
});
const total = queryOne(".pos-receipt-amount .pos-receipt-right-align");
expect(total.innerHTML).toBe("$ 17.85");
order.config.iface_tax_included = "subtotal";
const subtotal = queryOne(".pos-receipt-amount .pos-receipt-right-align");
expect(subtotal.innerHTML).toBe("$ 17.85");
});

View file

@ -0,0 +1,36 @@
import { test, expect } from "@odoo/hoot";
import { getFilledOrder, setupPosEnv, expectFormattedPrice } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
import { CustomerDisplayPosAdapter } from "@point_of_sale/app/customer_display/customer_display_adapter";
definePosModels();
test("getOrderlineData", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const adapter = new CustomerDisplayPosAdapter();
adapter.formatOrderData(order);
expect(adapter.data.lines).toHaveLength(2);
expect(adapter.data.lines[0].isSelected).toBe(false);
expect(adapter.data.lines[1].isSelected).toBe(true);
});
test("order amounts summary", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const adapter = new CustomerDisplayPosAdapter();
adapter.formatOrderData(order);
expectFormattedPrice(adapter.data.amount, "$ 17.85");
expectFormattedPrice(adapter.data.amountTaxes, "$ 2.85");
expect(adapter.data.subtotal).toBe(false);
store.config.iface_tax_included = "subtotal";
adapter.formatOrderData(order);
expectFormattedPrice(adapter.data.amount, "$ 17.85");
expectFormattedPrice(adapter.data.amountTaxes, "$ 2.85");
expectFormattedPrice(adapter.data.subtotal, "$ 15.00");
});

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class AccountCashRounding extends models.ServerModel {
_name = "account.cash.rounding";
_load_pos_data_fields() {
return [];
}
}

View file

@ -0,0 +1,23 @@
import { models } from "@web/../tests/web_test_helpers";
export class AccountFiscalPosition extends models.ServerModel {
_name = "account.fiscal.position";
_load_pos_data_fields() {
return ["id", "name", "display_name", "tax_map", "tax_ids"];
}
_records = [
{
id: 1,
name: "Domestic",
display_name: "Domestic",
tax_ids: [1],
},
{
id: 2,
name: "No tax fp",
display_name: "No tax fp",
},
];
}

View file

@ -0,0 +1,19 @@
import { models } from "@web/../tests/web_test_helpers";
export class AccountJournal extends models.ServerModel {
_name = "account.journal";
_load_pos_data_fields() {
return [];
}
_records = [
{
id: 1,
name: "Point of Sale",
type: "sale",
code: "POS",
company_id: 250,
},
];
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class AccountMove extends models.ServerModel {
_name = "account.move";
_load_pos_data_fields() {
return ["id"];
}
}

View file

@ -0,0 +1,81 @@
import { models } from "@web/../tests/web_test_helpers";
export class AccountTax extends models.ServerModel {
_name = "account.tax";
_load_pos_data_fields() {
return [
"id",
"name",
"price_include",
"include_base_amount",
"is_base_affected",
"has_negative_factor",
"amount_type",
"children_tax_ids",
"amount",
"company_id",
"id",
"sequence",
"tax_group_id",
];
}
_records = [
{
id: 1,
name: "15%",
price_include: false,
include_base_amount: false,
is_base_affected: true,
has_negative_factor: false,
amount_type: "percent",
children_tax_ids: [],
amount: 15.0,
company_id: 250,
sequence: 1,
tax_group_id: 1,
},
{
id: 2,
name: "25%",
price_include: false,
include_base_amount: false,
is_base_affected: true,
has_negative_factor: false,
amount_type: "percent",
children_tax_ids: [],
amount: 25.0,
company_id: 250,
sequence: 1,
tax_group_id: 3,
},
{
id: 3,
name: "tax incl",
type_tax_use: "sale",
amount_type: "percent",
amount: 7,
price_include_override: "tax_included",
include_base_amount: true,
has_negative_factor: true,
company_id: 250,
is_base_affected: true,
tax_group_id: 4,
},
{
id: 4,
name: "15% incl",
type_tax_use: "sale",
amount_type: "percent",
amount: 15,
price_include_override: "tax_included",
price_include: true,
include_base_amount: true,
has_negative_factor: false,
company_id: 250,
is_base_affected: true,
tax_group_id: 5,
},
];
}

View file

@ -0,0 +1,37 @@
import { models } from "@web/../tests/web_test_helpers";
export class AccountTaxGroup extends models.ServerModel {
_name = "account.tax.group";
_load_pos_data_fields() {
return ["id", "name", "pos_receipt_label"];
}
_records = [
{
id: 1,
name: "Tax 15%",
pos_receipt_label: false,
},
{
id: 2,
name: "Tax 0%",
pos_receipt_label: false,
},
{
id: 3,
name: "Tax 25%",
pos_receipt_label: false,
},
{
id: 4,
name: "No group",
pos_receipt_label: false,
},
{
id: 5,
name: "15% incl",
pos_receipt_label: false,
},
];
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class BarcodeNomenclature extends models.ServerModel {
_name = "barcode.nomenclature";
_load_pos_data_fields() {
return [];
}
}

View file

@ -0,0 +1,47 @@
import { models } from "@web/../tests/web_test_helpers";
export class DecimalPrecision extends models.ServerModel {
_name = "decimal.precision";
_load_pos_data_fields() {
return ["id", "name", "digits"];
}
_records = [
{
id: 1,
name: "Product Unit",
digits: 2,
},
{
id: 2,
name: "Percentage Analytic",
digits: 2,
},
{
id: 3,
name: "Product Price",
digits: 2,
},
{
id: 4,
name: "Discount",
digits: 2,
},
{
id: 5,
name: "Stock Weight",
digits: 2,
},
{
id: 6,
name: "Volume",
digits: 2,
},
{
id: 7,
name: "Payment Terms",
digits: 6,
},
];
}

View file

@ -0,0 +1,114 @@
import { defineModels, onRpc } from "@web/../tests/web_test_helpers";
import { mailModels } from "@mail/../tests/mail_test_helpers";
import { ResourceCalendar } from "./resource_calendar.data";
import { AccountMove } from "./account_move.data";
import { PosSession } from "./pos_session.data";
import { PosConfig } from "./pos_config.data";
import { PosPreset } from "./pos_preset.data";
import { ResourceCalendarAttendance } from "./resource_calendar_attendance";
import { PosOrder } from "./pos_order.data";
import { PosOrderLine } from "./pos_order_line.data";
import { PosPackOperationLot } from "./pos_pack_operation_lot.data";
import { PosPayment } from "./pos_payment.data";
import { PosPaymentMethod } from "./pos_payment_method.data";
import { PosPrinter } from "./pos_printer.data";
import { PosCategory } from "./pos_category.data";
import { PosBill } from "./pos_bill.data";
import { ResCompany } from "./res_company.data";
import { AccountTax } from "./account_tax.data";
import { AccountTaxGroup } from "./account_tax_group.data";
import { ProductTemplate } from "./product_template.data";
import { ProductProduct } from "./product_product.data";
import { ProductAttribute } from "./product_attribute.data";
import { ProductAttributeCustomValue } from "./product_attribute_custom_value.data";
import { ProductTemplateAttributeLine } from "./product_template_attribute_line.data";
import { ProductTemplateAttributeValue } from "./product_template_attribute_value.data";
import { ProductTemplateAttributeExclusion } from "./product_template_attribute_exclusion.data";
import { ProductCombo } from "./product_combo.data";
import { ProductComboItem } from "./product_combo_item.data";
import { ResUsers } from "./res_users.data";
import { ResPartner } from "./res_partner.data";
import { ProductUom } from "./product_uom.data";
import { DecimalPrecision } from "./decimal_precision.data";
import { UomUom } from "./uom_uom.data";
import { ResCountry } from "./res_country.data";
import { ResCountryState } from "./res_country_state.data";
import { ResLang } from "./res_lang.data";
import { ProductCategory } from "./product_category.data";
import { ProductPricelist } from "./product_pricelist.data";
import { ProductPricelistItem } from "./product_pricelist_item.data";
import { AccountCashRounding } from "./account_cash_rounding.data";
import { AccountFiscalPosition } from "./account_fiscal_position.data";
import { StockPickingType } from "./stock_picking_type.data";
import { ResCurrency } from "./res_currency.data";
import { PosNote } from "./pos_note.data";
import { ProductTag } from "./product_tag.data";
import { IrModuleModule } from "./ir_module_module.data";
import { AccountJournal } from "./account_journal.data";
import { IrSequence } from "./ir_sequence.data";
import { StockWarehouse } from "./stock_warehouse.data";
import { StockRoute } from "./stock_route.data";
import { BarcodeNomenclature } from "./barcode_nomenclature.data";
import { ProductAttributeValue } from "./product_attribute_value.data";
export const hootPosModels = [
ResCountry,
ResCountryState,
ResCurrency,
ResCompany,
ResPartner,
ResUsers,
ResLang,
PosSession,
PosConfig,
PosPreset,
ResourceCalendarAttendance,
PosOrder,
PosOrderLine,
PosPackOperationLot,
PosPayment,
PosPaymentMethod,
PosPrinter,
PosCategory,
PosBill,
AccountTax,
AccountTaxGroup,
AccountMove,
ProductCategory,
ProductTemplate,
ProductProduct,
ProductAttribute,
ProductAttributeValue,
ProductAttributeCustomValue,
ProductTemplateAttributeLine,
ProductTemplateAttributeValue,
ProductTemplateAttributeExclusion,
ProductCombo,
ProductComboItem,
ProductUom,
ProductTag,
ProductPricelist,
ProductPricelistItem,
DecimalPrecision,
StockWarehouse,
StockRoute,
UomUom,
AccountCashRounding,
AccountFiscalPosition,
StockPickingType,
IrSequence,
PosNote,
IrModuleModule,
AccountJournal,
ResourceCalendar,
BarcodeNomenclature,
];
export const definePosModels = () => {
const posModelNames = hootPosModels.map((modelClass) => modelClass.prototype.constructor._name);
const modelsFromMail = Object.values(mailModels).filter(
(modelClass) => !posModelNames.includes(modelClass.prototype.constructor._name)
);
onRpc("/pos/ping", () => {});
defineModels([...modelsFromMail, ...hootPosModels]);
};

View file

@ -0,0 +1,42 @@
import { registry } from "@web/core/registry";
import { createRelatedModels } from "@point_of_sale/app/models/related_models";
import { DataServiceOptions } from "@point_of_sale/app/models/data_service_options";
import { MockServer } from "@web/../tests/web_test_helpers";
export const getModelDefinitions = () => {
const session = MockServer.current._models["pos.session"];
const params = session.load_data_params();
return Object.entries(params).reduce((acc, [modelName, params]) => {
acc[modelName] = params.relations;
return acc;
}, {});
};
let generatedModels = null;
export const getRelatedModelsInstance = (useModelClass = true) => {
if (generatedModels) {
return generatedModels;
}
const options = new DataServiceOptions();
const relations = getModelDefinitions();
const modelClasses = {};
if (useModelClass) {
for (const posModel of registry.category("pos_available_models").getAll()) {
const pythonModel = posModel.pythonModel;
const extraFields = posModel.extraFields || {};
modelClasses[pythonModel] = posModel;
relations[pythonModel] = {
...relations[pythonModel],
...extraFields,
};
}
}
const models = createRelatedModels(relations, useModelClass ? modelClasses : {}, options);
generatedModels = models.models;
return models.models;
};

View file

@ -0,0 +1,17 @@
import { models } from "@web/../tests/web_test_helpers";
export class IrModuleModule extends models.ServerModel {
_name = "ir.module.module";
_load_pos_data_fields() {
return ["id", "name", "state"];
}
_records = [
{
id: 901,
name: "pos_settle_due",
state: "installed",
},
];
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class IrSequence extends models.ServerModel {
_name = "ir.sequence";
_load_pos_data_fields() {
return [];
}
}

View file

@ -0,0 +1,77 @@
import { models } from "@web/../tests/web_test_helpers";
export class PosBill extends models.ServerModel {
_name = "pos.bill";
_load_pos_data_fields() {
return ["id", "name", "value"];
}
_records = [
{
id: 1,
name: "0.05",
value: 0.05,
},
{
id: 2,
name: "0.10",
value: 0.1,
},
{
id: 3,
name: "0.20",
value: 0.2,
},
{
id: 4,
name: "0.25",
value: 0.25,
},
{
id: 5,
name: "0.50",
value: 0.5,
},
{
id: 6,
name: "1.00",
value: 1.0,
},
{
id: 7,
name: "2.00",
value: 2.0,
},
{
id: 8,
name: "5.00",
value: 5.0,
},
{
id: 9,
name: "10.00",
value: 10.0,
},
{
id: 10,
name: "20.00",
value: 20.0,
},
{
id: 11,
name: "50.00",
value: 50.0,
},
{
id: 12,
name: "100.00",
value: 100.0,
},
{
id: 13,
name: "200.00",
value: 200.0,
},
];
}

View file

@ -0,0 +1,79 @@
import { models } from "@web/../tests/web_test_helpers";
export class PosCategory extends models.ServerModel {
_name = "pos.category";
_load_pos_data_fields() {
return [
"id",
"name",
"parent_id",
"child_ids",
"write_date",
"has_image",
"color",
"sequence",
"hour_until",
"hour_after",
];
}
_records = [
{
id: 1,
name: "Category 1",
parent_id: false,
child_ids: [],
has_image: false,
color: 0,
sequence: 1,
hour_until: 0.0,
hour_after: 24.0,
},
{
id: 2,
name: "Category 2",
parent_id: false,
child_ids: [],
has_image: false,
color: 0,
sequence: 2,
hour_until: 0.0,
hour_after: 24.0,
},
{
id: 3,
name: "Food",
parent_id: false,
child_ids: [4, 5],
has_image: false,
color: 0,
sequence: 4,
hour_until: 0.0,
hour_after: 24.0,
},
{
id: 4,
name: "Burger",
parent_id: 3,
child_ids: [],
has_image: false,
color: 2,
sequence: 3,
hour_until: 0.0,
hour_after: 24.0,
},
{
id: 5,
name: "Pizza",
parent_id: 3,
child_ids: [],
has_image: false,
color: 3,
sequence: 5,
hour_until: 0.0,
hour_after: 24.0,
},
];
}

View file

@ -0,0 +1,153 @@
import { models } from "@web/../tests/web_test_helpers";
export class PosConfig extends models.ServerModel {
_name = "pos.config";
notify_synchronisation(session_id, device_identifier, records = {}) {
return true;
}
_load_pos_data_fields() {
return [];
}
register_new_device_identifier(self) {
return {
device_identifier: 1,
};
}
read_config_open_orders(configId) {
// We can read everything since its only related to the current test.
const orderIds = this.env["pos.order"].search_read([], ["id"]).map((order) => order.id);
return {
deleted_record_ids: {},
dynamic_records: {
...this.env["pos.order"].read_pos_data(orderIds, [], configId),
},
};
}
get_next_order_refs(device_identifier = 0) {
const sequence_num = ++this._orderRef;
const now = new Date();
const YY = now.getFullYear().toString().slice(-2);
const LL = String(device_identifier % 100);
const SSS = String(this.id);
const F = 0;
const OOOO = String(sequence_num).padStart(6, "0");
const order_ref = `${YY}${LL}-${SSS}-${F}${OOOO}`;
return [order_ref, sequence_num, String(sequence_num).padStart(3, "0")];
}
_load_pos_data_read(data) {
data[0]["_partner_commercial_fields"] = [];
data[0]["_server_version"] = "18.3+e";
data[0]["_base_url"] = "http://localhost:4444";
data[0]["_data_server_date"] = "2025-07-03 12:40:15";
data[0]["_has_cash_move_perm"] = true;
data[0]["_has_available_products"] = true;
data[0]["_pos_special_products_ids"] = [];
return data;
}
_records = [
{
id: 1,
display_name: "Hoot",
access_token: "test_access_token",
name: "Hoot",
printer_ids: [1],
is_order_printer: true,
is_installed_account_accountant: true,
picking_type_id: 9,
journal_id: 1,
invoice_journal_id: 1,
currency_id: 1,
iface_cashdrawer: false,
iface_electronic_scale: false,
iface_print_via_proxy: false,
iface_scan_via_proxy: false,
iface_big_scrollbars: false,
iface_print_auto: false,
iface_print_skip_screen: true,
iface_tax_included: "total",
iface_available_categ_ids: [],
customer_display_bg_img: false,
customer_display_bg_img_name: false,
restrict_price_control: false,
is_margins_costs_accessible_to_every_user: false,
cash_control: true,
set_maximum_difference: false,
receipt_header: false,
receipt_footer: false,
basic_receipt: false,
proxy_ip: false,
active: true,
uuid: "6f9034bb-faf8-4875-b216-dafb78982918",
session_ids: [1],
current_session_id: 1,
current_session_state: "opening_control",
number_of_rescue_session: 0,
last_session_closing_cash: 0.0,
last_session_closing_date: false,
pos_session_username: "Administrator",
pos_session_state: "opening_control",
pos_session_duration: "0",
pricelist_id: false,
available_pricelist_ids: [1],
company_id: 250,
group_pos_manager_id: false,
group_pos_user_id: false,
iface_tipproduct: false,
tip_product_id: 1,
fiscal_position_ids: [],
default_fiscal_position_id: false,
default_bill_ids: [],
use_pricelist: true,
use_presets: true,
default_preset_id: 1,
available_preset_ids: [1, 2],
tax_regime_selection: false,
limit_categories: false,
module_pos_restaurant: false,
module_pos_avatax: false,
module_pos_discount: false,
module_pos_appointment: false,
is_posbox: false,
is_header_or_footer: false,
module_pos_hr: false,
amount_authorized_diff: 0.0,
payment_method_ids: [2, 1],
company_has_template: true,
current_user_id: 2,
other_devices: false,
rounding_method: false,
cash_rounding: false,
only_round_cash_method: false,
has_active_session: true,
manual_discount: true,
ship_later: false,
warehouse_id: false,
route_id: false,
picking_policy: "direct",
auto_validate_terminal_payment: true,
trusted_config_ids: [],
show_product_images: true,
show_category_images: true,
note_ids: [],
module_pos_sms: false,
is_closing_entry_by_product: false,
order_edit_tracking: false,
last_data_change: "2025-07-03 14:35:55",
fallback_nomenclature_id: false,
create_date: "2025-07-03 12:40:00",
write_date: "2025-07-03 14:35:55",
epson_printer_ip: false,
use_fast_payment: true,
fast_payment_method_ids: [1],
},
];
}

View file

@ -0,0 +1,32 @@
import { models } from "@web/../tests/web_test_helpers";
export class PosNote extends models.ServerModel {
_name = "pos.note";
_load_pos_data_fields() {
return ["name", "color"];
}
_records = [
{
id: 1,
name: "Wait",
color: 0,
},
{
id: 2,
name: "To Serve",
color: 0,
},
{
id: 3,
name: "Emergency",
color: 0,
},
{
id: 4,
name: "No Dressing",
color: 0,
},
];
}

View file

@ -0,0 +1,144 @@
import { models, Command } from "@web/../tests/web_test_helpers";
export class PosOrder extends models.ServerModel {
_name = "pos.order";
get_preparation_change(id) {
const read = this.read([id]);
const changes = read[0]?.last_order_preparation_change || "{}";
return {
last_order_preparation_change: changes,
};
}
read_pos_orders(domain) {
const results = this.search(domain, this._load_pos_data_fields(), false);
return this.read_pos_data(results);
}
_load_pos_data_fields() {
return [];
}
action_pos_order_cancel(self) {
const records = this.browse(self);
const orderIds = [];
for (const record of records) {
this.write([record.id], { state: "cancel" });
orderIds.push(record.id);
}
return {
"pos.order": this.read(orderIds, this._load_pos_data_fields(), false),
};
}
create() {
const orderId = super.create(...arguments);
this.write([orderId], { pos_reference: "000-0-000000" });
return orderId;
}
sync_from_ui(data) {
const orderIds = [];
for (const record of data) {
const record_uuid_mapping = record.relations_uuid_mapping || {};
delete record.relations_uuid_mapping;
if (record.id) {
this.write([record.id], record);
orderIds.push(record.id);
} else {
const id = this.create(record);
orderIds.push(id);
}
for (const [modelName, mapping] of Object.entries(record_uuid_mapping)) {
// Search for owner records by UUID
const ownerRecords = this.env[modelName].search_read(
[["uuid", "in", Object.keys(mapping)]],
["id", "uuid"]
);
for (const [uuid, fields] of Object.entries(mapping)) {
for (const [name, uuids] of Object.entries(fields)) {
const field = this.env[modelName]._fields[name];
if (["one2many", "many2many"].includes(field.type)) {
// Get all related records by uuids
const relatedRecords = this.env[field.relation].search_read(
[["uuid", "in", uuids]],
["id", "uuid"]
);
const ownerRecord = ownerRecords.find((r) => r.uuid === uuid);
if (ownerRecord) {
this.env[modelName].write([ownerRecord.id], {
[name]: relatedRecords.map((r) => Command.link(r.id)),
});
}
} else {
// single record relation (many2one)
const record = this.env[field.relation].search([["uuid", "=", uuids]]);
const ownerRecord = ownerRecords.find((r) => r.uuid === uuid);
if (ownerRecord && record) {
this.env[modelName].write([ownerRecord.id], { [name]: record[0] });
}
}
}
}
}
}
return this.read_pos_data(orderIds, data, this.config_id);
}
read_pos_data(orderIds, data, config_id) {
const posOrder = [];
const posSession = [];
const posPayment = [];
const posOrderLine = [];
const posPackOperationLot = [];
const posCustomAttributeValue = [];
const readOrder = this.read(orderIds, this._load_pos_data_fields(config_id), false);
for (const order of readOrder) {
posOrder.push(order);
const lines = this.env["pos.order.line"].read(
order.lines,
this.env["pos.order.line"]._load_pos_data_fields(config_id),
false
);
const payments = this.env["pos.payment"].read(
order.payment_ids,
this.env["pos.payment"]._load_pos_data_fields(config_id),
false
);
const packLotLineIds = lines.flatMap((line) => line.pack_lot_ids);
const packLotLines = this.env["pos.pack.operation.lot"].read(
packLotLineIds,
this.env["pos.pack.operation.lot"]._load_pos_data_fields(config_id),
false
);
const customAttributeValueIds = lines.flatMap(
(line) => line.custom_attribute_value_ids
);
const customAttributeValues = this.env["product.attribute.custom.value"].read(
customAttributeValueIds,
this.env["product.attribute.custom.value"]._load_pos_data_fields(config_id),
false
);
posOrderLine.push(...lines);
posPayment.push(...payments);
posPackOperationLot.push(...packLotLines);
posCustomAttributeValue.push(...customAttributeValues);
}
return {
"pos.order": posOrder,
"pos.session": posSession,
"pos.payment": posPayment,
"pos.order.line": posOrderLine,
"pos.pack.operation.lot": posPackOperationLot,
"product.attribute.custom.value": posCustomAttributeValue,
};
}
}

View file

@ -0,0 +1,35 @@
import { models } from "@web/../tests/web_test_helpers";
export class PosOrderLine extends models.ServerModel {
_name = "pos.order.line";
_load_pos_data_fields() {
return [
"qty",
"attribute_value_ids",
"custom_attribute_value_ids",
"price_unit",
"uuid",
"price_subtotal",
"price_subtotal_incl",
"order_id",
"note",
"price_type",
"product_id",
"discount",
"tax_ids",
"pack_lot_ids",
"customer_note",
"refunded_qty",
"price_extra",
"full_product_name",
"refunded_orderline_id",
"combo_parent_id",
"combo_line_ids",
"combo_item_id",
"refund_orderline_ids",
"extra_tax_data",
"write_date",
];
}
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class PosPackOperationLot extends models.ServerModel {
_name = "pos.pack.operation.lot";
_load_pos_data_fields() {
return ["lot_name", "pos_order_line_id", "write_date"];
}
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class PosPayment extends models.ServerModel {
_name = "pos.payment";
_load_pos_data_fields() {
return [];
}
}

View file

@ -0,0 +1,59 @@
import { models } from "@web/../tests/web_test_helpers";
export class PosPaymentMethod extends models.ServerModel {
_name = "pos.payment.method";
_load_pos_data_fields() {
return [
"id",
"name",
"is_cash_count",
"use_payment_terminal",
"split_transactions",
"type",
"image",
"sequence",
"payment_method_type",
"default_qr",
];
}
_records = [
{
id: 2,
name: "Card",
is_cash_count: false,
use_payment_terminal: false,
split_transactions: false,
type: "bank",
image: false,
sequence: 1,
payment_method_type: "none",
default_qr: false,
},
{
id: 3,
name: "Customer Account",
is_cash_count: false,
use_payment_terminal: false,
split_transactions: true,
type: "pay_later",
image: false,
sequence: 2,
payment_method_type: "none",
default_qr: false,
},
{
id: 1,
name: "Cash",
is_cash_count: true,
use_payment_terminal: false,
split_transactions: false,
type: "cash",
image: false,
sequence: 0,
payment_method_type: "none",
default_qr: false,
},
];
}

View file

@ -0,0 +1,69 @@
import { models } from "@web/../tests/web_test_helpers";
export class PosPreset extends models.ServerModel {
_name = "pos.preset";
_load_pos_data_fields() {
return [
"id",
"name",
"pricelist_id",
"fiscal_position_id",
"is_return",
"color",
"has_image",
"write_date",
"identification",
"use_timing",
"slots_per_interval",
"interval_time",
"attendance_ids",
];
}
_records = [
{
id: 1,
name: "In",
pricelist_id: 1,
fiscal_position_id: 1,
is_return: false,
color: 0,
has_image: false,
write_date: "2025-07-03 14:34:01",
identification: "none",
use_timing: false,
slots_per_interval: 5,
interval_time: 20,
attendance_ids: [],
},
{
id: 2,
name: "Out",
pricelist_id: false,
fiscal_position_id: false,
is_return: false,
color: 0,
has_image: false,
write_date: "2025-07-03 14:34:07",
identification: "none",
use_timing: true,
slots_per_interval: 5,
interval_time: 20,
attendance_ids: [],
resource_calendar_id: 1,
},
{
id: 3,
name: "Name Required Preset",
identification: "name",
use_timing: false,
},
{
id: 4,
name: "Address Required Preset",
identification: "address",
use_timing: false,
},
];
}

View file

@ -0,0 +1,19 @@
import { models } from "@web/../tests/web_test_helpers";
export class PosPrinter extends models.ServerModel {
_name = "pos.printer";
_load_pos_data_fields() {
return ["id", "name", "proxy_ip", "product_categories_ids", "printer_type"];
}
_records = [
{
id: 1,
name: "Printer",
proxy_ip: false,
product_categories_ids: [1, 2],
printer_type: "epson_epos",
},
];
}

View file

@ -0,0 +1,175 @@
import { MockServer, models } from "@web/../tests/web_test_helpers";
export class PosSession extends models.ServerModel {
_name = "pos.session";
_orderRef = 1;
_load_pos_data_models(config_id) {
return [
"pos.session",
"pos.config",
"pos.preset",
"resource.calendar.attendance",
"pos.order",
"pos.order.line",
"pos.pack.operation.lot",
"pos.payment",
"pos.payment.method",
"pos.printer",
"pos.category",
"pos.bill",
"res.company",
"account.tax",
"account.tax.group",
"product.template",
"product.product",
"product.attribute",
"product.attribute.custom.value",
"product.template.attribute.line",
"product.template.attribute.value",
"product.template.attribute.exclusion",
"product.combo",
"product.combo.item",
"res.users",
"res.partner",
"product.uom",
"decimal.precision",
"uom.uom",
"res.country",
"res.country.state",
"res.lang",
"product.category",
"product.pricelist",
"product.pricelist.item",
"account.cash.rounding",
"account.fiscal.position",
"stock.picking.type",
"res.currency",
"pos.note",
"product.tag",
"ir.module.module",
];
}
_load_pos_data_fields() {
return [
"id",
"name",
"user_id",
"config_id",
"start_at",
"stop_at",
"payment_method_ids",
"state",
"update_stock_at_closing",
"cash_register_balance_start",
"access_token",
];
}
// These methods are designed to be overridden to customize the POS data loading behavior using the provided `opts`.
getModelsToLoad(opts) {
return this._load_pos_data_models();
}
getModelFieldsToLoad(model, opts) {
return model._load_pos_data_fields();
}
processPosReadData(model, records, opts) {
return (model._load_pos_data_read && model._load_pos_data_read(records)) || records;
}
load_data_params(opts = {}) {
const modelToLoad = this.getModelsToLoad(opts);
const response = modelToLoad.reduce((acc, modelName) => {
acc[modelName] = {
fields: {},
relations: {},
};
return acc;
}, {});
for (const model of modelToLoad) {
const serverModel = MockServer.env[model];
const posFields = this.getModelFieldsToLoad(serverModel, opts);
const allFields = serverModel.fields_get();
const base = posFields.length ? posFields : Object.keys(allFields);
if (!base.includes("id")) {
base.push("id");
}
for (const fieldName of base) {
const field = allFields[fieldName];
if (!field) {
console.debug(`Field ${fieldName} not found in model ${model}`);
continue;
}
response[model]["relations"][fieldName] = {
name: fieldName,
model: model,
compute: Boolean(field.compute),
related: Boolean(field.related),
type: field.type,
relation: field.relation,
inverse_name: field.inverse_fname_by_model_name?.[field.relation] || false,
};
}
response[model]["fields"] = posFields;
}
return response;
}
load_data(opts = {}) {
const modelToLoad = this.getModelsToLoad(opts);
const response = modelToLoad.reduce((acc, modelName) => {
acc[modelName] = {};
return acc;
}, {});
for (const modelName of modelToLoad) {
const model = MockServer.env[modelName];
const posFields = this.getModelFieldsToLoad(model, opts);
const records = model.search_read([], posFields, false, false, false, false);
response[modelName] = this.processPosReadData(model, records, opts);
}
return response;
}
_load_pos_data_read(data) {
data[0]["_partner_commercial_fields"] = [];
data[0]["_server_version"] = "18.3+e";
data[0]["_base_url"] = "http://localhost:4444";
data[0]["_data_server_date"] = "2025-07-03 12:40:15";
data[0]["_has_cash_move_perm"] = true;
data[0]["_has_available_products"] = true;
data[0]["_pos_special_products_ids"] = [];
return data;
}
filter_local_data() {
return {};
}
_records = [
{
id: 1,
name: "/",
user_id: 2,
config_id: 1,
start_at: false,
stop_at: false,
payment_method_ids: [2, 1],
state: "opening_control",
update_stock_at_closing: false,
cash_register_balance_start: 0.0,
access_token: "e09c4843-c913-463a-959d-b9e235881201",
},
];
}

View file

@ -0,0 +1,83 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductAttribute extends models.ServerModel {
_name = "product.attribute";
_order = "id";
_load_pos_data_fields() {
return [
"name",
"display_type",
"template_value_ids",
"attribute_line_ids",
"create_variant",
];
}
_records = [
{
id: 1,
name: "color",
display_type: "radio",
template_value_ids: [],
attribute_line_ids: [],
create_variant: "always",
},
{
id: 2,
name: "gender",
display_type: "radio",
template_value_ids: [],
attribute_line_ids: [],
create_variant: "always",
},
{
id: 3,
name: "material",
display_type: "radio",
template_value_ids: [],
attribute_line_ids: [],
create_variant: "always",
},
{
id: 4,
name: "pattern",
display_type: "radio",
template_value_ids: [],
attribute_line_ids: [],
create_variant: "always",
},
{
id: 5,
name: "manufacturer",
display_type: "radio",
template_value_ids: [],
attribute_line_ids: [],
create_variant: "always",
},
{
id: 6,
name: "brand",
display_type: "radio",
template_value_ids: [],
attribute_line_ids: [],
create_variant: "always",
},
{
id: 7,
name: "size",
display_type: "radio",
template_value_ids: [],
attribute_line_ids: [],
create_variant: "always",
},
{
id: 8,
name: "age group",
display_type: "radio",
template_value_ids: [],
attribute_line_ids: [],
create_variant: "always",
},
];
}

View file

@ -0,0 +1,14 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductAttributeCustomValue extends models.ServerModel {
_name = "product.attribute.custom.value";
_load_pos_data_fields() {
return [
"custom_value",
"custom_product_template_attribute_value_id",
"pos_order_line_id",
"write_date",
];
}
}

View file

@ -0,0 +1,24 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductAttributeValue extends models.ServerModel {
_name = "product.attribute.value";
_records = [
{
id: 1,
name: "White",
attribute_id: 1,
},
{
id: 2,
name: "Black",
attribute_id: 1,
},
{
id: 3,
name: "Blue",
attribute_id: 1,
default_extra_price: 5.0,
},
];
}

View file

@ -0,0 +1,32 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductCategory extends models.ServerModel {
_name = "product.category";
_load_pos_data_fields() {
return ["id", "name", "parent_id"];
}
_records = [
{
id: 2,
name: "Expenses",
parent_id: false,
},
{
id: 4,
name: "Food",
parent_id: false,
},
{
id: 1,
name: "Goods",
parent_id: false,
},
{
id: 3,
name: "Services",
parent_id: false,
},
];
}

View file

@ -0,0 +1,28 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductCombo extends models.ServerModel {
_name = "product.combo";
_load_pos_data_fields() {
return ["id", "name", "combo_item_ids", "base_price", "qty_free", "qty_max"];
}
_records = [
{
id: 1,
name: "Chairs Combo",
combo_item_ids: [1, 2],
base_price: 100,
qty_free: 0,
qty_max: 10,
},
{
id: 2,
name: "Desks Combo",
combo_item_ids: [3, 4],
base_price: 200,
qty_free: 1,
qty_max: 1,
},
];
}

View file

@ -0,0 +1,36 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductComboItem extends models.ServerModel {
_name = "product.combo.item";
_load_pos_data_fields() {
return ["id", "combo_id", "product_id", "extra_price"];
}
_records = [
{
id: 1,
combo_id: 1,
product_id: 8,
extra_price: 0,
},
{
id: 2,
combo_id: 1,
product_id: 9,
extra_price: 35,
},
{
id: 3,
combo_id: 2,
product_id: 10,
extra_price: 0,
},
{
id: 4,
combo_id: 2,
product_id: 11,
extra_price: 50,
},
];
}

View file

@ -0,0 +1,30 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductPricelist extends models.ServerModel {
_name = "product.pricelist";
_load_pos_data_fields() {
return ["id", "name", "display_name", "item_ids"];
}
_records = [
{
id: 1,
name: "Test Pricelist A",
display_name: "Test Pricelist A (USD)",
item_ids: [1],
},
{
id: 2,
name: "Test Pricelist B",
display_name: "Test Pricelist B (USD)",
item_ids: [1],
},
{
id: 3,
name: "Test Pricelist 90%",
display_name: "Test Pricelist 90% (USD)",
item_ids: [2],
},
];
}

View file

@ -0,0 +1,63 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductPricelistItem extends models.ServerModel {
_name = "product.pricelist.item";
_load_pos_data_fields() {
return [
"product_tmpl_id",
"product_id",
"pricelist_id",
"price_surcharge",
"price_discount",
"price_round",
"price_min_margin",
"price_max_margin",
"company_id",
"currency_id",
"date_start",
"date_end",
"compute_price",
"fixed_price",
"percent_price",
"base_pricelist_id",
"base",
"categ_id",
"min_quantity",
];
}
_records = [
{
id: 1,
product_tmpl_id: false,
product_id: false,
pricelist_id: 1,
price_surcharge: 0.0,
price_discount: 0.0,
price_round: 0.0,
price_min_margin: 0.0,
price_max_margin: 0.0,
company_id: 250,
currency_id: 1,
date_start: false,
date_end: false,
compute_price: "fixed",
fixed_price: 3.0,
percent_price: 0.0,
base_pricelist_id: false,
base: "list_price",
categ_id: false,
min_quantity: 0.0,
},
{
id: 2,
base: "list_price",
company_id: 250,
compute_price: "percentage",
currency_id: 1,
pricelist_id: 3,
percent_price: 90.0,
},
];
}

View file

@ -0,0 +1,229 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductProduct extends models.ServerModel {
_name = "product.product";
// NOTE - We don't take into account _eval_taxes_computation_prepare_product_fields
_load_pos_data_fields() {
return [
"id",
"lst_price",
"display_name",
"product_tmpl_id",
"product_template_variant_value_ids",
"product_template_attribute_value_ids",
"barcode",
"product_tag_ids",
"default_code",
"standard_price",
"pos_categ_ids",
];
}
_records = [
{
id: 1,
product_tmpl_id: 1,
lst_price: 1,
standard_price: 0,
display_name: "TIP",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
pos_categ_ids: [1],
},
{
id: 5,
product_tmpl_id: 5,
lst_price: 100,
standard_price: 0,
display_name: "TEST",
product_tag_ids: [],
barcode: "test_test",
pos_categ_ids: [1],
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
},
{
id: 6,
product_tmpl_id: 6,
lst_price: 100,
standard_price: 0,
display_name: "TEST 2",
product_tag_ids: [],
pos_categ_ids: [2],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
},
{
id: 7,
product_tmpl_id: 7,
lst_price: 750,
standard_price: 0,
display_name: "Office combo",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
pos_categ_ids: [],
},
{
id: 8,
product_tmpl_id: 8,
lst_price: 300,
standard_price: 0,
display_name: "Wood chair",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
pos_categ_ids: [],
},
{
id: 9,
product_tmpl_id: 9,
lst_price: 450,
standard_price: 0,
display_name: "Steel chair",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
pos_categ_ids: [],
},
{
id: 10,
product_tmpl_id: 10,
lst_price: 600,
standard_price: 0,
display_name: "Wood chair",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
pos_categ_ids: [],
},
{
id: 11,
product_tmpl_id: 11,
lst_price: 700,
standard_price: 0,
display_name: "Steel desk",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
pos_categ_ids: [],
},
{
id: 12,
product_tmpl_id: 12,
lst_price: 14,
standard_price: 0,
display_name: "Bacon burger",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
pos_categ_ids: [4],
},
{
id: 13,
product_tmpl_id: 13,
lst_price: 12,
standard_price: 0,
display_name: "Pizza margarita",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
pos_categ_ids: [5],
},
{
id: 14,
product_tmpl_id: 14,
lst_price: 3.4,
standard_price: 0,
display_name: "Club sandwich",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
pos_categ_ids: [4],
},
{
id: 15,
product_tmpl_id: 15,
lst_price: 1000,
standard_price: 0,
display_name: "Accounting Test Product 1",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
},
{
id: 16,
product_tmpl_id: 16,
lst_price: 100,
standard_price: 0,
display_name: "Accounting Test Product 2",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
},
{
id: 17,
product_tmpl_id: 17,
lst_price: 100,
standard_price: 0,
display_name: "Multi Category Product",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
},
{
id: 18,
product_tmpl_id: 18,
lst_price: 0,
standard_price: 0,
display_name: "Free Product - Wood chair",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
pos_categ_ids: [],
},
{
id: 24,
product_tmpl_id: 24,
lst_price: 100,
standard_price: 0,
display_name: "Tax Included Product",
product_tag_ids: [],
barcode: false,
default_code: false,
product_template_attribute_value_ids: [],
product_template_variant_value_ids: [],
},
];
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductTag extends models.ServerModel {
_name = "product.tag";
_load_pos_data_fields() {
return ["name", "pos_description", "color", "has_image", "write_date"];
}
}

View file

@ -0,0 +1,588 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductTemplate extends models.ServerModel {
_name = "product.template";
_load_pos_data_fields() {
return [
"id",
"display_name",
"standard_price",
"categ_id",
"pos_categ_ids",
"taxes_id",
"barcode",
"name",
"list_price",
"is_favorite",
"default_code",
"to_weight",
"uom_id",
"description_sale",
"description",
"tracking",
"type",
"service_tracking",
"is_storable",
"write_date",
"color",
"pos_sequence",
"available_in_pos",
"attribute_line_ids",
"active",
"image_128",
"combo_ids",
"product_variant_ids",
"public_description",
"pos_optional_product_ids",
"sequence",
"product_tag_ids",
];
}
_records = [
{
id: 1,
display_name: "TIP",
standard_price: 0,
categ_id: false,
pos_categ_ids: [],
taxes_id: [],
barcode: false,
name: "TIP",
list_price: 100,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
combo_ids: [],
product_variant_ids: [1],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 5,
display_name: "TEST",
standard_price: 0,
categ_id: false,
pos_categ_ids: [1],
taxes_id: [1],
barcode: false,
name: "TEST",
list_price: 100,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
combo_ids: [],
product_variant_ids: [5],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 6,
display_name: "TEST 2",
standard_price: 0,
categ_id: false,
pos_categ_ids: [2],
taxes_id: [2],
barcode: false,
name: "TEST 2",
list_price: 100,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
combo_ids: [],
product_variant_ids: [6],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 7,
display_name: "Product combo",
standard_price: 0,
categ_id: false,
pos_categ_ids: [2],
taxes_id: [2],
barcode: false,
name: "Product combo",
list_price: 100,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "combo",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
combo_ids: [1, 2],
product_variant_ids: [7],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 8,
display_name: "Wood chair",
standard_price: 0,
categ_id: false,
pos_categ_ids: [2],
taxes_id: [2],
barcode: false,
name: "Wood chair",
list_price: 300,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
product_variant_ids: [8],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 9,
display_name: "Steel chair",
standard_price: 0,
categ_id: false,
pos_categ_ids: [2],
taxes_id: [2],
barcode: false,
name: "Steel chair",
list_price: 450,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
product_variant_ids: [9],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 10,
display_name: "Wood desk",
standard_price: 0,
categ_id: false,
pos_categ_ids: [2],
taxes_id: [2],
barcode: false,
name: "Wood desk",
list_price: 600,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
product_variant_ids: [10],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 11,
display_name: "Steel desk",
standard_price: 0,
categ_id: false,
pos_categ_ids: [2],
taxes_id: [2],
barcode: false,
name: "Steel desk",
list_price: 700,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
product_variant_ids: [11],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 12,
display_name: "Bacon burger",
standard_price: 0,
categ_id: false,
pos_categ_ids: [4],
taxes_id: [2],
barcode: false,
name: "Bacon burger",
list_price: 14,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
product_variant_ids: [12],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 13,
display_name: "Pizza margarita",
standard_price: 0,
categ_id: false,
pos_categ_ids: [5],
taxes_id: [2],
barcode: false,
name: "Pizza margarita",
list_price: 12,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
product_variant_ids: [13],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 14,
display_name: "Club sandwich",
standard_price: 0,
categ_id: false,
pos_categ_ids: [3],
taxes_id: [2],
barcode: false,
name: "Club sandwich",
list_price: 3.4,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
product_variant_ids: [14],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 15,
display_name: "Accounting Test Product 1",
standard_price: 0,
categ_id: false,
pos_categ_ids: [],
taxes_id: [2],
barcode: false,
name: "Accounting Test Product 1",
list_price: 1000,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
product_variant_ids: [15],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 16,
display_name: "Accounting Test Product 2",
standard_price: 0,
categ_id: false,
pos_categ_ids: [],
taxes_id: [1, 2],
barcode: false,
name: "Accounting Test Product 2",
list_price: 100,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
product_variant_ids: [16],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 17,
display_name: "Multi Category Product",
standard_price: 0,
categ_id: false,
pos_categ_ids: [1, 2],
taxes_id: [2],
barcode: false,
name: "Multi Category Product",
list_price: 100,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
combo_ids: [],
product_variant_ids: [17],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 18,
display_name: "Free Product - Wood chair",
standard_price: 0,
categ_id: false,
pos_categ_ids: [2],
taxes_id: [2],
barcode: false,
name: "Free Product - Wood chair",
list_price: 0,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
product_variant_ids: [18],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
{
id: 24,
display_name: "Tax Included Product",
standard_price: 0,
categ_id: false,
pos_categ_ids: [2],
taxes_id: [4],
barcode: false,
name: "Tax Included Product",
list_price: 300,
is_favorite: false,
default_code: false,
to_weight: false,
uom_id: 1,
description_sale: false,
description: false,
tracking: "none",
type: "consu",
service_tracking: "no",
is_storable: false,
write_date: "2025-07-03 13:04:14",
color: 0,
pos_sequence: 5,
available_in_pos: true,
attribute_line_ids: [],
active: true,
image_128: false,
product_variant_ids: [24],
public_description: false,
pos_optional_product_ids: [],
sequence: 1,
product_tag_ids: [],
},
];
get_product_info_pos() {
return {
all_prices: { tax_details: [] },
pricelists: [],
warehouses: [],
suppliers: [],
variants: [],
optional_products: [],
};
}
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductTemplateAttributeExclusion extends models.ServerModel {
_name = "product.template.attribute.exclusion";
_load_pos_data_fields() {
return ["value_ids", "product_template_attribute_value_id"];
}
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductTemplateAttributeLine extends models.ServerModel {
_name = "product.template.attribute.line";
_load_pos_data_fields() {
return ["display_name", "attribute_id", "product_template_value_ids"];
}
}

View file

@ -0,0 +1,19 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductTemplateAttributeValue extends models.ServerModel {
_name = "product.template.attribute.value";
_load_pos_data_fields() {
return [
"attribute_id",
"attribute_line_id",
"product_attribute_value_id",
"price_extra",
"name",
"is_custom",
"html_color",
"image",
"exclude_for",
];
}
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductUom extends models.ServerModel {
_name = "product.uom";
_load_pos_data_fields() {
return ["id", "barcode", "product_id", "uom_id"];
}
}

View file

@ -0,0 +1,55 @@
import { webModels } from "@web/../tests/web_test_helpers";
export class ResCompany extends webModels.ResCompany {
_name = "res.company";
_load_pos_data_fields() {
return [
"id",
"currency_id",
"email",
"website",
"company_registry",
"vat",
"name",
"phone",
"partner_id",
"country_id",
"state_id",
"tax_calculation_rounding_method",
"nomenclature_id",
"point_of_sale_use_ticket_qr_code",
"point_of_sale_ticket_unique_code",
"point_of_sale_ticket_portal_url_display_mode",
"street",
"city",
"zip",
"account_fiscal_country_id",
];
}
_records = [
...webModels.ResCompany._records,
{
id: 250,
currency_id: 1,
email: false,
website: false,
company_registry: false,
vat: false,
name: "My Company",
phone: "",
partner_id: 1,
country_id: 233,
state_id: false,
tax_calculation_rounding_method: "round_per_line",
point_of_sale_use_ticket_qr_code: true,
point_of_sale_ticket_unique_code: false,
point_of_sale_ticket_portal_url_display_mode: "qr_code_and_url",
street: "",
city: "",
zip: "",
account_fiscal_country_id: 233,
},
];
}

View file

@ -0,0 +1,19 @@
import { webModels } from "@web/../tests/web_test_helpers";
export class ResCountry extends webModels.ResCountry {
_name = "res.country";
_load_pos_data_fields() {
return ["id", "name", "code", "vat_label"];
}
_records = [
...webModels.ResCountry._records,
{
id: 233,
name: "United States",
code: "US",
vat_label: "",
},
];
}

View file

@ -0,0 +1,18 @@
import { models } from "@web/../tests/web_test_helpers";
export class ResCountryState extends models.ServerModel {
_name = "res.country.state";
_load_pos_data_fields() {
return ["id", "name", "code", "country_id"];
}
_records = [
{
id: 69,
name: "Armed Forces Europe",
code: "AE",
country_id: 233,
},
];
}

View file

@ -0,0 +1,42 @@
import { webModels } from "@web/../tests/web_test_helpers";
export class ResCurrency extends webModels.ResCurrency {
_name = "res.currency";
_load_pos_data_fields() {
return [
"id",
"name",
"symbol",
"position",
"rounding",
"rate",
"decimal_places",
"iso_numeric",
];
}
_records = [
{
id: 1,
name: "USD",
symbol: "$",
position: "before",
rounding: 0.01,
rate: 1.0,
decimal_places: 2,
iso_numeric: 840,
},
{
id: 125,
name: "EUR",
symbol: "€",
position: "after",
rounding: 0.01,
rate: 1.0,
decimal_places: 2,
iso_numeric: 978,
},
...webModels.ResCurrency._records.filter((record) => record.id !== 1),
];
}

View file

@ -0,0 +1,19 @@
import { models } from "@web/../tests/web_test_helpers";
export class ResLang extends models.ServerModel {
_name = "res.lang";
_load_pos_data_fields() {
return ["id", "name", "code", "flag_image_url", "display_name"];
}
_records = [
{
id: 1,
name: "English (US)",
code: "en_US",
flag_image_url: "/base/static/img/country_flags/us.png",
display_name: "English (US)",
},
];
}

View file

@ -0,0 +1,82 @@
import { ResPartner as MailResPartner } from "@mail/../tests/mock_server/mock_models/res_partner";
export class ResPartner extends MailResPartner {
_name = "res.partner";
_load_pos_data_fields() {
return [
"id",
"name",
"street",
"street2",
"city",
"state_id",
"country_id",
"vat",
"lang",
"phone",
"zip",
"email",
"barcode",
"write_date",
"property_product_pricelist",
"parent_name",
"pos_contact_address",
"invoice_emails",
"company_type",
"fiscal_position_id",
];
}
_records = [
...MailResPartner.prototype.constructor._records,
{
id: 3,
name: "Administrator",
street: false,
street2: false,
city: false,
state_id: false,
country_id: false,
vat: false,
lang: "en_US",
phone: false,
zip: false,
email: false,
barcode: false,
write_date: "2025-07-03 12:38:12",
property_product_pricelist: false,
parent_name: false,
pos_contact_address: "\n\n \n",
invoice_emails: "",
company_type: "person",
fiscal_position_id: false,
credit_limit: 0.0,
use_partner_credit_limit: false,
},
{
id: 4,
name: "User1",
street: false,
street2: false,
city: false,
state_id: false,
country_id: false,
vat: false,
lang: "en_US",
phone: false,
zip: false,
email: false,
barcode: false,
write_date: "2025-08-03 12:12:12",
property_product_pricelist: false,
parent_name: false,
pos_contact_address: "\n\n \n",
invoice_emails: "",
company_type: "person",
fiscal_position_id: false,
credit_limit: 0.0,
use_partner_credit_limit: false,
},
];
}

View file

@ -0,0 +1,36 @@
import { ResUsers as MailResUsers } from "@mail/../tests/mock_server/mock_models/res_users";
export class ResUsers extends MailResUsers {
_name = "res.users";
_load_pos_data_fields() {
return ["id", "name", "partner_id", "all_group_ids"];
}
_records = [
...MailResUsers.prototype.constructor._records,
{
id: 2,
name: "Administrator",
partner_id: 3,
role: "manager",
},
{
id: 3,
name: "User1",
partner_id: 4,
role: "cashier",
},
];
_post_read_pos_data(records) {
records.forEach((user) => {
if (user.id === 2) {
user._role = "manager";
} else {
user._role = "cashier";
}
});
return records;
}
}

View file

@ -0,0 +1,13 @@
import { models } from "@web/../tests/web_test_helpers";
export class ResourceCalendar extends models.ServerModel {
_name = "resource.calendar";
_records = [
{
id: 1,
name: "Takeaway",
attendance_ids: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
},
];
}

View file

@ -0,0 +1,82 @@
import { models } from "@web/../tests/web_test_helpers";
export class ResourceCalendarAttendance extends models.ServerModel {
_name = "resource.calendar.attendance";
_load_pos_data_fields() {
return ["id", "hour_from", "hour_to", "dayofweek", "day_period"];
}
_records = [
{
id: 1,
hour_from: 12,
hour_to: 15,
dayofweek: "1",
day_period: "lunch",
},
{
id: 2,
hour_from: 18,
hour_to: 22,
dayofweek: "1",
day_period: "evening",
},
{
id: 3,
hour_from: 12,
hour_to: 15,
dayofweek: "2",
day_period: "lunch",
},
{
id: 4,
hour_from: 18,
hour_to: 22,
dayofweek: "2",
day_period: "evening",
},
{
id: 5,
hour_from: 12,
hour_to: 15,
dayofweek: "3",
day_period: "lunch",
},
{
id: 6,
hour_from: 18,
hour_to: 22,
dayofweek: "3",
day_period: "evening",
},
{
id: 7,
hour_from: 12,
hour_to: 15,
dayofweek: "4",
day_period: "lunch",
},
{
id: 8,
hour_from: 18,
hour_to: 22,
dayofweek: "4",
day_period: "evening",
},
{
id: 9,
hour_from: 12,
hour_to: 15,
dayofweek: "5",
day_period: "lunch",
},
{
id: 10,
hour_from: 18,
hour_to: 22,
dayofweek: "5",
day_period: "evening",
},
];
}

View file

@ -0,0 +1,17 @@
import { models } from "@web/../tests/web_test_helpers";
export class StockPickingType extends models.ServerModel {
_name = "stock.picking.type";
_load_pos_data_fields() {
return ["id", "use_create_lots", "use_existing_lots"];
}
_records = [
{
id: 9,
use_create_lots: true,
use_existing_lots: true,
},
];
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class StockRoute extends models.ServerModel {
_name = "stock.route";
_load_pos_data_fields() {
return [];
}
}

View file

@ -0,0 +1,9 @@
import { models } from "@web/../tests/web_test_helpers";
export class StockWarehouse extends models.ServerModel {
_name = "stock.warehouse";
_load_pos_data_fields() {
return [];
}
}

View file

@ -0,0 +1,108 @@
import { models } from "@web/../tests/web_test_helpers";
export class UomUom extends models.ServerModel {
_name = "uom.uom";
_load_pos_data_fields() {
return ["id", "name", "factor", "is_pos_groupable", "parent_path", "rounding"];
}
_records = [
{
id: 5,
name: "Days",
factor: 8.0,
is_pos_groupable: false,
parent_path: "4/5/",
rounding: 0.01,
},
{
id: 2,
name: "Pack of 6",
factor: 6.0,
is_pos_groupable: true,
parent_path: "1/2/",
rounding: 0.01,
},
{
id: 8,
name: "m",
factor: 1000.0,
is_pos_groupable: false,
parent_path: "6/7/8/",
rounding: 0.01,
},
{
id: 15,
name: "kg",
factor: 1000.0,
is_pos_groupable: false,
parent_path: "14/15/",
rounding: 0.01,
},
{
id: 16,
name: "Ton",
factor: 1000000.0,
is_pos_groupable: false,
parent_path: "14/15/16/",
rounding: 0.01,
},
{
id: 12,
name: "L",
factor: 1000.0,
is_pos_groupable: false,
parent_path: "11/12/",
rounding: 0.01,
},
{
id: 4,
name: "Hours",
factor: 1.0,
is_pos_groupable: false,
parent_path: "4/",
rounding: 0.01,
},
{
id: 1,
name: "Units",
factor: 1.0,
is_pos_groupable: true,
parent_path: "1/",
rounding: 0.01,
},
{
id: 14,
name: "g",
factor: 1.0,
is_pos_groupable: false,
parent_path: "14/",
rounding: 0.01,
},
{
id: 11,
name: "ml",
factor: 1.0,
is_pos_groupable: false,
parent_path: "11/",
rounding: 0.01,
},
{
id: 6,
name: "mm",
factor: 1.0,
is_pos_groupable: false,
parent_path: "6/",
rounding: 0.01,
},
{
id: 10,
name: "m²",
factor: 1.0,
is_pos_groupable: false,
parent_path: "10/",
rounding: 0.01,
},
];
}

View file

@ -1,34 +0,0 @@
odoo.define('point_of_sale.test_env', async function (require) {
'use strict';
/**
* Many components in PoS are dependent on the PosGlobalState instance (pos).
* Therefore, for unit tests that require pos in the Components' env, we
* prepared here a test env maker (makePosTestEnv) based on
* makeTestEnvironment of web.
*/
const makeTestEnvironment = require('web.test_env');
const env = require('point_of_sale.env');
const { PosGlobalState } = require('point_of_sale.models');
const cleanup = require("@web/../tests/helpers/cleanup");
// We override this method in the pos unit tests to prevent the unnecessary error in the web tests.
cleanup.registerCleanup = () => {}
await env.session.is_bound;
const pos = PosGlobalState.create({ env });
await pos.load_server_data();
/**
* @param {Object} env default env
* @param {Function} providedRPC mock rpc
* @param {Function} providedDoAction mock do_action
*/
function makePosTestEnv(env = {}, providedRPC = null, providedDoAction = null) {
env = Object.assign(env, { pos });
return makeTestEnvironment(env, providedRPC);
}
return makePosTestEnv;
});

View file

@ -0,0 +1,26 @@
import { test, expect } from "@odoo/hoot";
import { setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
test("getAllChildren", async () => {
const store = await setupPosEnv();
const category = store.models["pos.category"].get(3);
const children = category.getAllChildren();
expect(children.map((c) => c.id).sort()).toEqual([3, 4, 5]);
});
test("get allParents", async () => {
const store = await setupPosEnv();
const category = store.models["pos.category"].get(5);
const parent = category.allParents;
expect(parent[0].id).toEqual(3);
});
test("get associatedProducts", async () => {
const store = await setupPosEnv();
const category = store.models["pos.category"].get(3);
const associatedProducts = category.associatedProducts;
expect(associatedProducts.map((p) => p.id).sort()).toEqual([12, 13, 14]);
});

View file

@ -0,0 +1,466 @@
import { test, expect } from "@odoo/hoot";
import { getFilledOrder, setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
test("uiState", async () => {
const store = await setupPosEnv();
const order = store.addNewOrder();
expect(order.uiState).toEqual({
unmerge: {},
lastPrints: [],
lineToRefund: {},
displayed: true,
booked: false,
screen_data: {},
selected_orderline_uuid: undefined,
selected_paymentline_uuid: undefined,
TipScreen: {
inputTipAmount: "",
},
requiredPartnerDetails: {},
});
});
test("totalQuantity", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
expect(order.totalQuantity).toBe(5);
});
test("setPreset", async () => {
const store = await setupPosEnv();
const order = store.addNewOrder();
const inPreset = store.models["pos.preset"].get(1);
const outPreset = store.models["pos.preset"].get(2);
expect(order.pricelist_id).toBe(inPreset.pricelist_id);
expect(order.fiscal_position_id).toBe(inPreset.fiscal_position_id);
order.setPreset(outPreset);
expect(order.pricelist_id).toBe(outPreset.pricelist_id);
expect(order.fiscal_position_id).toBe(outPreset.fiscal_position_id);
order.setPreset(inPreset);
expect(order.pricelist_id).toBe(inPreset.pricelist_id);
expect(order.fiscal_position_id).toBe(inPreset.fiscal_position_id);
});
test("updateLastOrderChange", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
order.setGeneralCustomerNote("Customer note");
order.setInternalNote("Internal note");
order.updateLastOrderChange();
expect(order.last_order_preparation_change.general_customer_note).toBe("Customer note");
expect(order.last_order_preparation_change.internal_note).toBe("Internal note");
});
test("removeOrderline", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
order.general_customer_note = "Some note";
const line1 = order.lines[0];
const line2 = order.lines[1];
expect(order.getSelectedOrderline()).toBe(line2);
order.removeOrderline(line2);
expect(order.general_customer_note).toBe("Some note");
expect(order.getSelectedOrderline()).toBe(line1);
order.removeOrderline(line1);
// General customer note should be removed when removing the last order line
expect(order.general_customer_note).toBe("");
});
test("addPaymentline", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const cashPaymentMethod = store.models["pos.payment.method"].get(1);
// Test that the payment line is correctly created
const result = order.addPaymentline(cashPaymentMethod);
const cashPaymentLine = result.data;
expect(cashPaymentLine.payment_method_id.id).toBe(cashPaymentMethod.id);
expect(cashPaymentLine.amount).toBe(17.85);
// Update the cash payment line amount to 10 to confirm that the second created cash payment
// line will take the remaining amount to pay (7.85)
cashPaymentLine.setAmount(10);
const result2 = order.addPaymentline(cashPaymentMethod);
expect(result2.data.payment_method_id.id).toBe(cashPaymentMethod.id);
expect(result2.data.amount).toBe(7.85);
});
test("getTotalDiscount", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const discount = order.getTotalDiscount();
expect(discount).toBe(0);
const taxTotals = order.prices.taxDetails;
expect(taxTotals.base_amount).toBe(15);
expect(taxTotals.total_amount).toBe(17.85);
expect(taxTotals.tax_amount_currency).toBe(2.85);
//Compute total of discount on the order
const line1 = order.lines[0];
const line2 = order.lines[1];
line1.setDiscount(20);
line2.setDiscount(50);
expect(order.getTotalDiscount()).toBe(5.82);
const taxTotalsWDiscount = order.prices.taxDetails;
expect(taxTotalsWDiscount.base_amount).toBe(10.2);
expect(taxTotalsWDiscount.total_amount).toBe(12.03);
expect(taxTotalsWDiscount.tax_amount_currency).toBe(1.83);
});
test("preventRoundingErrorsCombo", async () => {
const store = await setupPosEnv();
store.models["product.product"].get(7).taxes_id = [1];
store.models["product.product"].get(7).lst_price = 50;
store.models["product.product"].get(8).taxes_id = [1];
store.models["product.product"].get(9).taxes_id = [1];
store.models["pos.preset"].get(1).pricelist_id = false;
store.models["product.combo"].get(1).qty_free = 3;
store.models["product.combo"].get(1).base_price = 10;
const comboProduct1 = store.models["product.combo.item"].get(1);
const comboProduct2 = store.models["product.combo.item"].get(2);
comboProduct2.extra_price = 0;
const template = store.models["product.template"].get(7);
const order = store.addNewOrder();
const order2 = store.addNewOrder();
const order3 = store.addNewOrder();
// 3 of the same product
await store.addLineToOrder(
{
product_tmpl_id: template,
payload: [[{ combo_item_id: comboProduct1, qty: 3 }]],
qty: 1,
},
order
);
order.setOrderPrices();
expect(order.amount_total).toBe(57.5);
expect(order.lines[1].qty).toBe(2);
expect(order.lines[1].price_unit).toBe(16.67);
expect(order.lines[2].qty).toBe(1);
expect(Math.round(order.lines[2].price_unit * 100) / 100).toBe(16.66);
// 2 different products
await store.addLineToOrder(
{
product_tmpl_id: template,
payload: [
[
{ combo_item_id: comboProduct1, qty: 1 },
{ combo_item_id: comboProduct2, qty: 2 },
],
],
qty: 1,
},
order2
);
order2.setOrderPrices();
expect(order2.amount_total).toBe(57.5);
expect(order2.lines[1].price_unit).toBe(16.67);
expect(order2.lines[1].qty).toBe(1);
expect(order2.lines[2].price_unit).toBe(16.67);
expect(order2.lines[2].qty).toBe(1);
expect(Math.round(order2.lines[3].price_unit * 100) / 100).toBe(16.66);
expect(order2.lines[3].qty).toBe(1);
// 3 of the same product and 3 of the same extra items
await store.addLineToOrder(
{
product_tmpl_id: template,
payload: [
[{ combo_item_id: comboProduct1, qty: 3 }],
[{ combo_item_id: comboProduct1, qty: 3 }],
],
qty: 1,
},
order3
);
order3.setOrderPrices();
expect(order3.amount_total).toBe(92);
expect(order3.lines[1].qty).toBe(2);
expect(order3.lines[1].price_unit).toBe(16.67);
expect(Math.round(order3.lines[2].price_unit * 100) / 100).toBe(16.66);
expect(order3.lines[2].qty).toBe(1);
expect(order3.lines[3].qty).toBe(3);
expect(order3.lines[3].price_unit).toBe(10);
});
test("customer requirements", async () => {
const store = await setupPosEnv();
const preset = store.models["pos.preset"].get(3); // Address Required Preset
const partner = store.models["res.partner"].get(3); // Customer Without Address
const order = store.addNewOrder();
order.preset_id = preset;
// No partner
expect(order.presetRequirementsFilled).toBe(false);
expect(order.uiState.requiredPartnerDetails.field).toBe("Customer");
expect(order.uiState.requiredPartnerDetails.message).toBe(
"Please add a valid customer to the order."
);
// Partner
order.partner_id = partner;
expect(order.presetRequirementsFilled).toBe(true);
});
test("Address requirements", async () => {
const store = await setupPosEnv();
const preset = store.models["pos.preset"].get(4); // Address Required Preset
const partner = store.models["res.partner"].get(3); // Customer Without Address
const order = store.addNewOrder();
order.preset_id = preset;
order.partner_id = partner;
expect(order.presetRequirementsFilled).toBe(false);
expect(order.uiState.requiredPartnerDetails.field).toBe("Address");
expect(order.uiState.requiredPartnerDetails.message).toBe(
"The selected customer needs an address."
);
// Partner with address
partner.street = "test abc";
expect(order.presetRequirementsFilled).toBe(true);
});
test("slot requirement preset", async () => {
const store = await setupPosEnv();
const preset = store.models["pos.preset"].get(2); // Time Slot Preset
const order = store.addNewOrder();
order.preset_id = preset;
// No slot
expect(order.presetRequirementsFilled).toBe(false);
expect(order.uiState.requiredPartnerDetails.field).toBe("Slot");
expect(order.uiState.requiredPartnerDetails.message).toBe(
"Please select a time slot before proceeding."
);
// Slot set
order.preset_time = "2025-08-11 14:00:00";
expect(order.presetRequirementsFilled).toBe(true);
});
test("isCustomerRequired", async () => {
const posStore = await setupPosEnv();
const order = await getFilledOrder(posStore);
const existingPartner = posStore.models["res.partner"].get(3);
expect(order.isCustomerRequired).toBe(false);
{
// preset - name identification
const namePreset = posStore.models["pos.preset"].get(3);
order.preset_id = namePreset;
expect(order.isCustomerRequired).toBe(true);
// with floating order name
order.floating_order_name = "TEST-P";
expect(order.isCustomerRequired).toBe(false);
order.floating_order_name = "";
// with assigned partner
order.partner_id = existingPartner;
expect(order.isCustomerRequired).toBe(false);
order.partner_id = false;
}
{
// preset - address identification
const addressPreset = posStore.models["pos.preset"].get(4);
order.preset_id = addressPreset;
expect(order.isCustomerRequired).toBe(true);
// with assigned partner
order.partner_id = existingPartner;
expect(order.isCustomerRequired).toBe(false);
order.partner_id = false;
}
{
// order invoicing
order.preset_id = false;
order.to_invoice = true;
expect(order.isCustomerRequired).toBe(true);
order.to_invoice = false;
}
{
// split payment (customer account)
const customerAccountMethod = posStore.models["pos.payment.method"].get(3);
order.addPaymentline(customerAccountMethod);
expect(order.isCustomerRequired).toBe(true);
order.partner_id = existingPartner;
expect(order.isCustomerRequired).toBe(false);
order.partner_id = false;
order.removePaymentline(order.payment_ids[0]);
}
expect(order.isCustomerRequired).toBe(false);
});
test("setShippingDate and getShippingDate with Luxon", async () => {
const store = await setupPosEnv();
const order = store.addNewOrder();
const testDate = "2019-03-11";
order.setShippingDate(testDate);
expect(order.shipping_date.toISODate()).toBe(testDate);
expect(typeof order.getShippingDate()).toBe("string");
order.setShippingDate(null);
expect(order.getShippingDate()).toBeEmpty();
});
test("[get prices] check prices and taxes", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const data = order.prices;
// Check taxes on order base_amount is 15 with 15% taxes
const orderTaxes = data.taxDetails;
expect(orderTaxes.base_amount).toBe(15.0);
expect(orderTaxes.total_amount).toBe(17.85);
expect(orderTaxes.tax_amount).toBe(2.85);
// Order prices data also return the prices of all lines
// Check first line with a price_unit of 3 and 3 qty
const line1Data = data.baseLineByLineUuids[order.lines[0].uuid].tax_details;
expect(line1Data.total_excluded).toBe(9.0);
expect(line1Data.total_included).toBe(10.35);
expect(line1Data.taxes_data[0].tax_amount).toBe(1.35);
// Check second line with a price_unit of 3 and 2 qty
const line2Data = data.baseLineByLineUuids[order.lines[1].uuid].tax_details;
expect(line2Data.total_excluded).toBe(6.0);
expect(line2Data.total_included).toBe(7.5);
expect(line2Data.taxes_data[0].tax_amount).toBe(1.5);
// Check with a discount on first line of 30%
order.lines[0].setDiscount(30);
const dataWDiscount = order.prices;
const orderTaxesWDiscount = dataWDiscount.taxDetails;
expect(orderTaxesWDiscount.base_amount).toBe(12.3);
expect(orderTaxesWDiscount.total_amount).toBe(14.75);
expect(orderTaxesWDiscount.tax_amount).toBe(2.45);
// Check first line with a price_unit of 3, 3 qty and 30% discount
const line1DataWDiscount = dataWDiscount.baseLineByLineUuids[order.lines[0].uuid].tax_details;
expect(line1DataWDiscount.total_excluded).toBe(6.3);
expect(line1DataWDiscount.total_included).toBe(7.25);
expect(line1DataWDiscount.taxes_data[0].tax_amount).toBe(0.95);
expect(line1DataWDiscount.discount_amount).toBe(3.1);
// No discount values should still represent the line without discount
expect(line1DataWDiscount.no_discount_total_excluded).toBe(9.0);
expect(line1DataWDiscount.no_discount_total_included).toBe(10.35);
expect(line1DataWDiscount.no_discount_taxes_data[0].tax_amount).toBe(1.35);
});
test("showChange remains true when change line name is translated", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const cashPaymentMethod = store.models["pos.payment.method"].get(1);
const { data: paymentLine } = order.addPaymentline(cashPaymentMethod);
const overpaidAmount = order.totalDue + 5;
paymentLine.setAmount(overpaidAmount);
const changeAmount = paymentLine.getAmount() - order.totalDue;
const changeLine = order.models["pos.payment"].create({
pos_order_id: order,
payment_method_id: cashPaymentMethod,
});
changeLine.setAmount(-changeAmount);
changeLine.name = "Retour"; // Simulate translated "return"
changeLine.is_change = true;
order.state = "paid";
expect(order.change).toBe(-changeAmount);
expect(order.showChange).toBe(true);
});
test("priceDoesntChangeWhenChangingPreset", async () => {
const store = await setupPosEnv();
store.models["pos.preset"].get(1).pricelist_id = false;
const otherPreset = store.models["pos.preset"].get(2);
store.models["product.combo"].get(1).qty_free = 2;
const comboProduct1 = store.models["product.combo.item"].get(1);
const comboProductExtra = store.models["product.combo.item"].get(2);
const comboProduct2 = store.models["product.combo.item"].get(3);
const template = store.models["product.template"].get(7);
const order = store.addNewOrder();
const order2 = store.addNewOrder();
const order3 = store.addNewOrder();
const order4 = store.addNewOrder();
// Normal flow with extras
await store.addLineToOrder(
{
product_tmpl_id: template,
payload: [
[{ combo_item_id: comboProduct1, qty: 2 }],
[{ combo_item_id: comboProduct2, qty: 2 }],
],
qty: 1,
},
order
);
order.setOrderPrices();
let total = order.amount_total;
order.setPreset(otherPreset);
order.setOrderPrices();
expect(order.amount_total).toBe(total);
// Normal flow
await store.addLineToOrder(
{
product_tmpl_id: template,
payload: [[{ combo_item_id: comboProduct1, qty: 2 }]],
qty: 1,
},
order2
);
order2.setOrderPrices();
total = order2.amount_total;
order2.setPreset(otherPreset);
order2.setOrderPrices();
expect(order2.amount_total).toBe(total);
// Flow with products with extra price
await store.addLineToOrder(
{
product_tmpl_id: template,
payload: [
[{ combo_item_id: comboProduct1, qty: 2 }],
[{ combo_item_id: comboProductExtra, qty: 2 }],
],
qty: 1,
},
order3
);
order3.setOrderPrices();
total = order3.amount_total;
order3.setPreset(otherPreset);
order3.setOrderPrices();
expect(order3.amount_total).toBe(total);
// Flow with all the same product
await store.addLineToOrder(
{
product_tmpl_id: template,
payload: [
[{ combo_item_id: comboProduct1, qty: 2 }],
[{ combo_item_id: comboProduct1, qty: 2 }],
],
qty: 1,
},
order4
);
order4.setOrderPrices();
total = order4.amount_total;
order4.setPreset(otherPreset);
order4.setOrderPrices();
expect(order4.amount_total).toBe(total);
});

View file

@ -0,0 +1,353 @@
import { test, expect, describe } from "@odoo/hoot";
import { setupPosEnv, getFilledOrder } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
function getAllPricesData(otherData = {}) {
return {
"pos.order": [
{
id: 1,
name: "Test Order",
lines: [1],
},
{
id: 2,
name: "Test Combo order",
},
],
"pos.order.line": [
{
id: 1,
order_id: 1,
product_id: 5,
price_unit: 100.0,
qty: 2,
tax_ids: [1],
},
{
id: 2,
order_id: 2,
product_id: 7,
price_unit: 0.0,
qty: 1,
combo_line_ids: [3, 4],
tax_ids: [],
},
{
id: 3,
order_id: 2,
product_id: 8,
price_unit: 1,
qty: 2,
combo_parent_id: 2,
tax_ids: [],
},
{
id: 4,
order_id: 2,
product_id: 10,
price_unit: 8,
qty: 1,
combo_parent_id: 2,
tax_ids: [],
},
],
...otherData,
};
}
test("[get prices()] Base test", async () => {
const store = await setupPosEnv();
const models = store.models;
const data = models.loadConnectedData(getAllPricesData());
const lineTax = data["pos.order.line"][0].prices;
expect(lineTax.total_included).toBe(230.0);
expect(lineTax.total_excluded).toBe(200.0);
expect(lineTax.taxes_data[0].base_amount).toBe(200.0);
expect(lineTax.taxes_data[0].tax_amount).toBe(30.0);
// Test with line qty = 0
data["pos.order.line"][0].qty = 0;
const zeroQtyLineTax = data["pos.order.line"][0].prices;
expect(zeroQtyLineTax.total_included).toBe(0.0);
expect(zeroQtyLineTax.total_excluded).toBe(0.0);
expect(zeroQtyLineTax.taxes_data[0].base_amount).toBe(0.0);
expect(zeroQtyLineTax.taxes_data[0].tax_amount).toBe(0.0);
});
test("[get prices()] with discount applied", async () => {
const store = await setupPosEnv();
const models = store.models;
const data = models.loadConnectedData(getAllPricesData());
const orderLine = data["pos.order.line"][0];
// Prices with a discount of 10% applied: 230 * 0.9 = 207.0
orderLine.setDiscount(10.0); // 10% discount
const lineTax = orderLine.prices;
expect(lineTax.total_included).toBe(207.0);
expect(lineTax.total_excluded).toBe(180.0);
expect(lineTax.taxes_data[0].tax_amount).toBe(27.0);
// Price with a discount of 100% applied
orderLine.setDiscount(100.0);
const updatedLineTax = orderLine.prices;
expect(updatedLineTax.total_excluded).toBe(0.0);
expect(updatedLineTax.total_included).toBe(0.0);
expect(updatedLineTax.taxes_data[0].tax_amount).toBe(0.0);
});
test("[get prices()] with multiple taxes settings", async () => {
const store = await setupPosEnv();
const models = store.models;
const rawData = getAllPricesData();
const product = models["product.product"].get(5);
product.taxes_id = models["account.tax"].readMany([1, 2]); // Set two taxes on the product
rawData["pos.order.line"][0].qty = 1;
rawData["pos.order.line"][0].tax_ids = [1, 2];
const data = models.loadConnectedData(rawData);
const orderLine = data["pos.order.line"][0];
// Test with two taxes applied (15% and 25%)
const lineTax = orderLine.prices;
expect(lineTax.total_excluded).toBe(100.0);
expect(lineTax.total_included).toBe(140.0);
expect(lineTax.taxes_data[0].tax_amount).toBe(15.0);
expect(lineTax.taxes_data[1].tax_amount).toBe(25.0);
// Test with "include_base_amount" and "include_base_amount" to true for both taxes
models["account.tax"].get(1).include_base_amount = true;
models["account.tax"].get(2).include_base_amount = true;
orderLine.order_id.triggerRecomputeAllPrices(); // Force the recompute because updating taxes do not trigger it
const updatedLineTax = orderLine.prices;
expect(updatedLineTax.total_excluded).toBe(100.0);
expect(updatedLineTax.total_included).toBe(143.75);
expect(updatedLineTax.taxes_data[0].tax_amount).toBe(15.0);
expect(updatedLineTax.taxes_data[1].tax_amount).toBe(28.75);
// Test without any taxes
product.taxes_id = [];
orderLine.tax_ids = []; // Do not need to force the recompute here, changing line taxes does it for us
const noTaxLine = data["pos.order.line"][0].prices;
expect(noTaxLine.total_excluded).toBe(100.0);
expect(noTaxLine.total_included).toBe(100.0);
expect(noTaxLine.taxes_data).toHaveLength(0);
});
test("[get prices()] with fixed-amount tax", async () => {
const store = await setupPosEnv();
const models = store.models;
const data = models.loadConnectedData(getAllPricesData());
models["account.tax"].get(1).amount_type = "fixed";
const orderLine = data["pos.order.line"][0];
orderLine.qty = 3;
orderLine.price_unit = 10.0;
const lineTax = orderLine.prices;
// 3 * 10 = 30, tax = 3 * 15 = 45
expect(lineTax.total_excluded).toBe(30.0);
expect(lineTax.total_included).toBe(75.0);
expect(lineTax.taxes_data[0].tax_amount).toBe(45.0);
});
test("[get prices()] with one price-included and one price-excluded tax", async () => {
const store = await setupPosEnv();
const models = store.models;
const product = models["product.product"].get(5);
const rawData = getAllPricesData();
models["account.tax"].get(1).price_include = true;
product.taxes_id = models["account.tax"].readMany([1, 2]);
rawData["pos.order.line"][0].tax_ids = [1, 2];
const data = models.loadConnectedData(rawData);
const orderLine = data["pos.order.line"][0];
orderLine.qty = 1;
orderLine.price_unit = 115.0; // price includes 15% tax
const lineTax = orderLine.prices;
// priceWithoutTax: 115 / 1.15 = 100, 25% tax = 25, priceWithTax = 115 + 25 = 140
expect(lineTax.total_excluded).toBe(100.0);
expect(lineTax.total_included).toBe(140.0);
expect(lineTax.taxes_data[0].tax_amount).toBe(15.0);
expect(lineTax.taxes_data[1].tax_amount).toBe(25.0);
});
test("[get quantityStr] Base test", async () => {
const store = await setupPosEnv();
const models = store.models;
const data = models.loadConnectedData(getAllPricesData());
const orderLine = data["pos.order.line"][0];
const qtyStr = orderLine.quantityStr; // Test with qty = 2
expect(qtyStr.qtyStr).toBe("2");
expect(qtyStr.unitPart).toBe("2");
expect(qtyStr.decimalPoint).toBe(".");
expect(qtyStr.decimalPart).toBe("");
orderLine.qty = 2.5; // Test with qty = 2.5
const updatedQtyStr = orderLine.quantityStr;
expect(updatedQtyStr.qtyStr).toBe("2.50");
expect(updatedQtyStr.unitPart).toBe("2");
expect(updatedQtyStr.decimalPoint).toBe(".");
expect(updatedQtyStr.decimalPart).toBe("50");
});
test("[setQuantity] Base test", async () => {
const store = await setupPosEnv();
const models = store.models;
const data = models.loadConnectedData(getAllPricesData());
const orderLine = data["pos.order.line"][0];
orderLine.setQuantity(2.77);
expect(orderLine.qty).toBe(2.77);
orderLine.setQuantity(2.779);
expect(orderLine.qty).toBe(2.78);
orderLine.setQuantity(2.771);
expect(orderLine.qty).toBe(2.77);
const comboOrderline = data["pos.order.line"][1];
const comboChild1 = data["pos.order.line"][2];
const comboChild2 = data["pos.order.line"][3];
expect(comboOrderline.qty).toBe(1);
expect(comboChild1.qty).toBe(2);
expect(comboChild2.qty).toBe(1);
expect(comboOrderline.price_unit).toBe(0);
expect(comboChild1.price_unit).toBe(1);
expect(comboChild2.price_unit).toBe(8);
comboOrderline.setQuantity(3, true);
expect(comboOrderline.qty).toBe(3);
expect(comboChild1.qty).toBe(6);
expect(comboChild2.qty).toBe(3);
expect(comboOrderline.price_unit).toBe(0);
expect(comboChild1.price_unit).toBe(1);
expect(comboChild2.price_unit).toBe(8);
comboOrderline.setQuantity(2, true);
expect(comboOrderline.qty).toBe(2);
expect(comboChild1.qty).toBe(4);
expect(comboChild2.qty).toBe(2);
expect(comboOrderline.price_unit).toBe(0);
expect(comboChild1.price_unit).toBe(1);
expect(comboChild2.price_unit).toBe(8);
});
test("[canBeMergedWith]: Base test", async () => {
const store = await setupPosEnv();
// Test with different products
const order = await getFilledOrder(store);
const line1 = order.lines[0];
const line2 = order.lines[1];
expect(line1.canBeMergedWith(line2)).toBe(false);
// Test with same product, same price, same qty, no discount, different name
line2.product_id = line1.product_id;
expect(line1.canBeMergedWith(line2)).toBe(false);
// Test with same product, same price, same qty, no discount, same name
line2.setFullProductName("TEST");
expect(line1.canBeMergedWith(line2)).toBe(true);
// Test with different note
line1.setNote("Test note");
expect(line1.canBeMergedWith(line2)).toBe(false);
// Test with same note
line2.setNote("Test note");
expect(line1.canBeMergedWith(line2)).toBe(true);
// Test with discount applied
line1.setDiscount(10.0);
expect(line1.canBeMergedWith(line2)).toBe(false);
// Test with same discount
line2.setDiscount(10.0);
expect(line1.canBeMergedWith(line2)).toBe(true);
// Test with different price unit
line2.price_unit = line1.price_unit + 1;
line2.price_type = "manual";
expect(line1.canBeMergedWith(line2)).toBe(false);
// Test to merge lines
line1.merge(line2);
expect(line1.qty).toBe(5);
});
describe("Test taxes after fiscal position", () => {
test("Orderline containing a taxed product", async () => {
const store = await setupPosEnv();
const models = store.models;
const dataDict = getAllPricesData();
dataDict["pos.order"][0]["fiscal_position_id"] = 2;
const data = models.loadConnectedData(dataDict);
const orderLine = data["pos.order.line"][0];
const lineValues = orderLine.prepareBaseLineForTaxesComputationExtraValues();
expect(lineValues.tax_ids.length).toBe(0);
});
test("Taxed Orderline orderline after fiscal position", async () => {
const store = await setupPosEnv();
const models = store.models;
const dataDict = getAllPricesData();
dataDict["pos.order"][0]["fiscal_position_id"] = 1;
const data = models.loadConnectedData(dataDict);
// Taxed order line
const orderLine = data["pos.order.line"][0];
// Taxed Product
const taxedProductLineValues = orderLine.prepareBaseLineForTaxesComputationExtraValues();
expect(taxedProductLineValues.tax_ids.length).toBe(1);
// Non Taxed Product
orderLine.product_id.taxes_id = [];
const nonTaxedProductlineValues = orderLine.prepareBaseLineForTaxesComputationExtraValues();
expect(nonTaxedProductlineValues.tax_ids.length).toBe(1);
});
test("Non-taxed orderline after fiscal position", async () => {
const store = await setupPosEnv();
const models = store.models;
const dataDict = getAllPricesData();
dataDict["pos.order"][0]["fiscal_position_id"] = 1;
const data = models.loadConnectedData(dataDict);
const orderLine = data["pos.order.line"][0];
// Non taxed order line
orderLine.tax_ids = [];
// Taxed product
const taxedProductLineValues = orderLine.prepareBaseLineForTaxesComputationExtraValues();
expect(taxedProductLineValues.tax_ids.length).toBe(0);
orderLine.product_id.taxes_id = [];
// Non Taxed product
const nonTaxedProductlineValues = orderLine.prepareBaseLineForTaxesComputationExtraValues();
expect(nonTaxedProductlineValues.tax_ids.length).toBe(0);
});
});
test("Test serial number requirements", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const serial_line = order.lines[0];
serial_line.product_id.tracking = "serial";
expect(serial_line.hasValidProductLot()).toBe(false); // No SN set
serial_line.setPackLotLines({
modifiedPackLotLines: {},
newPackLotLines: [
{
lot_name: "SN001",
},
],
});
expect(serial_line.hasValidProductLot()).toBe(true);
serial_line.qty = 2;
expect(serial_line.hasValidProductLot()).toBe(false); // Only one SN set
serial_line.setPackLotLines({
modifiedPackLotLines: {},
newPackLotLines: [
{
lot_name: "SN002",
},
],
});
expect(serial_line.hasValidProductLot()).toBe(true);
const lot_line = order.lines[1];
lot_line.product_id.tracking = "lot";
expect(lot_line.hasValidProductLot()).toBe(false);
lot_line.setPackLotLines({
modifiedPackLotLines: {},
newPackLotLines: [
{
lot_name: "LOT001",
},
],
});
expect(lot_line.hasValidProductLot()).toBe(true);
lot_line.qty = 2;
expect(lot_line.hasValidProductLot()).toBe(true); // One lot is enough
});

View file

@ -0,0 +1,27 @@
import { test, expect } from "@odoo/hoot";
import { setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
test("generateSlots", async () => {
const store = await setupPosEnv();
const presetIn = store.models["pos.preset"].get(1);
// expect all presetIn.availabilities to be empty arrays
for (const key in presetIn.availabilities) {
expect(Array.isArray(presetIn.availabilities[key])).toBe(true);
expect(presetIn.availabilities[key].length).toBe(0);
}
// expect days of week of presetOut.availabilities to contains slots
const presetOut = store.models["pos.preset"].get(2);
let daysWithSlot = 0;
for (const key in presetOut.availabilities) {
if (Object.keys(presetOut.availabilities[key]).length > 0) {
daysWithSlot++;
// each day should contains 23 slots of 20 minutes (12:00 to 15:00, and 18:00 to 22:00)
expect(Object.keys(presetOut.availabilities[key]).length).toBe(23);
}
}
// expect at least 5 days with slots (Monday to Friday)
expect(daysWithSlot).toBe(5);
});

View file

@ -0,0 +1,27 @@
import { test, expect } from "@odoo/hoot";
import { setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
import { ProductProduct } from "@point_of_sale/app/models/product_product";
import { ProductTemplate } from "@point_of_sale/app/models/product_template";
definePosModels();
test("product template and product product override", async () => {
const store = await setupPosEnv();
const product = store.models["product.template"].get(18);
patchWithCleanup(ProductProduct.prototype, {
get allBarcodes() {
return this.barcode || "";
},
});
patchWithCleanup(ProductTemplate.prototype, {
get allBarcodes() {
return (
(this.barcode || "") + this.product_variant_ids.map((p) => p.allBarcodes).join(",")
);
},
});
expect(product.allBarcodes).toBe("");
});

View file

@ -0,0 +1,35 @@
import { PosData } from "@point_of_sale/app/services/data_service";
import { patch } from "@web/core/utils/patch";
/**
* Disable IndexedDB in Hoot tests to avoid creating to much IndexedDB databases
* when running the full test suite.
*
* IndexedDB is still tested in dedicated tours.
*/
patch(PosData.prototype, {
setup() {
this.indexedDB = {
delete: async () => ({}),
create: async () => ({}),
reset: async () => ({}),
readAll: async () => ({}),
};
return super.setup(...arguments);
},
initIndexedDB() {
return true;
},
initListeners() {
return true;
},
synchronizeLocalDataInIndexedDB() {
return true;
},
async getCachedServerDataFromIndexedDB() {
return {};
},
async getLocalDataFromIndexedDB() {
return {};
},
});

View file

@ -0,0 +1,146 @@
import { expect, test, describe } from "@odoo/hoot";
import { getRelatedModelsInstance } from "../data/get_model_definitions";
import { makeMockServer } from "@web/../tests/web_test_helpers";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
describe(`Related models Events`, () => {
test("Connecting multiple records must fire update once", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order = models["pos.order"].create({});
const line1 = models["pos.order.line"].create({});
const line2 = models["pos.order.line"].create({});
const orderUpdates = [];
const linesUpdates = [];
models["pos.order"].addEventListener("update", (data) => {
orderUpdates.push(data);
});
models["pos.order.line"].addEventListener("update", (data) => {
linesUpdates.push(data);
});
expect(orderUpdates.length).toBe(0);
order.update({ lines: [line1, line2], amount_total: 10 });
expect(orderUpdates.length).toBe(1);
expect(orderUpdates[0].id).toEqual(order.id);
expect(orderUpdates[0].fields).toEqual(["lines", "amount_total"]);
expect(linesUpdates.length).toBe(2);
expect(linesUpdates[0].fields).toEqual(["order_id"]);
});
test("Loading data", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
let orderCreates = [];
let orderUpdates = [];
models["pos.order"].addEventListener("create", (data) => {
orderCreates.push(data);
});
const order1 = models["pos.order"].create({});
models["pos.order"].addEventListener("update", (data) => {
orderUpdates.push(data);
});
expect(orderUpdates.length).toBe(0);
expect(orderCreates.length).toBe(1);
expect(orderCreates[0].ids).toEqual([order1.id]);
orderCreates = [];
orderUpdates = [];
models.connectNewData({
"pos.order": [
{
id: 1,
uuid: order1.uuid, //Update
},
{
id: 2,
},
{
id: 3,
},
],
});
expect(orderUpdates.length).toBe(1);
expect(orderCreates.length).toBe(1);
expect(orderCreates[0].ids).toEqual([2, 3]);
});
test("Connecting new data", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order1 = models["pos.order"].create({ id: 3333 });
const orderUpdates = [];
const lineUpdates = [];
const lineCreates = [];
models["pos.order"].addEventListener("update", (data) => {
orderUpdates.push(data);
});
models["pos.order.line"].addEventListener("create", (data) => {
lineCreates.push(data);
});
models["pos.order.line"].addEventListener("update", (data) => {
lineUpdates.push(data);
});
models.connectNewData({
"pos.order.line": [
{
id: 1,
order_id: order1.id,
},
],
});
expect(orderUpdates.length).toBe(1); // The new line is connected to the order and updates it
expect(lineUpdates.length).toBe(0);
expect(lineCreates.length).toBe(1);
});
test("Delete record", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
let orderUpdates = [];
const orderDeletes = [];
const order = models["pos.order"].create({});
models["pos.order"].addEventListener("update", (data) => {
orderUpdates.push(data);
});
models["pos.order"].addEventListener("delete", (data) => {
orderDeletes.push(data);
});
const linesUpdates = [];
models["pos.order.line"].create({ order_id: order.id });
models["pos.order.line"].create({ order_id: order.id });
models["pos.order.line"].addEventListener("update", (data) => {
linesUpdates.push(data);
});
expect(orderUpdates.length).toBe(2); // connecting lines to order
expect(orderDeletes.length).toBe(0);
expect(linesUpdates.length).toBe(0);
orderUpdates = [];
order.delete();
expect(orderDeletes.length).toBe(1);
expect(orderUpdates.length).toBe(0);
expect(linesUpdates.length).toBe(2);
expect(linesUpdates[0].fields).toEqual(["order_id"]);
});
});

View file

@ -0,0 +1,71 @@
import { expect, test } from "@odoo/hoot";
import { SERIALIZED_UI_STATE_PROP } from "@point_of_sale/app/models/related_models/utils";
import { getRelatedModelsInstance } from "../data/get_model_definitions";
import { makeMockServer } from "@web/../tests/web_test_helpers";
import { definePosModels } from "../data/generate_model_definitions";
import { getFilledOrder, setupPosEnv } from "../utils";
definePosModels();
test("newly created record", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order = models["pos.order"].create({ amount_total: 10 });
const line1 = models["pos.order.line"].create({
order_id: order,
qty: 1,
});
const line2 = models["pos.order.line"].create({
order_id: order,
qty: 2,
});
{
const result = order.serializeForIndexedDB();
expect(result.id).toBe(order.id);
expect(result.amount_total).toBe(order.amount_total);
expect(Array.isArray(result.lines)).toBe(true);
expect(result.lines.length).toBe(2);
expect(result.lines[0]).toBe(line1.id);
expect(result.lines[1]).toBe(line2.id);
expect(result[SERIALIZED_UI_STATE_PROP]).toBeEmpty();
}
{
const result = line1.serializeForIndexedDB();
expect(result.id).toBe(line1.id);
expect(result.qty).toBe(1);
expect(result[SERIALIZED_UI_STATE_PROP]).toBeEmpty();
}
});
test("UIState serialization", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order = models["pos.order"].create({ amount_total: 10 });
order.uiState = { demoValue: 99 };
const result = order.serializeForIndexedDB();
expect(result.id).toBe(order.id);
expect(result.amount_total).toBe(10);
expect(result[SERIALIZED_UI_STATE_PROP]).not.toBeEmpty();
expect(typeof result[SERIALIZED_UI_STATE_PROP]).toBe("string");
});
test("Restore serialized data", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
order.uiState = { demoValue: 999 };
const serialized = order.serializeForIndexedDB();
const serializedLines = order.lines.map((line) => line.serializeForIndexedDB());
store.data.localDeleteCascade(order);
const data = store.models.loadConnectedData({
"pos.order": [serialized],
"pos.order.line": serializedLines,
});
// UI state is restored
expect(data["pos.order"][0].uiState.demoValue).toBe(999);
// UIState must be excluded from the raw data
expect(data["pos.order"][0].raw.uiState).toBeEmpty();
});

View file

@ -0,0 +1,437 @@
import { expect, test } from "@odoo/hoot";
import { getRelatedModelsInstance } from "../data/get_model_definitions";
import { makeMockServer } from "@web/../tests/web_test_helpers";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
test("basic", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order = models["pos.order"].create({});
const line1 = models["pos.order.line"].create({ order_id: order, qty: 1 });
const line2 = models["pos.order.line"].create({ order_id: order, qty: 2 });
order.amount_total = 10;
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
{
expect(keepCommandResult).toEqual(result);
expect(result.uuid).not.toBeEmpty();
expect(result.amount_total).toBe(10);
expect(result.lines.length).toBe(2);
expect(result.lines[0][0]).toBe(0);
expect(result.lines[0][1]).toBe(0);
expect(result.lines[0][2].id).toBe(undefined);
expect(result.lines[0][2].uuid).toBe(line1.uuid);
expect(result.lines[0][2].qty).toBe(1);
expect(result.lines[1][0]).toBe(0);
expect(result.lines[1][1]).toBe(0);
expect(result.lines[1][2].id).toBe(undefined);
expect(result.lines[1][2].uuid).toBe(line2.uuid);
expect(result.lines[1][2].qty).toBe(2);
expect(result.relations_uuid_mapping).toBe(undefined);
}
// Server results with ids
models.connectNewData({
"pos.order": [{ ...order.raw, id: 1, lines: [11, 12] }],
"pos.order.line": [
{ ...line1.raw, id: 11, order_id: 1 },
{ ...line2.raw, id: 12, order_id: 1 },
],
});
line1.qty = 99;
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines.length).toBe(1);
expect(result.lines[0][0]).toBe(1);
expect(result.lines[0][1]).toBe(11);
expect(result.lines[0][2].qty).toBe(99);
}
// Delete line
line1.delete();
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines.length).toBe(1);
expect(result.lines[0][0]).toBe(3);
expect(result.lines[0][1]).toBe(11);
}
});
test("serialization of non-dynamic model relationships", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order = models["pos.order"].create({});
const tax1 = models["account.tax"].create({ id: 99 });
const tax2 = models["account.tax"].create({ id: 999 });
const line = models["pos.order.line"].create({ order_id: order, qty: 1 });
line.tax_ids = [["link", tax1, tax2]];
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines[0][2].tax_ids).toEqual([99, 999]);
}
const tax3 = models["account.tax"].create({ id: 9999 });
line.tax_ids = [["link", tax3]];
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines[0][2].tax_ids).toEqual([99, 999, 9999]);
}
line.tax_ids = [["unlink", tax3, tax2]];
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines[0][2].tax_ids).toEqual([99]);
}
line.tax_ids = [];
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines[0][2].tax_ids).toEqual([]);
}
});
test("serialization of dynamic model without uuid", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
let order = models["pos.order"].create({});
const orderUUID = order.uuid;
let line1 = models["pos.order.line"].create({
order_id: order,
});
const line1UUID = line1.uuid;
models["pos.pack.operation.lot"].create({ pos_order_line_id: line1, lot_name: "lot1" });
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines.length).toBe(1);
const pack_lot_ids = result.lines[0][2].pack_lot_ids;
expect(pack_lot_ids.length).toBe(1);
expect(pack_lot_ids[0][0]).toBe(0);
expect(pack_lot_ids[0][0]).toBe(0);
expect(pack_lot_ids[0][2]).toEqual({ lot_name: "lot1", write_date: false });
}
models.connectNewData({
"pos.order": [
{
id: 1,
lines: [11],
uuid: order.uuid,
},
],
"pos.order.line": [
{
id: 11,
pack_lot_ids: [99],
uuid: line1UUID,
},
],
"pos.pack.operation.lot": [
{
id: 99,
lot_name: "lot1",
},
],
});
order = models["pos.order"].getBy("uuid", orderUUID);
line1 = models["pos.order.line"].getBy("uuid", line1UUID);
models["pos.pack.operation.lot"].create({ pos_order_line_id: line1, lot_name: "lot2" });
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines.length).toBe(1);
const pack_lot_ids = result.lines[0][2].pack_lot_ids;
expect(pack_lot_ids.length).toBe(1);
expect(pack_lot_ids[0][0]).toBe(0);
expect(pack_lot_ids[0][1]).toBe(0);
expect(pack_lot_ids[0][2].lot_name).toEqual("lot2");
}
models.connectNewData({
"pos.order": [
{
id: 1,
lines: [11],
uuid: order.uuid,
},
],
"pos.order.line": [
{
id: 11,
order_id: 1,
pack_lot_ids: [99, 999],
uuid: line1UUID,
},
],
"pos.pack.operation.lot": [
{
id: 99,
pos_order_line_id: 11,
lot_name: "lot1",
},
{
id: 999,
pos_order_line_id: 11,
lot_name: "lot2",
},
],
});
order = models["pos.order"].getBy("uuid", orderUUID);
order.lines[0].pack_lot_ids[1].delete();
order.lines[0].pack_lot_ids[0].delete();
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines.length).toBe(1);
const pack_lot_ids = result.lines[0][2].pack_lot_ids;
expect(pack_lot_ids.length).toBe(2);
expect(pack_lot_ids).toEqual([
[3, 999],
[3, 99],
]);
}
});
test("nested lines relationship", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
let order = models["pos.order"].create({});
let parentLine = models["pos.order.line"].create({ order_id: order });
let line1 = models["pos.order.line"].create({
order_id: order,
combo_parent_id: parentLine,
});
let line2 = models["pos.order.line"].create({
order_id: order,
combo_parent_id: parentLine,
});
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines.length).toBe(3);
expect(result.lines[0][2].combo_line_ids).toBeEmpty();
expect(result.lines[0][2].combo_parent_id).toBeEmpty();
expect(result.lines[1][2].combo_line_ids).toBeEmpty();
expect(result.lines[1][2].combo_parent_id).toBeEmpty();
expect(result.lines[2][2].combo_line_ids).toBeEmpty();
expect(result.lines[2][2].combo_parent_id).toBeEmpty();
const { relations_uuid_mapping } = result;
expect(Object.keys(relations_uuid_mapping["pos.order.line"]).length).toBe(2);
expect(relations_uuid_mapping["pos.order.line"][line1.uuid]["combo_parent_id"]).toBe(
parentLine.uuid
);
expect(relations_uuid_mapping["pos.order.line"][line2.uuid]["combo_parent_id"]).toBe(
parentLine.uuid
);
}
models.connectNewData({
"pos.order": [
{
id: 1,
lines: [11, 111],
uuid: order.uuid,
},
],
"pos.order.line": [
{
id: 1,
uuid: parentLine.uuid,
},
{
id: 11,
uuid: line1.uuid,
combo_parent_id: 1,
},
{
id: 111,
uuid: line2.uuid,
combo_parent_id: 1,
},
],
});
// Update line: the uuid mapping must be present
order = models["pos.order"].getBy("uuid", order.uuid);
line1 = models["pos.order.line"].getBy("uuid", line1.uuid);
line2 = models["pos.order.line"].getBy("uuid", line2.uuid);
parentLine = models["pos.order.line"].getBy("uuid", parentLine.uuid);
line1.qty = 99;
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines.length).toBe(1);
expect(result.lines[0][0]).toBe(1);
expect(result.lines[0][1]).toBe(11);
expect(result.lines[0][2].qty).toBe(99);
expect(result.relations_uuid_mapping).toBe(undefined);
}
});
test("recursive relationship with group of lines", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
let order = models["pos.order"].create({});
let line1 = models["pos.order.line"].create({
order_id: order,
});
let line2 = models["pos.order.line"].create({
order_id: order,
combo_parent_id: line1,
});
let line3 = models["pos.order.line"].create({
order_id: order,
combo_parent_id: line1,
});
models["pos.order.line"].create({
order_id: order,
combo_parent_id: line1,
});
expect(order.lines.length).toBe(4);
expect(order.lines[0].combo_line_ids.length).toBe(3);
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines.length).toBe(4);
expect(result.lines[0][2].combo_parent_id).toBeEmpty();
expect(result.lines[1][2].combo_parent_id).toBeEmpty();
const { relations_uuid_mapping } = result;
expect(Object.keys(relations_uuid_mapping["pos.order.line"]).length).toBe(3);
expect(relations_uuid_mapping["pos.order.line"][line2.uuid]["combo_parent_id"]).toBe(
line1.uuid
);
expect(relations_uuid_mapping["pos.order.line"][line3.uuid]["combo_parent_id"]).toBe(
line1.uuid
);
}
models.connectNewData({
"pos.order": [
{
id: 1,
lines: [110, 111],
uuid: order.uuid,
},
],
"pos.order.line": [
{
id: 1,
uuid: line1.uuid,
},
{
id: 110,
order_id: 1,
uuid: line2.uuid,
combo_parent_id: 1,
},
{
id: 111,
order_id: 1,
uuid: line3.uuid,
combo_parent_id: 1,
},
],
});
order = models["pos.order"].getBy("uuid", order.uuid);
line1 = models["pos.order.line"].getBy("uuid", line1.uuid);
line2 = models["pos.order.line"].getBy("uuid", line2.uuid);
line3 = models["pos.order.line"].getBy("uuid", line3.uuid);
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.relations_uuid_mapping).toBe(undefined);
}
// Delete line
line3.delete();
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines.length).toBe(2);
expect(result.lines[0][0]).toBe(3);
expect(result.lines[0][1]).toBe(111);
expect(result.relations_uuid_mapping).toBe(undefined);
}
{
//All update/delete have been cleared
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines).toHaveLength(1);
}
});
test("grouped lines and nested lines", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order = models["pos.order"].create({});
const parentLine = models["pos.order.line"].create({ order_id: order });
const line1 = models["pos.order.line"].create({
order_id: order,
combo_parent_id: parentLine,
});
const line2 = models["pos.order.line"].create({
order_id: order,
combo_parent_id: parentLine,
});
const line3 = models["pos.order.line"].create({
order_id: order,
});
{
const keepCommandResult = models.serializeForORM(order, { keepCommands: true });
const result = models.serializeForORM(order);
expect(keepCommandResult).toEqual(result);
expect(result.lines.length).toBe(4);
const { relations_uuid_mapping } = result;
expect(Object.keys(relations_uuid_mapping["pos.order.line"]).length).toBe(2);
const lineMapping = relations_uuid_mapping["pos.order.line"];
expect(lineMapping[line1.uuid]["combo_parent_id"]).toBe(parentLine.uuid);
expect(lineMapping[line2.uuid]["combo_parent_id"]).toBe(parentLine.uuid);
expect(lineMapping[line3.uuid]?.["combo_parent_id"]?.length).toBeEmpty();
}
});

View file

@ -0,0 +1,663 @@
import { describe, expect, test } from "@odoo/hoot";
import { getRelatedModelsInstance } from "../data/get_model_definitions";
import { makeMockServer } from "@web/../tests/web_test_helpers";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
describe("models with backlinks", () => {
describe("many2one and one2many field relations to other models", () => {
test("create operation", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const category = models["pos.category"].create({});
const product = models["product.template"].create({ pos_categ_ids: [category] });
expect(product.pos_categ_ids).toInclude(category);
expect(category.backLink("product.template.pos_categ_ids")).toInclude(product);
});
test("read operation 1", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const c2 = models["pos.category"].create({});
const p1 = models["product.template"].create({ pos_categ_ids: [c1] });
const p2 = models["product.template"].create({ pos_categ_ids: [c1] });
const p3 = models["product.template"].create({ pos_categ_ids: [c2] });
// Test reading back the categories directly
const readC1 = models["pos.category"].read(c1.id);
expect(readC1).toEqual(c1);
const readP1 = models["product.template"].read(p1.id);
expect(readP1).toEqual(p1);
// Test the many2one relationship from products to category
expect(readP1.pos_categ_ids).toEqual([c1]);
// Additional checks for completeness
const readMany = models["product.template"].readMany([p2.id, p3.id]);
expect(readMany).toEqual([p2, p3]);
const readNonExistent = models["product.template"].read(9999);
expect(readNonExistent).toBe(undefined);
const readNonExistentC = models["pos.category"].read(9999);
expect(readNonExistentC).toBe(undefined);
});
test("update operation, many2one", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const p1 = models["product.template"].create({});
const c1 = models["product.category"].create({});
p1.update({ categ_id: c1 });
expect(p1.categ_id).toBe(c1);
});
test("update operation, one2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const l1 = models["pos.order.line"].create({});
const l2 = models["pos.order.line"].create({});
const order1 = models["pos.order"].create({});
order1.update({ lines: [["link", l1, l2]] });
expect(order1.lines).toInclude(l1);
expect(order1.lines).toInclude(l2);
expect(l1.order_id).toBe(order1);
});
test("update operation, unlink many2one", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const o1 = models["pos.order"].create({ lines: [] });
const l1 = models["pos.order.line"].create({
order_id: o1,
});
expect(l1.order_id).toEqual(o1);
o1.update({ lines: [] });
expect(l1.order_id).toBe(undefined);
});
test("update operation, unlink one2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const o1 = models["pos.order"].create({ lines: [] });
const l1 = models["pos.order.line"].create({
order_id: o1,
});
o1.update({ lines: [["link", l1]] });
expect(o1.lines).toInclude(l1);
expect(l1.order_id).toBe(o1);
o1.update({ lines: [["unlink", l1]] });
expect(o1.lines).not.toInclude(l1);
expect(l1.order_id).toBe(undefined);
});
test("update operation, Clear one2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const o1 = models["pos.order"].create({});
const l1 = models["pos.order.line"].create({
order_id: o1,
});
o1.update({ lines: [["link", l1]] });
expect(o1.lines).toInclude(l1);
expect(l1.order_id).toBe(o1);
o1.update({ lines: [["unlink", l1]] });
expect(o1.lines).not.toInclude(l1);
expect(l1.order_id).toBe(undefined);
});
test("update operation, Clear many2one", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const o1 = models["pos.order"].create({ lines: [] });
models["pos.order.line"].create({
order_id: o1,
});
models["pos.order"].update(o1, { lines: [] });
expect(o1.lines).toHaveLength(0);
});
test("delete operation, one2many item", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const l1 = models["pos.order.line"].create({});
const l2 = models["pos.order.line"].create({});
const o1 = models["pos.order"].create({});
o1.update({ lines: [["link", l1, l2]] });
expect(o1.lines).toInclude(l1);
l1.delete();
expect(models["pos.order.line"].read(l1.id)).toBe(undefined);
expect(o1.lines).not.toInclude(l1);
});
test("delete operation, many2one item", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const o1 = models["pos.order"].create({});
const l1 = models["pos.order.line"].create({});
o1.update({ lines: [["link", l1]] });
expect(l1.order_id).toBe(o1);
l1.delete();
expect(models["pos.order.line"].read(l1.id)).toBe(undefined);
expect(o1.lines).not.toInclude(l1);
});
});
describe("many2one/one2many field relations to own model", () => {
test("create operation", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const o1 = models["pos.order"].create({});
const l2 = models["pos.order.line"].create({ order_id: o1 });
expect(l2.order_id).toBe(o1);
expect(o1.lines).toInclude(l2);
});
test("read operation 2", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const c2 = models["pos.category"].create({ parent_id: c1 });
const readC1 = models["pos.category"].read(c1.id);
expect(readC1.child_ids).toEqual([c2]);
const readC2 = models["pos.category"].read(c2.id);
expect(readC2.parent_id).toEqual(c1);
const readMany = models["pos.category"].readMany([c1.id, c2.id]);
expect(readMany).toEqual([c1, c2]);
});
test("update operation, many2one", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const o1 = models["pos.order"].create({});
const l1 = models["pos.order.line"].create({});
const l2 = models["pos.order.line"].create({ order_id: o1 });
expect(l2.order_id).toBe(o1);
o1.update({ lines: [l1] });
expect(o1.lines).not.toInclude(l2);
});
test("update operation, one2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const o1 = models["pos.order"].create({});
const l1 = models["pos.order.line"].create({ order_id: o1 });
const l2 = models["pos.order.line"].create({});
expect(o1.lines).toInclude(l1);
l2.update({ order_id: o1 });
expect(o1.lines).toInclude(l2);
});
test("update operation, unlink many2one", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const c2 = models["pos.category"].create({});
c2.update({ parent_id: c1 });
expect(c2.parent_id).toBe(c1);
c2.update({ parent_id: undefined });
expect(c2.parent_id).toBe(undefined);
expect(c1.child_ids).not.toInclude(c2);
});
test("update operation, unlink one2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const c2 = models["pos.category"].create({ parent_id: c1 });
expect(c1.child_ids).toInclude(c2);
c1.update({ child_ids: [["unlink", c2]] });
expect(c1.child_ids).not.toInclude(c2);
expect(c2.parent_id).toBe(undefined);
});
test("update operation, Clear one2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const category = models["pos.category"].create({});
models["pos.category"].create({ parent_id: category });
models["pos.category"].create({ parent_id: category });
expect(category.child_ids).toHaveLength(2);
models["pos.category"].update(category, { child_ids: [["clear"]] });
expect(category.child_ids).toHaveLength(0);
});
test("update operation, Clear many2one", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const category = models["pos.category"].create({});
const category1 = models["pos.category"].create({ parent_id: category });
expect(category.child_ids).toInclude(category1);
models["pos.category"].update(category1, { parent_id: undefined });
expect(category.child_ids).not.toInclude(category1);
});
test("delete operation, one2many item", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const c2 = models["pos.category"].create({ parent_id: c1 });
expect(c1.child_ids).toInclude(c2);
c2.delete();
expect(models["pos.category"].read(c2.id)).toBe(undefined);
expect(c1.child_ids).not.toInclude(c2);
});
test("delete operation, many2one item", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const c2 = models["pos.category"].create({ parent_id: c1 });
expect(c1.child_ids).toInclude(c2);
c1.delete();
expect(models["pos.category"].read(c1.id)).toBe(undefined);
expect(c2.parent_id).toBe(undefined);
});
});
describe("many2many field relations to other models", () => {
test("create operation, create", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const line1 = { id: 2 };
const line2 = { name: 4 };
const product = models["pos.order"].create({
name: "Smartphone",
lines: [["create", line1, line2]],
});
expect(product.lines).toHaveLength(2);
});
test("create operation, link", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const line1 = models["pos.order.line"].create({ id: 1 });
const line2 = models["pos.order.line"].create({ id: 2 });
const product = models["pos.order"].create({
name: "Smartphone",
lines: [["link", line1, line2]],
});
expect(product.lines).toInclude(line1);
expect(product.lines).toInclude(line2);
});
test("read operation 3", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const c2 = models["pos.category"].create({});
const c3 = models["pos.category"].create({});
const t1 = models["product.template"].create({ pos_categ_ids: [["link", c1, c2, c3]] });
const t2 = models["product.template"].create({ pos_categ_ids: [["link", c1, c2]] });
const readT1 = models["product.template"].read(t1.id);
expect(readT1).toEqual(t1);
const readP1 = models["product.template"].read(t2.id);
expect(readP1).toEqual(t2);
expect(readT1.pos_categ_ids).toInclude(c1);
expect(readT1.pos_categ_ids).toInclude(c2);
expect(readT1.pos_categ_ids).toInclude(c3);
expect(readP1.pos_categ_ids).toInclude(c1);
expect(readP1.pos_categ_ids).toInclude(c2);
const readMany = models["product.template"].readMany([t1.id, t2.id]);
expect(readMany[0]).toBe(t1);
expect(readMany[1]).toBe(t2);
});
test("update operation, many2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const c2 = models["pos.category"].create({});
const t1 = models["product.template"].create({ pos_categ_ids: [] });
expect(t1.pos_categ_ids).not.toInclude(c1);
t1.update({ pos_categ_ids: [["link", c1]] });
expect(t1.pos_categ_ids).toInclude(c1);
expect(t1.pos_categ_ids).not.toInclude(c2);
t1.update({ pos_categ_ids: [["link", c2]] });
expect(t1.pos_categ_ids).toInclude(c2);
});
test("update operation, unlink many2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const t1 = models["product.template"].create({ pos_categ_ids: [] });
t1.update({ pos_categ_ids: [["link", c1]] });
expect(t1.pos_categ_ids).toInclude(c1);
t1.update({ pos_categ_ids: [["unlink", c1]] });
expect(t1.pos_categ_ids).not.toInclude(c1);
});
test("update operation, Clear many2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const c2 = models["pos.category"].create({});
const t1 = models["product.template"].create({ pos_categ_ids: [c1, c2] });
expect(t1.pos_categ_ids).toHaveLength(2);
t1.update({ pos_categ_ids: [["clear"]] });
expect(t1.pos_categ_ids).toHaveLength(0);
});
test("delete operation, many2many item", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const c2 = models["pos.category"].create({});
const t1 = models["product.template"].create({ pos_categ_ids: [] });
t1.update({ pos_categ_ids: [["link", c1, c2]] });
expect(t1.pos_categ_ids).toInclude(c1);
c1.delete();
expect(models["pos.category"].read(c1.id)).toBe(undefined);
expect(t1.pos_categ_ids).not.toInclude(c1);
expect(t1.pos_categ_ids).toInclude(c2);
});
describe("many2many field relations to own model", () => {
test("create operation, link", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const l1 = models["pos.order.line"].create({ id: 1 });
const l2 = models["pos.order.line"].create({ id: 2, combo_parent_id: l1 });
expect(l1.combo_line_ids).toInclude(l2);
expect(l2.combo_parent_id).toBe(l1);
});
test("read operation 4", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const l1 = models["pos.order.line"].create({});
const l2 = models["pos.order.line"].create({});
const l3 = models["pos.order.line"].create({});
const l4 = models["pos.order.line"].create({ combo_line_ids: [l1, l2, l3] });
const readL1 = models["pos.order.line"].read(l1.id);
expect(readL1).toEqual(l1);
const readL4 = models["pos.order.line"].read(l4.id);
expect(readL4).toEqual(l4);
expect([l1, l2, l3].every((n) => readL4.combo_line_ids.includes(n))).toBe(true);
const readMany = models["pos.order.line"].readMany([l2.id, l3.id]);
expect(readMany[0]).toBe(l2);
expect(readMany[1]).toBe(l3);
});
test("update operation, many2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const l1 = models["pos.order.line"].create({});
const l2 = models["pos.order.line"].create({});
const l3 = models["pos.order.line"].create({});
l1.update({ combo_line_ids: [["link", l3]] });
expect(l1.combo_line_ids).toInclude(l3);
expect(l3.combo_parent_id).toBe(l1);
l3.update({ combo_line_ids: [["link", l2]] });
expect(l3.combo_line_ids).toInclude(l2);
l3.update({ combo_line_ids: [["unlink", l2]] });
expect(l3.combo_line_ids).not.toInclude(l2);
expect(l2.combo_line_ids).not.toInclude(l3);
});
test("update operation, unlink many2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const l1 = models["pos.order.line"].create({});
const l2 = models["pos.order.line"].create({});
l2.update({ combo_line_ids: [["link", l1]] });
expect(l2.combo_line_ids).toInclude(l1);
expect(l1.combo_parent_id).toBe(l2);
l2.update({ combo_line_ids: [["unlink", l1]] });
expect(l2.combo_line_ids).not.toInclude(l1);
expect(l1.combo_parent_id).toBeEmpty();
});
test("update operation, Clear many2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const l1 = models["pos.order.line"].create({});
const l2 = models["pos.order.line"].create({});
const l3 = models["pos.order.line"].create({});
const l4 = models["pos.order.line"].create({ combo_line_ids: [l1, l2, l3] });
expect(l4.combo_line_ids).toHaveLength(3);
models["pos.order.line"].update(l4, { combo_line_ids: [["clear"]] });
expect(l4.combo_line_ids).toHaveLength(0);
expect(l1.combo_parent_id).toBeEmpty();
});
test("delete operation, many2many item", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const l1 = models["pos.order.line"].create({});
const l2 = models["pos.order.line"].create({});
const l3 = models["pos.order.line"].create({});
l3.update({ combo_line_ids: [["link", l1, l2]] });
expect([l1, l2].every((n) => l3.combo_line_ids.includes(n))).toBe(true);
l1.delete();
expect(models["pos.order.line"].read(l1.id)).toBe(undefined);
expect(l3.combo_line_ids).not.toInclude(l1);
});
});
});
});
describe("models without backlinks", () => {
describe("many2one and one2many field relations to other models", () => {
test("create operation", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const category = models["pos.category"].create({ id: 1 });
const product = models["product.template"].create({ pos_categ_ids: [category] });
expect(product.pos_categ_ids).toEqual([category]);
expect(category.backLink("<-product.template.pos_categ_ids")).toEqual([product]);
});
test("read operation 5", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const p1 = models["product.template"].create({ pos_categ_ids: [c1] });
const p2 = models["product.template"].create({ pos_categ_ids: [c1] });
const readC1 = models["pos.category"].read(c1.id);
expect(readC1).toEqual(c1);
const readP1 = models["product.template"].read(p1.id);
expect(readP1).toEqual(p1);
expect(readC1.backLink("product.template.pos_categ_ids")).toEqual([p1, p2]);
expect(readP1.pos_categ_ids).toEqual([c1]);
});
test("update operation, many2one", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const p1 = models["product.template"].create({});
const c1 = models["pos.category"].create({});
p1.update({ pos_categ_ids: [c1] });
expect(p1.pos_categ_ids).toInclude(c1);
});
test("update operation, unlink many2one", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const categ = models["pos.category"].create({});
const p1 = models["product.template"].create({ pos_categ_ids: [categ] });
p1.update({ pos_categ_ids: [] });
expect(p1.pos_categ_ids).toHaveLength(0);
expect(categ.backLink("product.template.pos_categ_ids")).toHaveLength(0);
});
test("delete operation, many2one item", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const p1 = models["product.template"].create({});
const c1 = models["pos.category"].create({});
p1.update({ pos_categ_ids: [c1] });
expect(c1.backLink("<-product.template.pos_categ_ids")).toEqual([p1]);
c1.delete();
expect(models["pos.category"].read(c1.id)).toBe(undefined);
expect(p1.pos_categ_ids).toHaveLength(0);
});
});
describe("many2many relations", () => {
test("create operation, link", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const l1 = models["pos.order.line"].create({});
const l2 = models["pos.order.line"].create({});
const l3 = models["pos.order.line"].create({});
const o1 = models["pos.order"].create({
lines: [["link", l1, l2, l3]],
});
expect(o1.lines).toInclude(l1);
});
test("read operation 6", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const c1 = models["pos.category"].create({});
const c2 = models["pos.category"].create({});
const p1 = models["product.template"].create({
pos_categ_ids: [["link", c1, c2]],
});
const p2 = models["product.template"].create({
pos_categ_ids: [["link", c1, c2]],
});
const p3 = models["product.template"].create({ pos_categ_ids: [["link", c1]] });
const readT1 = models["product.template"].read(p1.id);
expect(readT1).toEqual(p1);
const readP1 = models["product.template"].read(p2.id);
expect(readP1).toEqual(p2);
const readMany = models["product.template"].readMany([p2.id, p3.id]);
expect(readMany).toEqual([p2, p3]);
});
test("update operation, link", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const p1 = models["product.template"].create({});
const p2 = models["product.template"].create({});
const t1 = models["pos.category"].create({});
p1.update({ pos_categ_ids: [["link", t1]] });
expect(p1.pos_categ_ids).toInclude(t1);
expect(t1.backLink("<-product.template.pos_categ_ids")).toInclude(p1);
expect(t1.backLink("<-product.template.pos_categ_ids")).not.toInclude(p2);
p2.update({ pos_categ_ids: [["link", t1]] });
expect(t1.backLink("<-product.template.pos_categ_ids")).toInclude(p2);
});
test("update operation, unlink", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const p1 = models["product.template"].create({});
const t1 = models["pos.category"].create({});
p1.update({ pos_categ_ids: [["link", t1]] });
expect(t1.backLink("<-product.template.pos_categ_ids")).toInclude(p1);
expect(p1.pos_categ_ids).toInclude(t1);
p1.update({ pos_categ_ids: [["unlink", t1]] });
expect(t1.backLink("<-product.template.pos_categ_ids")).not.toInclude(p1);
expect(p1.pos_categ_ids).toHaveLength(0);
});
test("update operation, Clear", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const tag1 = models["pos.category"].create({});
const tag2 = models["pos.category"].create({});
const product = models["product.template"].create({ pos_categ_ids: [tag1, tag2] });
models["product.template"].update(product, { pos_categ_ids: [["clear"]] });
const updatedProduct = models["product.template"].read(product.id);
expect(updatedProduct.pos_categ_ids).toHaveLength(0);
models["product.template"].update(product, { pos_categ_ids: [["link", tag1, tag2]] });
expect([tag1, tag2].every((t) => product.pos_categ_ids.includes(t))).toBe(true);
models["product.template"].update(product, { pos_categ_ids: [["clear"]] });
expect(tag1.backLink("<-product.template.pos_categ_ids")).not.toInclude(product);
});
test("delete operation", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const p1 = models["product.template"].create({});
const p2 = models["product.template"].create({});
const t1 = models["pos.category"].create({});
p1.update({ pos_categ_ids: [["link", t1]] });
p2.update({ pos_categ_ids: [["link", t1]] });
expect(t1.backLink("<-product.template.pos_categ_ids")).toInclude(p1);
p1.delete();
expect(models["product.template"].read(p1.id)).toBe(undefined);
expect(t1.backLink("<-product.template.pos_categ_ids")).not.toInclude(p1);
t1.delete();
expect(models["pos.category"].read(t1.id)).toBe(undefined);
expect(p1.pos_categ_ids).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,179 @@
import { expect, test, describe } from "@odoo/hoot";
import { uuidv4 } from "@point_of_sale/utils";
import { getRelatedModelsInstance } from "../data/get_model_definitions";
import { makeMockServer } from "@web/../tests/web_test_helpers";
import { definePosModels } from "../data/generate_model_definitions";
const { DateTime } = luxon;
definePosModels();
describe("Dirty record", () => {
test("field update", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order = models["pos.order"].create({});
expect(order.isDirty()).toBe(true);
order.amount_total = 23.5;
models.serializeForORM(order, { orm: true });
// Setting the same value must not mark the record as dirty.
expect(order.isDirty()).toBe(false);
order.amount_total = 23.5;
expect(order.isDirty()).toBe(false);
order.amount_total = 25;
expect(order.isDirty()).toBe(true);
models.serializeForORM(order, { orm: true });
expect(order.isDirty()).toBe(false);
order.update({ amount_total: 26 });
expect(order.isDirty()).toBe(true);
});
test("model creation", async () => {
// Models created with a numeric ID are not considered dirty by default
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order = models["pos.order"].create({ id: 12 });
expect(order.isDirty()).toBe(false);
order.amount_total = 23.5;
expect(order.isDirty()).toBe(true);
});
test("load data", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const sampleUUID = uuidv4();
// When loading data, the dirty flag must not be updated.
models.loadConnectedData({
"pos.order": [
{
id: 13,
amount_total: 30,
uuid: sampleUUID,
},
],
});
const order = models["pos.order"].getBy("uuid", sampleUUID);
expect(order.id).toBe(13);
expect(order.amount_total).toBe(30);
expect(order.isDirty()).toBe(false);
});
test("related record update", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order = models["pos.order"].create({ id: 12 });
expect(order.isDirty()).toBe(false);
function clearOrder() {
models.serializeForORM(order, { orm: true });
expect(order.isDirty()).toBe(false);
}
// Add new line to the order
const line = models["pos.order.line"].create({
qty: 1,
order_id: order,
});
expect(line.isDirty()).toBe(true);
expect(order.isDirty()).toBe(true);
clearOrder();
expect(line.isDirty()).toBe(false);
// Assign a product to the line
const sampleProduct = models["product.product"].create({ name: "demo_product", id: 111 });
line.product_id = sampleProduct;
expect(line.isDirty()).toBe(true);
expect(order.isDirty()).toBe(true);
clearOrder();
expect(line.isDirty()).toBe(false);
// Update line quantity
line.qty = 10;
expect(line.isDirty()).toBe(true);
expect(order.isDirty()).toBe(true);
clearOrder();
order.lines[0].qty = 1000;
expect(line.isDirty()).toBe(true);
expect(order.isDirty()).toBe(true);
clearOrder();
// Delete product from line
line.product_id = null;
expect(order.isDirty()).toBe(true);
clearOrder();
line.delete();
expect(order.isDirty()).toBe(true);
expect(order.lines.length).toBe(0);
});
test("many2many", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order = models["pos.order"].create({ id: 12 });
function clearOrder() {
models.serializeForORM(order, { orm: true });
expect(order.isDirty()).toBe(false);
}
const att1 = models["product.template.attribute.value"].create({ id: 99 });
const line = models["pos.order.line"].create({ id: 100, order_id: order, qty: 1 });
line.update({ attribute_value_ids: [["link", att1]] });
expect(line.isDirty()).toBe(true);
expect(order.isDirty()).toBe(true);
clearOrder();
const att2 = models["product.template.attribute.value"].create({ id: 999 });
line.update({ attribute_value_ids: [["link", att2]] });
expect(line.isDirty()).toBe(true);
expect(order.isDirty()).toBe(true);
clearOrder();
line.update({ attribute_value_ids: [["unlink", att1]] });
expect(line.isDirty()).toBe(true);
expect(order.isDirty()).toBe(true);
});
test("datetime type", async () => {
await makeMockServer();
const models = getRelatedModelsInstance(false);
const order = models["pos.order"].create({ id: 12 });
function clearOrder() {
models.serializeForORM(order, { orm: true });
expect(order.isDirty()).toBe(false);
}
expect(order.isDirty()).toBe(false);
order.date_order = undefined;
expect(order.isDirty()).toBe(false);
// Other valid DateTime
clearOrder();
order.date_order = DateTime.local(2025, 1, 1, 9, 30);
expect(order.isDirty()).toBe(true);
// Same DateTime
clearOrder();
order.date_order = DateTime.local(2025, 1, 1, 9, 30);
expect(order.isDirty()).toBe(false);
// Different DateTime
clearOrder();
order.date_order = DateTime.local(2028, 1, 1, 10, 30);
expect(order.isDirty()).toBe(true);
// Set to false / null
clearOrder();
order.date_order = false;
expect(order.isDirty()).toBe(true);
clearOrder();
order.date_order = null;
expect(order.isDirty()).toBe(false);
});
});

View file

@ -0,0 +1,26 @@
import { test, describe, expect } from "@odoo/hoot";
import { convertRawToDate, convertDateToRaw } from "@point_of_sale/app/models/related_models/utils";
const { DateTime } = luxon;
describe("Date conversion utilities", () => {
const mockModel = { model: "test.model" };
test("convertRawToDate", () => {
const rawDate = "2023-12-25";
const result = convertRawToDate(mockModel, rawDate, "date_field");
expect(result).toBeInstanceOf(DateTime);
expect(result.isValid).toBe(true);
expect(convertRawToDate(mockModel, null, "date_field")).toBe(undefined);
expect(convertRawToDate(mockModel, undefined, "date_field")).toBe(undefined);
expect(() => convertRawToDate(mockModel, "invalid", "date_field")).toThrow();
});
test("convertDateToRaw", () => {
const dateTime = DateTime.fromISO("2023-12-25");
const result = convertDateToRaw(dateTime);
expect(result).toBe("2023-12-25"); // directly check the string value
expect(convertDateToRaw(null)).toBe(undefined);
expect(convertDateToRaw(undefined)).toBe(undefined);
expect(convertDateToRaw("2023-12-25")).toBe("2023-12-25"); // should return the same string
});
});

View file

@ -0,0 +1,19 @@
import { test, expect, describe } from "@odoo/hoot";
import { getFilledOrder, setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
describe("data_service", () => {
test("localDeleteCascade", async () => {
const store = await setupPosEnv();
const data = store.data;
const order = await getFilledOrder(store);
expect(store.models["pos.order"].length).toBe(1);
expect(store.models["pos.order.line"].length).toBe(2);
data.localDeleteCascade(order);
expect(store.models["pos.order"].length).toBe(0);
expect(store.models["pos.order.line"].length).toBe(0);
});
});

View file

@ -0,0 +1,36 @@
import { test, expect, describe } from "@odoo/hoot";
import { setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
describe("printer_service.js", () => {
test("print should work whith printer in hardware_proxy", async () => {
const store = await setupPosEnv();
const printerService = store.env.services.printer;
// Mock renderer
printerService.renderer = {
toHtml: async () => document.createElement("div"),
whenMounted: ({ callback, el }) => callback(el),
};
// Mock printer
const mockPrinter = {
printReceipt: async () => ({ successful: true }),
};
store.env.services.hardware_proxy.printer = mockPrinter;
// Spy log
const originalConsoleLog = console.log;
const consoleLogCalls = [];
console.log = (...args) => {
consoleLogCalls.push(args);
};
try {
const lenBefore = consoleLogCalls.length;
await printerService.print(() => {}, {}, {});
const lenAfter = consoleLogCalls.length;
expect(lenAfter).toBe(lenBefore);
} finally {
console.log = originalConsoleLog;
}
});
});

View file

@ -0,0 +1,644 @@
import { test, expect, describe } from "@odoo/hoot";
import { getFilledOrder, setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
import { ConnectionLostError } from "@web/core/network/rpc";
import { onRpc } from "@web/../tests/web_test_helpers";
import { imageUrl } from "@web/core/utils/urls";
import { prepareRoundingVals } from "../accounting/utils";
const { DateTime } = luxon;
definePosModels();
describe("pos_store.js", () => {
test("setTip", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store); // Should have 2 lines
expect(order.lines.length).toBe(2);
await store.setTip(50);
expect(order.is_tipped).toBe(true);
expect(order.tip_amount).toBe(50);
expect(order.lines.length).toBe(3); // 2 original lines + 1 tip line
});
test("orderNoteFormat", async () => {
const store = await setupPosEnv();
const str = store.getStrNotes("string");
expect(str).toBeOfType("string");
expect(str).toBe("string");
const json2str = store.getStrNotes([{ text: "json", colorIndex: 0 }]);
expect(json2str).toBeOfType("string");
expect(json2str).toBe("json");
});
test("connectNewData canceled order", async () => {
const store = await setupPosEnv();
const models = store.models;
const order = await getFilledOrder(store);
const serializedOrder = { ...order.raw };
await store.deleteOrders([order]);
serializedOrder.state = "cancel";
let isListenerCalled = false;
const listenerCleanup = models["pos.order"].addEventListener("update", (data) => {
if (data.id === order.id) {
const orderToRecompute = models["pos.order"].get(data.id);
orderToRecompute.triggerRecomputeAllPrices();
isListenerCalled = true;
}
});
models.connectNewData({
"pos.order": [serializedOrder],
});
const updatedOrder = models["pos.order"].get(order.id);
expect(updatedOrder.state).toBe("cancel");
expect(isListenerCalled).toBe(true);
listenerCleanup();
});
test("sendOrderInPreparation clears change state when no prep changes", async () => {
const store = await setupPosEnv();
const order = store.addNewOrder();
const productOutsidePrep = store.models["product.template"].get(14);
const date = DateTime.now();
await store.addLineToOrder(
{
product_tmpl_id: productOutsidePrep,
qty: 1,
write_date: date,
create_date: date,
},
order
);
expect(order.hasChange).toBe(true);
let printCalled = false;
store.printChanges = async () => {
printCalled = true;
return true;
};
await store.sendOrderInPreparation(order);
expect(printCalled).toBe(false);
expect(order.hasChange).toBe(false);
});
describe("syncAllOrders", () => {
test("simple sync", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
expect(store.getPendingOrder().orderToCreate).toHaveLength(1);
expect(order.lines).toHaveLength(2);
expect(order.lines[0].id).toBeOfType("string");
expect(order.lines[1].id).toBeOfType("string");
await store.syncAllOrders();
// Object should be updated in place
expect(store.getPendingOrder().orderToCreate).toHaveLength(0);
expect(order.lines).toHaveLength(2);
expect(order.lines[0].id).toBeOfType("number");
expect(order.lines[1].id).toBeOfType("number");
const noSync = await store.syncAllOrders();
expect(noSync).toBe(undefined);
expect(store.models["pos.order"].length).toBe(1);
});
test("sync specific order", async () => {
const store = await setupPosEnv();
const order1 = await getFilledOrder(store);
const order2 = await getFilledOrder(store);
expect(store.getPendingOrder().orderToCreate).toHaveLength(2);
expect(order1.lines).toHaveLength(2);
expect(order1.lines[0].id).toBeOfType("string");
expect(order1.lines[1].id).toBeOfType("string");
expect(order2.lines).toHaveLength(2);
expect(order2.lines[0].id).toBeOfType("string");
expect(order2.lines[1].id).toBeOfType("string");
await store.syncAllOrders({ orders: [order1] });
expect(store.getPendingOrder().orderToCreate).toHaveLength(1);
expect(order1.lines).toHaveLength(2);
expect(order1.lines[0].id).toBeOfType("number");
expect(order1.lines[1].id).toBeOfType("number");
expect(order2.lines).toHaveLength(2);
expect(order2.lines[0].id).toBeOfType("string");
expect(order2.lines[1].id).toBeOfType("string");
const data = await store.syncAllOrders();
expect(data).toHaveLength(1);
expect(store.getPendingOrder().orderToCreate).toHaveLength(0);
expect(order2.lines).toHaveLength(2);
expect(order2.lines[0].id).toBeOfType("number");
expect(order2.lines[1].id).toBeOfType("number");
});
test("sync no network should not raise error", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
expect(store.getPendingOrder().orderToCreate).toHaveLength(1);
expect(order.lines).toHaveLength(2);
expect(order.lines[0].id).toBeOfType("string");
expect(order.lines[1].id).toBeOfType("string");
store.data.network.offline = true;
const data = await store.syncAllOrders();
expect(data).toBeInstanceOf(ConnectionLostError);
expect(store.getPendingOrder().orderToCreate).toHaveLength(1);
expect(order.lines).toHaveLength(2);
expect(order.lines[0].id).toBeOfType("string");
expect(order.lines[1].id).toBeOfType("string");
});
test("insync order should not be re-synced", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
expect(store.getPendingOrder().orderToCreate).toHaveLength(1);
expect(order.lines).toHaveLength(2);
expect(order.lines[0].id).toBeOfType("string");
expect(order.lines[1].id).toBeOfType("string");
store.syncingOrders.add(order.uuid);
const data = await store.syncAllOrders();
expect(store.getPendingOrder().orderToCreate).toHaveLength(1);
expect(data).toBeEmpty();
expect(order.lines).toHaveLength(2);
expect(order.lines[0].id).toBeOfType("string");
expect(order.lines[1].id).toBeOfType("string");
});
});
test("addLineToCurrentOrder", async () => {
const store = await setupPosEnv();
store.setOrder(null);
expect(store.getOrder()).toBe(undefined);
// Should create order if none exist
const product = store.models["product.product"].get(5);
await store.addLineToCurrentOrder({ product_tmpl_id: product.product_tmpl_id });
expect(store.getOrder()).not.toBe(undefined);
expect(store.getOrder().lines.length).toBe(1);
expect(store.getOrder().lines[0].product_id.id).toBe(product.id);
expect(store.getOrder().lines[0].qty).toBe(1);
await store.addLineToCurrentOrder({ product_tmpl_id: product.product_tmpl_id, qty: 3 }, {});
expect(store.getOrder().lines[0].qty).toBe(4);
});
test("changesToOrderNoPrepCateg", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const orderChange = store.changesToOrder(order, new Set([]), false);
expect(orderChange.new.length).toBe(0);
expect(orderChange.cancelled.length).toBe(0);
});
test("orderContainsProduct", async () => {
const store = await setupPosEnv();
await getFilledOrder(store);
const product1 = store.models["product.template"].get(5);
const product2 = store.models["product.template"].get(6);
const product3 = store.models["product.template"].get(8);
expect(store.orderContainsProduct(product1)).toBe(true);
expect(store.orderContainsProduct(product2)).toBe(true);
expect(store.orderContainsProduct(product3)).toBe(false);
const order = await store.addNewOrder();
await store.addLineToOrder(
{
product_tmpl_id: product3,
qty: 1,
},
order
);
expect(store.orderContainsProduct(product1)).toBe(true);
expect(store.orderContainsProduct(product2)).toBe(true);
expect(store.orderContainsProduct(product3)).toBe(true);
});
test("generateReceiptsDataToPrint", async () => {
const store = await setupPosEnv();
const pos_categories = store.models["pos.category"].getAll().map((c) => c.id);
const order = await getFilledOrder(store);
order.lines[1].setNote('[{"text":"Wait","colorIndex":0}]');
order.lines[0].setCustomerNote("Test Orderline Customer Note");
const orderChange = store.changesToOrder(order, new Set([...pos_categories]), false);
const { orderData, changes } = store.generateOrderChange(
order,
orderChange,
pos_categories,
false
);
const receiptsData = await store.generateReceiptsDataToPrint(
orderData,
changes,
orderChange
);
expect(receiptsData.length).toBe(1);
expect(receiptsData[0].changes.title).toBe("NEW");
expect(receiptsData[0].changes.data.length).toBe(2);
expect(receiptsData[0].changes.data[0]).toEqual({
uuid: order.lines[0].uuid,
name: "TEST",
basic_name: "TEST",
combo_parent_uuid: undefined,
customer_note: "Test Orderline Customer Note",
product_id: 5,
attribute_value_names: [],
quantity: 3,
note: "",
pos_categ_id: 1,
pos_categ_sequence: 1,
display_name: "TEST",
group: undefined,
isCombo: false,
});
expect(receiptsData[0].changes.data[1]).toEqual({
uuid: order.lines[1].uuid,
name: "TEST 2",
basic_name: "TEST 2",
combo_parent_uuid: undefined,
customer_note: "",
product_id: 6,
attribute_value_names: [],
quantity: 2,
note: "Wait",
pos_categ_id: 2,
pos_categ_sequence: 2,
display_name: "TEST 2",
group: undefined,
isCombo: false,
});
});
test("filterChangeByCategories", async () => {
const store = await setupPosEnv();
const allowedCategories = [1];
const productA = store.models["product.product"].get(5);
const productB = store.models["product.product"].get(6);
productA.pos_categ_ids = [1];
productB.pos_categ_ids = [2];
const currentOrderChange = {
new: [
{ uuid: "combo-parent-uuid", isCombo: true },
{
uuid: "combo-child-a-uuid",
combo_parent_uuid: "combo-parent-uuid",
product_id: productA.id,
isCombo: false,
},
{
uuid: "combo-child-b-uuid",
combo_parent_uuid: "combo-parent-uuid",
product_id: productB.id,
isCombo: false,
},
{ uuid: "line1", product_id: productA.id, isCombo: false },
{ uuid: "line2", product_id: productB.id, isCombo: false },
],
cancelled: [],
noteUpdate: [],
};
const filtered = store.filterChangeByCategories(allowedCategories, currentOrderChange);
const expectedUuids = ["combo-parent-uuid", "combo-child-a-uuid", "line1"];
const actualUuids = filtered.new.map((c) => c.uuid);
expect(actualUuids.sort()).toEqual(expectedUuids.sort());
});
test("deleteOrders", async () => {
const store = await setupPosEnv();
const order1 = await getFilledOrder(store);
await store.syncAllOrders();
await store.deleteOrders([order1]);
expect(store.models["pos.order"].getBy("uuid", order1.uuid)).toBeEmpty();
});
test("deleteOrders multiple orders", async () => {
const store = await setupPosEnv();
await getFilledOrder(store);
store.addNewOrder();
let openOrders = store.getOpenOrders();
expect(openOrders.length).toBe(2);
const deletedOrders = await store.deleteOrders(openOrders);
expect(deletedOrders).toBe(true);
openOrders = store.getOpenOrders();
expect(openOrders.length).toBe(0);
});
test("getOrderData", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const orderData = store.getOrderData(order);
expect(orderData).toEqual({
reprint: undefined,
pos_reference: "1001",
config_name: "Hoot",
time: "10:30",
tracking_number: "1001",
preset_time: false,
preset_name: "In",
employee_name: "Administrator",
internal_note: "",
general_customer_note: "",
changes: {
title: "",
data: [],
},
});
});
test("productsToDisplay", async () => {
const store = await setupPosEnv();
store.selectedCategory = store.models["pos.category"].get(1);
let products = store.productsToDisplay;
expect(products.length).toBe(2);
expect(products[0].id).toBe(17);
expect(products[1].id).toBe(5);
expect(store.selectedCategory.id).toBe(1);
store.selectedCategory = store.models["pos.category"].get(1);
store.searchProductWord = "TEST";
products = store.productsToDisplay;
expect(products.length).toBe(4);
expect(products[0].id).toBe(5);
expect(products[1].id).toBe(6);
expect(store.selectedCategory).toBe(undefined);
store.searchProductWord = "TEST 2";
products = store.productsToDisplay;
expect(products.length).toBe(1);
expect(products[0].id).toBe(6);
});
test("productToDisplayByCateg", async () => {
const store = await setupPosEnv();
// Case 1: Grouping disabled
store.config.iface_group_by_categ = false;
let grouped = store.productToDisplayByCateg;
expect(grouped.length).toBe(1); //Only one group
expect(grouped[0][0]).toBe("0");
expect(grouped[0][1].length).toBe(15);
// Case 2: Grouping enabled
store.config.iface_group_by_categ = true;
grouped = store.productToDisplayByCateg;
expect(grouped.length).toBe(6);
// Confirm grouping structure
for (const [catId, prods] of grouped) {
expect(Array.isArray(prods)).toBe(true);
expect(prods.length).toBeGreaterThan(0);
for (const prod of prods) {
if (catId === "0") {
expect(prod.pos_categ_ids.length).toBe(0);
continue;
}
const categoryIds = prod.pos_categ_ids.map((c) => c.id);
expect(categoryIds).toInclude(parseInt(catId));
}
}
// Case 3: Grouping with search filtering
store.searchProductWord = "TEST";
grouped = store.productToDisplayByCateg;
expect(grouped.length).toBe(3);
expect(grouped[0][1][0].name).toBe("TEST");
expect(grouped[1][1][0].name).toBe("TEST 2");
expect(grouped[2][1][0].name).toBe("Accounting Test Product 1");
expect(grouped[2][1][1].name).toBe("Accounting Test Product 2");
// Case 4: Grouping with category filtering
store.searchProductWord = "";
store.selectedCategory = store.models["pos.category"].get(1);
grouped = store.productToDisplayByCateg;
expect(grouped.length).toBe(1);
expect(grouped[0][0]).toBe(1);
expect(grouped[0][1][0].name).toBe("Multi Category Product");
expect(grouped[0][1][1].name).toBe("TEST");
// Case 5: Grouping with category 'Food' selected (parent of 'Burger' & 'Pizza')
store.selectedCategory = store.models["pos.category"].get(3);
grouped = store.productToDisplayByCateg;
expect(grouped.length).toBe(3);
expect(grouped[0][0]).toBe(3);
expect(grouped[0][1][0].name).toBe("Club sandwich");
expect(grouped[1][1][0].name).toBe("Bacon burger");
expect(grouped[2][1][0].name).toBe("Pizza margarita");
});
test("productToDisplayByCateg count", async () => {
const store = await setupPosEnv();
const createProductsAndCateg = (prefix, count) => {
const categ = store.models["pos.category"].create({
name: `${prefix}_categ`,
});
for (let i = 0; i < count; i++) {
store.models["product.template"].create({
name: `${prefix}_${i}`,
pos_categ_ids: [categ.id],
});
}
return categ;
};
store.config.iface_group_by_categ = true;
const test1 = createProductsAndCateg("producttest1", 100);
const test2 = createProductsAndCateg("producttest2", 150);
const grouped = store.productToDisplayByCateg;
const byCateg = store.models["product.template"].getAllBy("pos_categ_ids");
const test1Products = byCateg[test1.id];
const test2Products = byCateg[test2.id];
const groupedTest1 = grouped.find((data) => data[0] == test1.id)[1];
const groupedTest2 = grouped.find((data) => data[0] == test2.id)[1];
expect(test1Products).toHaveLength(100);
expect(test2Products).toHaveLength(150);
expect(groupedTest1).toHaveLength(100);
expect(groupedTest2).toHaveLength(100);
store.searchProductWord = "producttest1";
const groupedSearchTest1 = store.productToDisplayByCateg;
const groupedSearchTest1Prods = groupedSearchTest1.find((data) => data[0] == test1.id)[1];
expect(groupedSearchTest1).toHaveLength(1);
expect(groupedSearchTest1Prods).toHaveLength(100);
store.searchProductWord = "producttest";
const groupedSearchTest = store.productToDisplayByCateg;
const groupedSearchTest1Prods2 = groupedSearchTest.find((data) => data[0] == test1.id)[1];
const groupedSearchTest2Prods2 = groupedSearchTest.find((data) => data[0] == test2.id)[1];
expect(groupedSearchTest).toHaveLength(2);
expect(groupedSearchTest1Prods2).toHaveLength(100);
expect(groupedSearchTest2Prods2).toHaveLength(100);
store.searchProductWord = "";
const productWoCategory = store.models["product.template"].readMany([15, 16]);
const groupedSearchTest3 = store.productToDisplayByCateg;
expect(groupedSearchTest3[groupedSearchTest3.length - 1][0]).toEqual("0");
expect(groupedSearchTest3[groupedSearchTest3.length - 1][1]).toHaveLength(2);
expect(groupedSearchTest3[groupedSearchTest3.length - 1][1].map((p) => p.id)).toEqual(
productWoCategory.map((p) => p.id)
);
store.selectedCategory = test1;
const groupedSearchTest4 = store.productToDisplayByCateg;
expect(groupedSearchTest4).toHaveLength(1);
expect(groupedSearchTest4[0][0]).toEqual(test1.id);
expect(groupedSearchTest4[0][1]).toHaveLength(100);
});
test("onDeleteOrder", async () => {
const store = await setupPosEnv();
const order = store.addNewOrder();
const deletedOrder = await store.onDeleteOrder(order);
expect(order.uiState.displayed).toBe(false);
expect(deletedOrder).toBe(true);
});
test("setNextOrderRefs", async () => {
const store = await setupPosEnv();
const order = store.addNewOrder();
await store.setNextOrderRefs(order);
expect(order.pos_reference).toBeOfType("string");
expect(order.pos_reference.length).toBeGreaterThan(1);
expect(order.sequence_number).toBeOfType("integer");
expect(order.tracking_number).toBeOfType("string");
expect(order.tracking_number.length).toBeGreaterThan(2);
});
test("pending orders", async () => {
const store = await setupPosEnv();
let { orderToCreate, orderToUpdate, orderToDelete } = store.getPendingOrder();
expect(orderToCreate).toHaveLength(0);
expect(orderToUpdate).toHaveLength(0);
expect(orderToDelete).toHaveLength(0);
const order = await getFilledOrder(store);
({ orderToCreate, orderToUpdate, orderToDelete } = store.getPendingOrder());
expect(order.id).toBe(orderToCreate[0].id);
// After sync, order should be in 'orderToUpdate'
await store.syncAllOrders({ orders: [order] });
store.addPendingOrder([order.id]);
({ orderToCreate, orderToUpdate, orderToDelete } = store.getPendingOrder());
expect(orderToCreate).toHaveLength(0);
expect(orderToUpdate).toHaveLength(1);
// Remove pending order
store.addPendingOrder([order.id], true);
({ orderToCreate, orderToUpdate, orderToDelete } = store.getPendingOrder());
expect(orderToUpdate).toHaveLength(0);
expect(orderToDelete).toHaveLength(1);
// Clear pending orders
store.clearPendingOrder();
({ orderToCreate, orderToUpdate, orderToDelete } = store.getPendingOrder());
expect(orderToDelete).toHaveLength(0);
});
test("getPaymentMethodFmtAmount", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const cashPm = store.models["pos.payment.method"].find((pm) => pm.is_cash_count);
// Case 1: No rounding enabled
expect(store.getPaymentMethodFmtAmount(cashPm, order)).toBeEmpty();
// Case 2: Rounding enabled, not limited to cash
const { cashPm: cash1, cardPm: card1 } = prepareRoundingVals(store, 0.05, "HALF-UP", false);
expect(store.getPaymentMethodFmtAmount(cash1, order)).toBe("$ 17.85");
expect(store.getPaymentMethodFmtAmount(card1, order)).toBe("$ 17.85");
// Case 3: Rounding enabled, only for cash
const { cashPm: cash2, cardPm: card2 } = prepareRoundingVals(store, 0.05, "HALF-UP", true);
expect(store.getPaymentMethodFmtAmount(cash2, order)).toBe("$ 17.85");
expect(store.getPaymentMethodFmtAmount(card2, order)).toBeEmpty();
});
test("canEditPayment", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
expect(store.canEditPayment(order)).toBe(true);
order.nb_print = 1;
expect(store.canEditPayment(order)).toBe(false);
});
describe("cacheReceiptLogo", () => {
function getCompanyLogo256Url(companyId) {
const fullUrl = imageUrl("res.company", companyId, "logo", {
width: 256,
height: 256,
});
const index = fullUrl.indexOf("/web");
return fullUrl.substring(index);
}
test("correctly cached", async () => {
onRpc(getCompanyLogo256Url("<int:id>"), async (request, { id }) => {
expect.step(`Company logo ${id} fetched`);
return `Company logo ${id}`;
});
const store = await setupPosEnv();
const companyId = store.company.id;
expect.verifySteps([`Company logo ${companyId} fetched`]);
const { receiptLogoUrl } = store.config;
expect(receiptLogoUrl).toInclude("data:");
expect(atob(receiptLogoUrl.split(",")[1])).toInclude(`Company logo ${companyId}`);
});
test("fetch failed", async () => {
onRpc(getCompanyLogo256Url("<int:id>"), async (request, { id }) => {
expect.step(`Company logo ${id} fetched`);
throw new Error("Fetch failed");
});
const store = await setupPosEnv();
const companyId = store.company.id;
expect.verifySteps([`Company logo ${companyId} fetched`]);
expect(store.config.receiptLogoUrl).toInclude(getCompanyLogo256Url(companyId));
});
test("preSyncAllOrders", async () => {
// This test check prices sign on preSyncAllOrders for refunds
const store = await setupPosEnv();
const order = await getFilledOrder(store);
await store.preSyncAllOrders([order]);
expect(order.amount_total).toEqual(17.85);
expect(order.amount_tax).toEqual(2.85);
expect(order.lines[0].qty).toEqual(3);
expect(order.lines[0].price_unit).toEqual(3);
expect(order.lines[0].price_subtotal).toEqual(9);
expect(order.lines[0].price_subtotal_incl).toEqual(10.35);
expect(order.lines[1].qty).toEqual(2);
expect(order.lines[1].price_unit).toEqual(3);
expect(order.lines[1].price_subtotal).toEqual(6);
expect(order.lines[1].price_subtotal_incl).toEqual(7.5);
order.is_refund = true;
order.lines.forEach((line) => (line.qty = -line.qty));
await store.preSyncAllOrders([order]);
expect(order.amount_total).toEqual(-17.85);
expect(order.amount_tax).toEqual(-2.85);
expect(order.lines[0].qty).toEqual(-3);
expect(order.lines[0].price_unit).toEqual(3);
expect(order.lines[0].price_subtotal).toEqual(9);
expect(order.lines[0].price_subtotal_incl).toEqual(10.35);
expect(order.lines[1].qty).toEqual(-2);
expect(order.lines[1].price_unit).toEqual(3);
expect(order.lines[1].price_subtotal).toEqual(6);
expect(order.lines[1].price_subtotal_incl).toEqual(7.5);
});
});
});

View file

@ -0,0 +1,51 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { mockFetch } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import { allowTranslations, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { htmlToCanvas } from "@point_of_sale/app/services/render_service";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
odoo.pos_session_id = 1; // Ensure the session ID is set for lazy getters
test("test the render service", async () => {
class ComponentToBeRendered extends Component {
static props = ["name"];
static template = xml`
<div> It's me, <t t-esc="props.name" />! </div>
`;
}
allowTranslations(); // this is needed because we are not loading the localization service
const comp = await mountWithCleanup("none");
const renderedComp = await comp.env.services.renderer.toHtml(ComponentToBeRendered, {
name: "Mario",
});
expect(renderedComp).toHaveOuterHTML("<div> It's me, Mario! </div>");
});
test("htmlToCanvas", async () => {
// htmlToCanvas fetches some fonts useless for the test, we mock it to avoid warnings
mockFetch(() => "");
const target = getFixture();
const node = document.createElement("div");
node.classList.add("render-container");
target.appendChild(node);
const asciiChars = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join("");
node.textContent = asciiChars;
let canvas = null;
try {
canvas = await htmlToCanvas(node, { addClass: "pos-receipt-print" });
} catch (error) {
// htmlToCanvas create an <img> by setting a svg to its src attribute
// if this fails, an Event of type "error" is thrown
if (error.constructor.name !== "Event") {
throw error;
}
}
expect(canvas).not.toBe(null, {
message: "htmlToCanvas should work with all ascii characters",
});
});

View file

@ -1,414 +0,0 @@
odoo.define('point_of_sale.tests.ComponentRegistry', function(require) {
'use strict';
const Registries = require('point_of_sale.Registries');
QUnit.module('unit tests for ComponentRegistry', {
before() {},
});
QUnit.test('basic extend', async function(assert) {
assert.expect(5);
class A {
constructor() {
assert.step('A');
}
}
Registries.Component.add(A);
let A1 = x =>
class extends x {
constructor() {
super();
assert.step('A1');
}
};
Registries.Component.extend(A, A1);
Registries.Component.freeze();
const RegA = Registries.Component.get(A);
let a = new RegA();
assert.verifySteps(['A', 'A1']);
assert.ok(a instanceof RegA);
assert.ok(RegA.name === 'A');
});
QUnit.test('addByExtending', async function(assert) {
assert.expect(8);
class A {
constructor() {
assert.step('A');
}
}
Registries.Component.add(A);
let B = x =>
class extends x {
constructor() {
super();
assert.step('B');
}
};
Registries.Component.addByExtending(B, A);
let A1 = x =>
class extends x {
constructor() {
super();
assert.step('A1');
}
};
Registries.Component.extend(A, A1);
let A2 = x =>
class extends x {
constructor() {
super();
assert.step('A2');
}
};
Registries.Component.extend(A, A2);
Registries.Component.freeze();
const RegA = Registries.Component.get(A);
const RegB = Registries.Component.get(B);
let b = new RegB();
assert.verifySteps(['A', 'A1', 'A2', 'B']);
assert.ok(b instanceof RegA);
assert.ok(b instanceof RegB);
assert.ok(RegB.name === 'B');
});
QUnit.test('extend the one that is added by extending', async function(assert) {
assert.expect(6);
class A {
constructor() {
assert.step('A');
}
}
Registries.Component.add(A);
let B = x =>
class extends x {
constructor() {
super();
assert.step('B');
}
};
Registries.Component.addByExtending(B, A);
let B1 = x =>
class extends x {
constructor() {
super();
assert.step('B1');
}
};
Registries.Component.extend(B, B1);
let B2 = x =>
class extends x {
constructor() {
super();
assert.step('B2');
}
};
Registries.Component.extend(B, B2);
let A1 = x =>
class extends x {
constructor() {
super();
assert.step('A1');
}
};
Registries.Component.extend(A, A1);
Registries.Component.freeze();
const RegB = Registries.Component.get(B);
new RegB();
assert.verifySteps(['A', 'A1', 'B', 'B1', 'B2']);
});
QUnit.test('addByExtending based on added by extending', async function(assert) {
assert.expect(10);
class A {
constructor() {
assert.step('A');
}
}
Registries.Component.add(A);
let B = x =>
class extends x {
constructor() {
super();
assert.step('B');
}
};
Registries.Component.addByExtending(B, A);
let A1 = x =>
class extends x {
constructor() {
super();
assert.step('A1');
}
};
Registries.Component.extend(A, A1);
let C = x =>
class extends x {
constructor() {
super();
assert.step('C');
}
};
Registries.Component.addByExtending(C, B);
let B7 = x =>
class extends x {
constructor() {
super();
assert.step('B7');
}
};
Registries.Component.extend(B, B7);
Registries.Component.freeze();
const RegA = Registries.Component.get(A);
const RegB = Registries.Component.get(B);
const RegC = Registries.Component.get(C);
let c = new RegC();
assert.verifySteps(['A', 'A1', 'B', 'B7', 'C']);
assert.ok(c instanceof RegA);
assert.ok(c instanceof RegB);
assert.ok(c instanceof RegC);
assert.ok(RegC.name === 'C');
});
QUnit.test('deeper inheritance', async function(assert) {
assert.expect(9);
class A {
constructor() {
assert.step('A');
}
}
Registries.Component.add(A);
let B = x =>
class extends x {
constructor() {
super();
assert.step('B');
}
};
Registries.Component.addByExtending(B, A);
let A1 = x =>
class extends x {
constructor() {
super();
assert.step('A1');
}
};
Registries.Component.extend(A, A1);
let C = x =>
class extends x {
constructor() {
super();
assert.step('C');
}
};
Registries.Component.addByExtending(C, B);
let B2 = x =>
class extends x {
constructor() {
super();
assert.step('B2');
}
};
Registries.Component.extend(B, B2);
let B3 = x =>
class extends x {
constructor() {
super();
assert.step('B3');
}
};
Registries.Component.extend(B, B3);
let A9 = x =>
class extends x {
constructor() {
super();
assert.step('A9');
}
};
Registries.Component.extend(A, A9);
let E = x =>
class extends x {
constructor() {
super();
assert.step('E');
}
};
Registries.Component.addByExtending(E, C);
Registries.Component.freeze();
// |A| => A9 -> A1 -> A
// |B| => B3 -> B2 -> B -> |A|
// |C| => C -> |B|
// |E| => E -> |C|
new (Registries.Component.get(E))();
assert.verifySteps(['A', 'A1', 'A9', 'B', 'B2', 'B3', 'C', 'E']);
});
QUnit.test('mixins?', async function(assert) {
assert.expect(12);
class A {
constructor() {
assert.step('A');
}
}
Registries.Component.add(A);
let Mixin = x =>
class extends x {
constructor() {
super();
assert.step('Mixin');
}
mixinMethod() {
return 'mixinMethod';
}
get mixinGetter() {
return 'mixinGetter';
}
};
// use the mixin when declaring B.
let B = x =>
class extends Mixin(x) {
constructor() {
super();
assert.step('B');
}
};
Registries.Component.addByExtending(B, A);
let A1 = x =>
class extends x {
constructor() {
super();
assert.step('A1');
}
};
Registries.Component.extend(A, A1);
Registries.Component.freeze();
B = Registries.Component.get(B);
const b = new B();
assert.verifySteps(['A', 'A1', 'Mixin', 'B']);
// instance of B should have the mixin properties
assert.strictEqual(b.mixinMethod(), 'mixinMethod');
assert.strictEqual(b.mixinGetter, 'mixinGetter');
// instance of A should not have the mixin properties
A = Registries.Component.get(A);
const a = new A();
assert.verifySteps(['A', 'A1']);
assert.notOk(a.mixinMethod);
assert.notOk(a.mixinGetter);
});
QUnit.test('extending methods', async function(assert) {
assert.expect(16);
class A {
foo() {
assert.step('A foo');
}
}
Registries.Component.add(A);
let B = x =>
class extends x {
bar() {
assert.step('B bar');
}
};
Registries.Component.addByExtending(B, A);
let A1 = x =>
class extends x {
bar() {
assert.step('A1 bar');
// should only be for A.
}
};
Registries.Component.extend(A, A1);
let B1 = x =>
class extends x {
foo() {
super.foo();
assert.step('B1 foo');
}
};
Registries.Component.extend(B, B1);
let C = x =>
class extends x {
foo() {
super.foo();
assert.step('C foo');
}
bar() {
super.bar();
assert.step('C bar');
}
};
Registries.Component.addByExtending(C, B);
Registries.Component.freeze();
A = Registries.Component.get(A);
B = Registries.Component.get(B);
C = Registries.Component.get(C);
const a = new A();
const b = new B();
const c = new C();
a.foo();
assert.verifySteps(['A foo']);
b.foo();
assert.verifySteps(['A foo', 'B1 foo']);
c.foo();
assert.verifySteps(['A foo', 'B1 foo', 'C foo']);
a.bar();
assert.verifySteps(['A1 bar']);
b.bar();
assert.verifySteps(['B bar']);
c.bar();
assert.verifySteps(['B bar', 'C bar']);
});
});

View file

@ -1,69 +0,0 @@
odoo.define('point_of_sale.tests.NumberBuffer', function(require) {
'use strict';
const NumberBuffer = require('point_of_sale.NumberBuffer');
const makeTestEnvironment = require('web.test_env');
const testUtils = require('web.test_utils');
const { mount } = require('@web/../tests/helpers/utils');
const { LegacyComponent } = require("@web/legacy/legacy_component");
const { useState, xml } = owl;
QUnit.module('unit tests for NumberBuffer', {
before() {},
});
QUnit.test('simple fast inputs with capture in between', async function(assert) {
assert.expect(3);
const target = testUtils.prepareTarget();
const env = makeTestEnvironment();
class Root extends LegacyComponent {
setup() {
this.state = useState({ buffer: '' });
NumberBuffer.activate();
NumberBuffer.use({
nonKeyboardInputEvent: 'numpad-click-input',
state: this.state,
});
}
resetBuffer() {
NumberBuffer.capture();
NumberBuffer.reset();
}
onClickOne() {
this.trigger('numpad-click-input', { key: '1' });
}
onClickTwo() {
this.trigger('numpad-click-input', { key: '2' });
}
}
Root.template = xml/* html */ `
<div>
<p><t t-esc="state.buffer" /></p>
<button class="one" t-on-click="onClickOne">1</button>
<button class="two" t-on-click="onClickTwo">2</button>
<button class="reset" t-on-click="resetBuffer">reset</button>
</div>
`;
await mount(Root, target, { env });
const oneButton = target.querySelector('button.one');
const twoButton = target.querySelector('button.two');
const resetButton = target.querySelector('button.reset');
const bufferEl = target.querySelector('p');
testUtils.dom.click(oneButton);
testUtils.dom.click(twoButton);
await testUtils.nextTick();
assert.strictEqual(bufferEl.textContent, '12');
testUtils.dom.click(resetButton);
await testUtils.nextTick();
assert.strictEqual(bufferEl.textContent, '');
testUtils.dom.click(twoButton);
testUtils.dom.click(oneButton);
await testUtils.nextTick();
assert.strictEqual(bufferEl.textContent, '21');
});
});

View file

@ -1,165 +0,0 @@
odoo.define('point_of_sale.tests.PosPopupController', function(require) {
'use strict';
const PosPopupController = require('point_of_sale.PosPopupController');
const AbstractAwaitablePopup = require('point_of_sale.AbstractAwaitablePopup');
const PosComponent = require('point_of_sale.PosComponent');
const makeTestEnvironment = require('web.test_env');
const testUtils = require('web.test_utils');
const Registries = require('point_of_sale.Registries');
const { mount } = require('@web/../tests/helpers/utils');
const { EventBus, useSubEnv, xml } = owl;
QUnit.module('unit tests for PosPopupController', {
before() {
Registries.Component.freeze();
// Note that we are creating new popups here to decouple this test from the pos app.
class CustomPopup1 extends AbstractAwaitablePopup {}
CustomPopup1.template = xml/* html */`
<div class="popup custom-popup-1">
<footer>
<div class="confirm" t-on-click="confirm">
Yes
</div>
<div class="cancel" t-on-click="cancel">
No
</div>
</footer>
</div>
`;
class CustomPopup2 extends AbstractAwaitablePopup {}
CustomPopup2.template = xml/* html */`
<div class="popup custom-popup-2">
<footer>
<div class="confirm" t-on-click="confirm">
Okay
</div>
</footer>
</div>
`;
PosPopupController.components = { CustomPopup1, CustomPopup2 };
},
});
QUnit.test('allow multiple popups at the same time', async function(assert) {
assert.expect(12);
class Root extends PosComponent {
setup() {
super.setup();
useSubEnv({
isDebug: () => false,
posbus: new EventBus(),
});
}
}
Root.env = makeTestEnvironment();
Root.template = xml/* html */ `
<div>
<PosPopupController />
</div>
`;
const root = await mount(Root, testUtils.prepareTarget());
// Check 1 popup
let popup1Promise = root.showPopup('CustomPopup1', {});
await testUtils.nextTick();
assert.strictEqual(root.el.querySelectorAll('.popup').length, 1);
testUtils.dom.click(root.el.querySelector('.modal-dialog .custom-popup-1 .confirm'));
let result1 = await popup1Promise;
assert.strictEqual(result1.confirmed, true);
await testUtils.nextTick();
assert.strictEqual(root.el.querySelectorAll('.popup').length, 0);
// Check multiple popups
popup1Promise = root.showPopup('CustomPopup1', {});
await testUtils.nextTick();
// Check if the first popup is shown.
assert.strictEqual(root.el.querySelectorAll('.popup').length, 1);
let popup2Promise = root.showPopup('CustomPopup2', {});
await testUtils.nextTick();
// Check for the second popup.
assert.strictEqual(root.el.querySelectorAll('.popup').length, 2);
// popup 1 should be hidden
assert.strictEqual(root.el.querySelectorAll('.modal-dialog.oe_hidden').length, 1);
// click confirm on popup 2
testUtils.dom.click(root.el.querySelector('.modal-dialog .custom-popup-2 .confirm'));
await testUtils.nextTick();
// after confirming on popup 2, only 1 should remain.
assert.strictEqual(root.el.querySelectorAll('.popup').length, 1);
assert.strictEqual(root.el.querySelectorAll('.modal-dialog .custom-popup-2').length, 0);
// popup 1 should not be hidden
const CustomPopup1 = root.el.querySelector('.modal-dialog')
assert.strictEqual(![...CustomPopup1.classList].includes('oe_hidden'), true);
testUtils.dom.click(root.el.querySelector('.modal-dialog .custom-popup-1 .cancel'));
await testUtils.nextTick();
// after cancelling popup 1, no popup should remain.
assert.strictEqual(root.el.querySelectorAll('.popup').length, 0);
result1 = await popup1Promise;
let result2 = await popup2Promise;
assert.strictEqual(result1.confirmed, false); // false because it's cancelled.
assert.strictEqual(result2.confirmed, true); // true because it's confirmed.
});
QUnit.test('pressing cancel/confirm key should only close the top popup', async function(assert) {
assert.expect(6);
class Root extends PosComponent {
setup() {
super.setup();
useSubEnv({
isDebug: () => false,
posbus: new EventBus(),
});
}
}
Root.env = makeTestEnvironment();
Root.template = xml/* html */ `
<div>
<PosPopupController />
</div>
`;
const root = await mount(Root, testUtils.prepareTarget());
let popup1Promise = root.showPopup('CustomPopup1', { confirmKey: 'Enter', cancelKey: 'Escape' });
await testUtils.nextTick();
assert.strictEqual(root.el.querySelectorAll('.popup').length, 1);
let popup2Promise = root.showPopup('CustomPopup2', { confirmKey: 'Enter', cancelKey: 'Escape' });
await testUtils.nextTick();
assert.strictEqual(root.el.querySelectorAll('.popup').length, 2);
// Pressing 'Escape' should cancel the top popup which is the CustomPopup2.
testUtils.dom.triggerEvent(window, 'keyup', { key: 'Escape' });
await testUtils.nextTick();
// Therefore, the popup2Promise has now resolved with `confirmed` value = false.
const result2 = await popup2Promise;
assert.strictEqual(result2.confirmed, false);
assert.strictEqual(root.el.querySelectorAll('.popup').length, 1);
testUtils.dom.triggerEvent(window, 'keyup', { key: 'Enter' });
await testUtils.nextTick();
assert.strictEqual(root.el.querySelectorAll('.popup').length, 0);
const result1 = await popup1Promise;
assert.strictEqual(result1.confirmed, true);
});
});

View file

@ -0,0 +1,64 @@
import { test, expect } from "@odoo/hoot";
import { getFilledOrder, setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
test("Check GAP", async () => {
const store = await setupPosEnv();
const device = store.device;
let orderStack = [];
// Ensure there is no order at the beginning
await store.deleteOrders(store.models["pos.order"].getAll());
const createNewOrdersAndCheck = async (nbr) => {
for (let i = 0; i < nbr; i++) {
const order = await getFilledOrder(store);
orderStack.push(order);
}
};
const deleteOrdersAndCheck = async () => {
const numbers = orderStack.map((order) => parseInt(order.pos_reference.split("-")[2]));
await store.deleteOrders(orderStack);
orderStack = [];
expect(device.data.unsynced_number_stack).not.toBeEmpty();
expect(device.data.unsynced_number_stack).toMatch(numbers);
};
// Create 15 orders, check that the next number is incremented correctly
await createNewOrdersAndCheck(15);
expect(device.data.next_number).toBe(16);
expect(device.data.unsynced_number_stack).toBeEmpty();
// Delete all of them, check that the unsynced number stack is filled
await deleteOrdersAndCheck();
// Create 15 more orders, the number should not be incremented, we reuse the unsynced numbers
await createNewOrdersAndCheck(15);
// Stack is empty numbers are used
expect(device.data.unsynced_number_stack).toBeEmpty();
expect(device.data.next_number).toBe(16);
await deleteOrdersAndCheck();
expect(device.data.next_number).toBe(16);
// Create 15 more orders, the number should be incremented
await createNewOrdersAndCheck(15);
expect(device.data.next_number).toBe(16);
// Sync orders and cancel them
const orders = await store.syncAllOrders();
await store.deleteOrders(orders);
// Create 15 more orders, the number should be incremented again
await createNewOrdersAndCheck(15);
expect(device.data.next_number).toBe(31);
});
test("Device identifier is set", async () => {
const store = await setupPosEnv();
const device = store.device;
expect(device.identifier).not.toBeEmpty();
});

View file

@ -0,0 +1,338 @@
import { afterEach, expect, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-dom";
import { Component, onWillRender, reactive, useState, xml } from "@odoo/owl";
import {
mountWithCleanup,
allowTranslations,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import {
WithLazyGetterTrap,
clearGettersCache,
createLazyGetter,
} from "@point_of_sale/lazy_getter";
import { zip } from "@web/core/utils/arrays";
/**
* @param {string} value
*/
function unorderedStep(value) {
unorderedSteps.push(value);
}
/**
* Makes multiple assertions:
* - Are all items in `vals` in steps?
* - Are the items in `steps` ordered according to each item in `stepOrders`?
* Then it clears the `steps`.
* @param {string[]} expectedSteps
* @param {Iterable<string[]>} [stepOrders=[]]
*/
function verifyUnorderedSteps(expectedSteps, stepOrders = []) {
expect([...unorderedSteps].sort()).toEqual([...expectedSteps].sort());
for (const stepOrder of stepOrders) {
expect(
zip(stepOrder.slice(0, -1), stepOrder.slice(1)).reduce(
(acc, [a, b]) => acc && unorderedSteps.indexOf(a) < unorderedSteps.indexOf(b),
true
)
).toBe(true);
}
unorderedSteps = [];
}
let unorderedSteps = [];
allowTranslations();
afterEach(clearGettersCache);
class AppStore extends WithLazyGetterTrap {
constructor() {
super({ traps: {} });
this.a = 0;
this.b = 0;
this.c = 0;
this.d = 0;
}
get ab() {
return this.a + this.b;
}
get abc() {
let result = 0;
for (let i = 0; i < 10; i++) {
result += this.ab;
}
return result + this.c;
}
get bc() {
return this.b + this.c;
}
get cd() {
return this.c + this.d;
}
get x() {
return this.abc + this.bc;
}
get y() {
return this.cd + this.x;
}
}
class WithStore extends Component {
static props = {};
static template = xml`
<span t-att-class="property">
<t t-esc="constructor.name" />: <t t-esc="this.store[property]" />
</span>
`;
property = "";
setup() {
this.store = useState(this.env.store);
onWillRender(() => this.onWillRender());
}
onWillRender() {}
}
class A extends WithStore {
property = "a";
}
class B extends WithStore {
property = "b";
}
class C extends WithStore {
property = "c";
}
class D extends WithStore {
property = "d";
}
class AB extends WithStore {
property = "ab";
}
class ABC extends WithStore {
property = "abc";
}
class BC extends WithStore {
property = "bc";
}
class CD extends WithStore {
property = "cd";
}
class Root extends Component {
static components = { A, B, C, D, AB, ABC, BC, CD };
static props = {};
static template = xml`
<t t-foreach="constructor.components" t-as="key" t-key="key">
<t t-component="constructor.components[key]" />
</t>
`;
}
test("each getter should only be called once and only when needed", async () => {
patchWithCleanup(AppStore.prototype, {
get ab() {
unorderedStep("ab");
return super.ab;
},
get abc() {
unorderedStep("abc");
return super.abc;
},
get bc() {
unorderedStep("bc");
return super.bc;
},
get cd() {
unorderedStep("cd");
return super.cd;
},
});
const store = reactive(new AppStore());
await mountWithCleanup(Root, {
env: { store },
noMainContainer: true,
});
verifyUnorderedSteps(["ab", "abc", "bc", "cd"]);
store.a = 1;
// Getters should only be called after an interface re-render
verifyUnorderedSteps([]);
await animationFrame();
verifyUnorderedSteps(["ab", "abc"]);
store.b = 1;
verifyUnorderedSteps([]);
await animationFrame();
verifyUnorderedSteps(["bc", "ab", "abc"]);
store.c = 1;
verifyUnorderedSteps([]);
await animationFrame();
verifyUnorderedSteps(["cd", "bc", "abc"]);
store.d = 1;
verifyUnorderedSteps([]);
await animationFrame();
verifyUnorderedSteps(["cd"]);
});
test("only dependent components rerender", async () => {
patchWithCleanup(WithStore.prototype, {
onWillRender() {
unorderedStep(this.property);
},
});
const store = reactive(new AppStore());
await mountWithCleanup(Root, {
env: { store },
noMainContainer: true,
});
verifyUnorderedSteps(["a", "b", "c", "d", "ab", "abc", "bc", "cd"]);
store.a = 1;
await animationFrame();
verifyUnorderedSteps(["a", "ab", "abc"]);
store.b = 1;
await animationFrame();
verifyUnorderedSteps(["b", "ab", "abc", "bc"]);
store.c = 1;
await animationFrame();
verifyUnorderedSteps(["c", "abc", "bc", "cd"]);
store.d = 1;
await animationFrame();
verifyUnorderedSteps(["d", "cd"]);
});
test("only dependent getters are called and in correct order", () => {
patchWithCleanup(AppStore.prototype, {
get ab() {
const result = super.ab;
unorderedStep("ab");
return result;
},
get abc() {
const result = super.abc;
unorderedStep("abc");
return result;
},
get bc() {
const result = super.bc;
unorderedStep("bc");
return result;
},
get cd() {
const result = super.cd;
unorderedStep("cd");
return result;
},
get x() {
const result = super.x;
unorderedStep("x");
return result;
},
get y() {
const result = super.y;
unorderedStep("y");
return result;
},
});
const store = reactive(new AppStore());
expect(store.y).toBe(0);
verifyUnorderedSteps(["ab", "bc", "cd", "abc", "x", "y"], [["ab", "abc", "x", "y"]]);
store.a = 1;
expect(store.y).toBe(10);
verifyUnorderedSteps(["ab", "abc", "x", "y"], [["ab", "abc", "x", "y"]]);
store.b = 1;
expect(store.y).toBe(21);
verifyUnorderedSteps(
["ab", "bc", "abc", "x", "y"],
[
["ab", "abc", "x", "y"],
["bc", "x", "y"],
]
);
store.c = 1;
expect(store.y).toBe(24);
verifyUnorderedSteps(
["abc", "bc", "cd", "x", "y"],
[
["abc", "x", "y"],
["bc", "x", "y"],
["cd", "y"],
]
);
store.d = 1;
expect(store.y).toBe(25);
verifyUnorderedSteps(["cd", "y"], [["cd", "y"]]);
});
test("dynamically creates a lazy getter", () => {
class DemoClass extends WithLazyGetterTrap {
constructor(params = {}) {
super(params);
}
}
const reactiveObj = reactive(new DemoClass());
reactiveObj.name = "demo";
let computeCallCount = 0;
function computeValue() {
computeCallCount++;
return "Hello " + this.name;
}
createLazyGetter(reactiveObj, "hello", computeValue);
expect(reactiveObj.hello).toBe("Hello demo");
expect(computeCallCount).toBe(1);
// On the second call, the computed method is not executed again.
expect(reactiveObj.hello).toBe("Hello demo");
expect(computeCallCount).toBe(1);
// Modifying the value will invalidate the computed value
expect(computeCallCount).toBe(1);
reactiveObj.name = "World";
expect(reactiveObj.hello).toBe("Hello World");
expect(computeCallCount).toBe(2);
reactiveObj.notRelatedValue = 1;
expect(reactiveObj.hello).toBe("Hello World");
expect(computeCallCount).toBe(2);
});

View file

@ -0,0 +1,20 @@
import { expect, test } from "@odoo/hoot";
import { getLNATargetAddressSpace } from "@point_of_sale/app/utils/init_lna";
test("targetAddressSpace local", () => {
expect(getLNATargetAddressSpace("http://192.168.1.1")).toBe("local");
expect(getLNATargetAddressSpace("http://192.168.1.1:8008")).toBe("local");
expect(getLNATargetAddressSpace("http://192.168.1.1:8080/demo")).toBe("local");
expect(getLNATargetAddressSpace("invalidurl")).toBe("local");
});
test("targetAddressSpace loopback", () => {
expect(getLNATargetAddressSpace("http://localhost")).toBe("loopback");
expect(getLNATargetAddressSpace("http://localhost:1234/demo")).toBe("loopback");
expect(getLNATargetAddressSpace("http://localhost/demo")).toBe("loopback");
expect(getLNATargetAddressSpace("http://127.0.0.1")).toBe("loopback");
expect(getLNATargetAddressSpace("http://127.0.0.1:1234/demo")).toBe("loopback");
expect(getLNATargetAddressSpace("http://127.0.0.1/demo")).toBe("loopback");
});

View file

@ -0,0 +1,28 @@
import { expect, test } from "@odoo/hoot";
import { EQ, AbstractNumbers } from "@point_of_sale/app/utils/numbers";
class CustomNumbers extends AbstractNumbers {
constructor() {
super({});
}
get precision() {
return 0.05;
}
get method() {
return "UP";
}
}
const numbers = new CustomNumbers();
test("inputs are rounded before comparison", () => {
expect(numbers.comp(1.24, 1.21)).toBe(EQ);
expect(numbers.isZero(1.24 - 1.21)).toBe(false);
});
test("rounding", () => {
expect(numbers.round(1.28)).toBe(1.3);
expect(numbers.round(-1.28)).toBe(-1.3);
expect(numbers.asymmetricRound(1.28)).toBe(1.3);
expect(numbers.asymmetricRound(-1.28)).toBe(-1.25);
});

View file

@ -0,0 +1,33 @@
import { expect, test } from "@odoo/hoot";
import OrderPaymentValidation from "@point_of_sale/app/utils/order_payment_validation";
import { getFilledOrder, setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
test("validateOrder", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const fastPaymentMethod = order.config.fast_payment_method_ids[0];
const validation = new OrderPaymentValidation({
pos: store,
orderUuid: store.getOrder().uuid,
fastPaymentMethod: fastPaymentMethod,
});
await validation.validateOrder(false);
expect(order.payment_ids[0].payment_method_id).toEqual(fastPaymentMethod);
expect(order.state).toBe("paid");
expect(order.amount_paid).toBe(17.85);
});
test("isOrderValid", async () => {
const store = await setupPosEnv();
const order = store.addNewOrder();
order.setToInvoice(true);
const validation = new OrderPaymentValidation({
pos: store,
orderUuid: store.getOrder().uuid,
});
const isOrderValid = await validation.isOrderValid(false);
expect(order.lines).toHaveLength(0);
expect(isOrderValid).toBe(false); // The order cannot be invoiced if the order line count is zero.
});

View file

@ -0,0 +1,52 @@
import { expect, test } from "@odoo/hoot";
import { getFilledOrder, setupPosEnv } from "../utils";
import { definePosModels } from "../data/generate_model_definitions";
definePosModels();
test("Related models must keep local records", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
const product = store.models["product.template"].get(8);
expect(order.isSynced).toBe(false);
expect(order.lines.every((l) => l.isSynced === true)).toBe(false);
await store.syncAllOrders();
expect(order.isSynced).toBe(true);
expect(order.lines.every((l) => l.isSynced === true)).toBe(true);
await store.addLineToOrder(
{
product_tmpl_id: product,
qty: 1,
},
order
);
expect(order.lines.every((l) => l.isSynced === true)).toBe(false);
// Download the same order from server, the local unsynced line must be kept
await store.data.loadServerOrders([["id", "=", order.id]]);
expect(order.lines.every((l) => l.isSynced === true)).toBe(false);
});
test("Check behavior when deleting records", async () => {
const store = await setupPosEnv();
const order = await getFilledOrder(store);
expect(order.isSynced).toBe(false);
expect(order.lines.every((l) => l.isSynced === true)).toBe(false);
await store.syncAllOrders();
expect(order.isSynced).toBe(true);
expect(order.lines.every((l) => l.isSynced === true)).toBe(true);
order.removeOrderline(order.lines[0]);
expect(order.lines).toHaveLength(1);
// At this point if we download the same order from server,
// we must not lose the local deletion
await store.data.loadServerOrders([["id", "=", order.id]]);
expect(order.lines).toHaveLength(2);
// But if we sync before downloading, the deletion must be kept
order.removeOrderline(order.lines[0]);
expect(order.lines).toHaveLength(1);
await store.syncAllOrders({ orders: [order] });
await store.data.loadServerOrders([["id", "=", order.id]]);
expect(order.lines).toHaveLength(1);
});

View file

@ -0,0 +1,143 @@
import { uuidv4 } from "@point_of_sale/utils";
import {
getService,
makeDialogMockEnv,
mountWithCleanup,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { animationFrame, tick, waitFor, waitUntil } from "@odoo/hoot-dom";
import { Deferred } from "@odoo/hoot-mock";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { patch } from "@web/core/utils/patch";
import { onMounted } from "@odoo/owl";
import { expect } from "@odoo/hoot";
import { user } from "@web/core/user";
const { DateTime } = luxon;
export const setupPosEnv = async () => {
// Do not change these variables, they are in accordance with the demo data
odoo.pos_session_id = 1;
odoo.pos_config_id = 1;
odoo.from_backend = 0;
odoo.access_token = uuidv4(); // Avoid indexedDB conflicts
odoo.info = {
db: `pos-${uuidv4()}`, // Avoid indexedDB conflicts
isEnterprise: true,
};
await makeDialogMockEnv();
const store = getService("pos");
store.setCashier(store.user);
patchWithCleanup(user, {
// Needed for the allowProductCreation method
checkAccessRight: (model, operation) =>
operation === "create" && model === "product.product",
});
return store;
};
export const getFilledOrder = async (store, data = {}) => {
const order = store.addNewOrder(data);
const product1 = store.models["product.template"].get(5);
const product2 = store.models["product.template"].get(6);
const date = DateTime.now();
order.write_date = date;
order.create_date = date;
await store.addLineToOrder(
{
product_tmpl_id: product1,
qty: 3,
write_date: date,
create_date: date,
},
order
);
await store.addLineToOrder(
{
product_tmpl_id: product2,
qty: 2,
write_date: date,
create_date: date,
},
order
);
store.addPendingOrder([order.id]);
return order;
};
export async function waitUntilOrdersSynced(store, options) {
await waitUntil(() => !store.syncingOrders.size, options);
await tick();
}
export const mountPosDialog = async (component, props) => {
patchDialogComponent(component);
const dialog = getService("dialog");
const root = await mountWithCleanup(MainComponentsContainer);
const deferred = new Deferred();
const getComponentInstance = (root) => {
const flattenedChildren = (comp, acc = {}) => {
const array = Object.values(comp.children);
for (const child of array) {
acc[child.name] = child;
flattenedChildren(child, acc);
}
return acc;
};
const components = flattenedChildren(root);
return components[component.name];
};
dialog.add(component, {
...props,
onMounted() {
const dialogComponent = getComponentInstance(root.__owl__);
deferred.resolve(dialogComponent.component);
},
});
return await deferred;
};
export const patchDialogComponent = (component) => {
component.props = [...component.props, "onMounted?"];
patch(component.prototype, {
setup() {
super.setup();
onMounted(() => {
this.props.onMounted && this.props.onMounted();
});
},
});
};
export const expectFormattedPrice = (value, expected) => {
expect(value).toBe(expected.replaceAll(" ", "\u00a0"));
};
export const dialogActions = async (action, steps = []) => {
// Launch the action in a promise to be able to await the end of the steps
await mountWithCleanup(MainComponentsContainer);
const promise = new Promise((resolve) => {
const call = async (fn) => {
const result = await fn();
resolve(result);
};
call(action);
});
// Wait for the dialog to be mounted
await waitFor(".o_dialog");
// Execute the steps one by one
for (const step of steps) {
await step();
await animationFrame();
}
// Return the result of the action
return await promise;
};