@cavulsqa/create 2.9.2 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/bin/create.mjs +18 -25
- package/lib/templates.mjs +56 -0
- package/package.json +13 -5
- package/templates/f7-app/CLAUDE.md +3 -0
- package/templates/f7-app/auto-imports.d.ts +15 -0
- package/templates/f7-app/package.json +6 -3
- package/templates/f7-app/src/App.vue +1 -0
- package/templates/f7-app/src/env.d.ts +1 -0
- package/templates/f7-app/src/main.ts +3 -0
- package/templates/f7-app/src/modules/demo/router/routes/demo.routes.ts +4 -16
- package/templates/f7-app/src/modules/home/router/routes/home.routes.ts +3 -11
- package/templates/f7-app/src/modules/settings/router/routes/settings.routes.ts +2 -6
- package/templates/f7-app/src/plugins/bootstrapError.ts +2 -0
- package/templates/f7-app/src/plugins/recorder.plugin.ts +155 -0
- package/templates/f7-app/src/router/global/global.routes.ts +2 -6
- package/templates/f7-app/src/shared/composables/useNavigationGuard.ts +48 -0
- package/templates/f7-app/src/shared/recorder/capacitorSink.ts +124 -0
- package/templates/f7-app/src/shared/utils/lazyRoute.ts +25 -0
- package/templates/f7-app/tests/lazyRoute.test.ts +30 -0
- package/templates/f7-app/tests/navigationGuard.test.ts +82 -0
- package/templates/f7-app/vite.config.ts +1 -0
- package/templates/m3e-app/.claude/rules/data-fetching.md +68 -0
- package/templates/m3e-app/.claude/rules/database.md +106 -0
- package/templates/m3e-app/.claude/rules/m3e-ui.md +48 -0
- package/templates/m3e-app/.claude/rules/modules.md +43 -0
- package/templates/m3e-app/.claude/rules/native.md +60 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/SKILL.md +68 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/color.md +44 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/components.md +325 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/layout.md +42 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/motion.md +51 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/shapes-type.md +48 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/sources.md +53 -0
- package/templates/m3e-app/.claude/skills/module-architecture/SKILL.md +65 -0
- package/templates/m3e-app/.claude/skills/module-architecture/file-templates.md +178 -0
- package/templates/m3e-app/.claude/skills/reactive-data/SKILL.md +90 -0
- package/templates/m3e-app/.claude/skills/reactive-data/testing.md +55 -0
- package/templates/m3e-app/.env.example +32 -0
- package/templates/m3e-app/CLAUDE.md +93 -0
- package/templates/m3e-app/auto-imports.d.ts +851 -0
- package/templates/m3e-app/capacitor.config.ts +44 -0
- package/templates/m3e-app/components.d.ts +252 -0
- package/templates/m3e-app/index.html +16 -0
- package/templates/m3e-app/package.json +107 -0
- package/templates/m3e-app/src/App.vue +129 -0
- package/templates/m3e-app/src/app/pragmas.config.ts +35 -0
- package/templates/m3e-app/src/app/scroll.config.ts +20 -0
- package/templates/m3e-app/src/app/storage.config.ts +65 -0
- package/templates/m3e-app/src/app/tabs.ts +33 -0
- package/templates/m3e-app/src/app/theme.config.ts +51 -0
- package/templates/m3e-app/src/assets/css/app.css +18 -0
- package/templates/m3e-app/src/assets/css/base.css +53 -0
- package/templates/m3e-app/src/assets/css/layout/container-transform.css +113 -0
- package/templates/m3e-app/src/assets/css/layout/shell.css +69 -0
- package/templates/m3e-app/src/assets/css/layout/transitions.css +121 -0
- package/templates/m3e-app/src/assets/css/m3e.css +6 -0
- package/templates/m3e-app/src/assets/css/theme/framework7.css +18 -0
- package/templates/m3e-app/src/assets/css/theme/tailwind.css +365 -0
- package/templates/m3e-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
- package/templates/m3e-app/src/domains/benchmark/benchmark.suite.ts +662 -0
- package/templates/m3e-app/src/domains/sales/sales.repository.ts +458 -0
- package/templates/m3e-app/src/env.d.ts +40 -0
- package/templates/m3e-app/src/locales/ar.json +1260 -0
- package/templates/m3e-app/src/locales/en.json +1260 -0
- package/templates/m3e-app/src/locales/fr.json +1260 -0
- package/templates/m3e-app/src/main.ts +41 -0
- package/templates/m3e-app/src/modules/demo/components/DemoBenchmark.vue +119 -0
- package/templates/m3e-app/src/modules/demo/components/DemoBusLog.vue +63 -0
- package/templates/m3e-app/src/modules/demo/components/DemoCreateOrderSheet.vue +203 -0
- package/templates/m3e-app/src/modules/demo/components/DemoMetricsSheet.vue +75 -0
- package/templates/m3e-app/src/modules/demo/components/DemoOrderList.vue +75 -0
- package/templates/m3e-app/src/modules/demo/components/DemoPipelineBenchmark.vue +65 -0
- package/templates/m3e-app/src/modules/demo/components/DemoStatCards.vue +71 -0
- package/templates/m3e-app/src/modules/demo/composables/useBenchmark.ts +139 -0
- package/templates/m3e-app/src/modules/demo/composables/useOrderStatus.ts +64 -0
- package/templates/m3e-app/src/modules/demo/composables/useReactiveDemo.ts +171 -0
- package/templates/m3e-app/src/modules/demo/router/routes/demo.routes.ts +22 -0
- package/templates/m3e-app/src/modules/demo/views/DemoView.vue +210 -0
- package/templates/m3e-app/src/modules/demo/views/OrderDetailView.vue +136 -0
- package/templates/m3e-app/src/modules/demo/views/OrderSearchView.vue +74 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryBlock.vue +16 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryCarouselTile.vue +71 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryCustomerForm.vue +76 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryProofOfDelivery.vue +69 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaDay.vue +106 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaMonth.vue +40 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaVisitList.vue +45 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatAttachSheet.vue +106 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatContactPicker.vue +85 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatInviteComposer.vue +131 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatPollComposer.vue +148 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatRecentPhotos.vue +62 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsAccount.vue +79 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsTags.vue +53 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsVerification.vue +73 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/PasswordStrength.vue +37 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryButtons.vue +128 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCarousels.vue +236 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCharts.vue +84 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryFabs.vue +70 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryInputs.vue +219 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryNavigation.vue +141 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryOverlays.vue +368 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryPickers.vue +175 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryProgress.vue +118 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryScale.vue +103 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GallerySelection.vue +219 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryShapes.vue +48 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GallerySurfaces.vue +213 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryTables.vue +185 -0
- package/templates/m3e-app/src/modules/gallery/components/surfaces/SurfacesContainerTransform.vue +46 -0
- package/templates/m3e-app/src/modules/gallery/composables/chatTeam.ts +18 -0
- package/templates/m3e-app/src/modules/gallery/composables/composeKind.ts +2 -0
- package/templates/m3e-app/src/modules/gallery/composables/currentPosition.ts +35 -0
- package/templates/m3e-app/src/modules/gallery/composables/demoOrders.ts +29 -0
- package/templates/m3e-app/src/modules/gallery/composables/featuredStories.ts +73 -0
- package/templates/m3e-app/src/modules/gallery/composables/fieldFormats.ts +36 -0
- package/templates/m3e-app/src/modules/gallery/composables/openExternal.ts +11 -0
- package/templates/m3e-app/src/modules/gallery/composables/routePlan.ts +90 -0
- package/templates/m3e-app/src/modules/gallery/composables/useAgenda.ts +61 -0
- package/templates/m3e-app/src/modules/gallery/composables/useChatCustomers.ts +31 -0
- package/templates/m3e-app/src/modules/gallery/composables/useChatDemo.ts +299 -0
- package/templates/m3e-app/src/modules/gallery/composables/useGallerySections.ts +230 -0
- package/templates/m3e-app/src/modules/gallery/composables/useLocalAttachments.ts +71 -0
- package/templates/m3e-app/src/modules/gallery/composables/useLocationShare.ts +38 -0
- package/templates/m3e-app/src/modules/gallery/composables/usePhotoScenes.ts +82 -0
- package/templates/m3e-app/src/modules/gallery/composables/useTreeDemo.ts +67 -0
- package/templates/m3e-app/src/modules/gallery/composables/wilayas.ts +67 -0
- package/templates/m3e-app/src/modules/gallery/router/routes/gallery.routes.ts +52 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryAgendaView.vue +117 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryChatView.vue +292 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryContactsView.vue +77 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryFeaturedView.vue +60 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryLoginView.vue +135 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryOnboardingView.vue +116 -0
- package/templates/m3e-app/src/modules/gallery/views/GallerySectionView.vue +27 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryTabsView.vue +135 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryView.vue +50 -0
- package/templates/m3e-app/src/modules/home/components/HomeHero.vue +57 -0
- package/templates/m3e-app/src/modules/home/composables/useHomeFeatures.ts +128 -0
- package/templates/m3e-app/src/modules/home/composables/useOpenFeature.ts +22 -0
- package/templates/m3e-app/src/modules/home/router/routes/home.routes.ts +21 -0
- package/templates/m3e-app/src/modules/home/views/FeatureDetailView.vue +66 -0
- package/templates/m3e-app/src/modules/home/views/HomeView.vue +47 -0
- package/templates/m3e-app/src/modules/settings/components/RolePalette.vue +38 -0
- package/templates/m3e-app/src/modules/settings/components/SeedSwatches.vue +41 -0
- package/templates/m3e-app/src/modules/settings/components/SettingsChoice.vue +43 -0
- package/templates/m3e-app/src/modules/settings/components/StudioPreview.vue +52 -0
- package/templates/m3e-app/src/modules/settings/router/routes/settings.routes.ts +17 -0
- package/templates/m3e-app/src/modules/settings/types.ts +7 -0
- package/templates/m3e-app/src/modules/settings/views/ColorStudioView.vue +166 -0
- package/templates/m3e-app/src/modules/settings/views/SettingsView.vue +157 -0
- package/templates/m3e-app/src/plugins/bootstrapError.ts +62 -0
- package/templates/m3e-app/src/plugins/capacitor/index.ts +14 -0
- package/templates/m3e-app/src/plugins/capacitor/useAndroidBackButton.ts +36 -0
- package/templates/m3e-app/src/plugins/capacitor/useKeyboard.ts +48 -0
- package/templates/m3e-app/src/plugins/capacitor/useSplashScreen.ts +43 -0
- package/templates/m3e-app/src/plugins/capacitor/useStatusBar.ts +16 -0
- package/templates/m3e-app/src/plugins/framework7.plugin.ts +36 -0
- package/templates/m3e-app/src/plugins/i18n.plugin.ts +87 -0
- package/templates/m3e-app/src/plugins/m3e.plugin.ts +22 -0
- package/templates/m3e-app/src/plugins/seed.plugin.ts +7 -0
- package/templates/m3e-app/src/plugins/sqlite.plugin.ts +32 -0
- package/templates/m3e-app/src/router/global/global.routes.ts +12 -0
- package/templates/m3e-app/src/router/index.ts +20 -0
- package/templates/m3e-app/src/shared/components/error/404.vue +13 -0
- package/templates/m3e-app/src/shared/components/layout/EmptyState.vue +30 -0
- package/templates/m3e-app/src/shared/components/layout/SectionHeader.vue +10 -0
- package/templates/m3e-app/src/shared/components/page/AppPage.vue +104 -0
- package/templates/m3e-app/src/shared/composables/navigation/useActiveTab.ts +39 -0
- package/templates/m3e-app/src/shared/composables/navigation/useContainerTransform.ts +117 -0
- package/templates/m3e-app/src/shared/composables/navigation/useNavigationGuard.ts +48 -0
- package/templates/m3e-app/src/shared/composables/navigation/useNavigationVisibility.ts +64 -0
- package/templates/m3e-app/src/shared/composables/navigation/useViewRouter.ts +25 -0
- package/templates/m3e-app/src/shared/composables/navigation/useWindowClass.ts +22 -0
- package/templates/m3e-app/src/shared/composables/theme/useThemeSettings.ts +155 -0
- package/templates/m3e-app/src/shared/database/candidates/capacitorSqlite.ts +36 -0
- package/templates/m3e-app/src/shared/database/candidates/index.ts +4 -0
- package/templates/m3e-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
- package/templates/m3e-app/src/shared/database/candidates/types.ts +56 -0
- package/templates/m3e-app/src/shared/database/candidates/waSqlite.ts +56 -0
- package/templates/m3e-app/src/shared/database/database.ts +219 -0
- package/templates/m3e-app/src/shared/database/index.ts +3 -0
- package/templates/m3e-app/src/shared/database/migrations.ts +223 -0
- package/templates/m3e-app/src/shared/database/opfs.worker.ts +5 -0
- package/templates/m3e-app/src/shared/database/queries.ts +26 -0
- package/templates/m3e-app/src/shared/database/schema.ts +153 -0
- package/templates/m3e-app/src/shared/database/storage.ts +64 -0
- package/templates/m3e-app/src/shared/database/wa.worker.ts +5 -0
- package/templates/m3e-app/src/shared/utils/lazyRoute.ts +41 -0
- package/templates/m3e-app/src/shared/utils/resolvers/resolvers.ts +42 -0
- package/templates/m3e-app/src/shared/utils/textDirection.ts +22 -0
- package/templates/m3e-app/src/shared/utils/theme/themeSettings.ts +59 -0
- package/templates/m3e-app/src/shared/utils/tone.ts +8 -0
- package/templates/m3e-app/tests/benchmark.suite.test.ts +104 -0
- package/templates/m3e-app/tests/containerTransform.test.ts +58 -0
- package/templates/m3e-app/tests/fieldFormats.test.ts +25 -0
- package/templates/m3e-app/tests/lazyRoute.test.ts +30 -0
- package/templates/m3e-app/tests/locales.test.ts +73 -0
- package/templates/m3e-app/tests/migrations.test.ts +74 -0
- package/templates/m3e-app/tests/navigationGuard.test.ts +82 -0
- package/templates/m3e-app/tests/openDatabase.test.ts +54 -0
- package/templates/m3e-app/tests/sales.repository.test.ts +329 -0
- package/templates/m3e-app/tests/storage.test.ts +117 -0
- package/templates/m3e-app/tests/textDirection.test.ts +11 -0
- package/templates/m3e-app/tooling/linkedPackages.ts +112 -0
- package/templates/m3e-app/tsconfig.json +21 -0
- package/templates/m3e-app/vite.config.ts +144 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, test, vi } from "vite-plus/test";
|
|
3
|
+
|
|
4
|
+
/** Read raw: the app's i18n plugin compiles imported locale JSON into message functions. */
|
|
5
|
+
const load = (name: string) =>
|
|
6
|
+
JSON.parse(readFileSync(new URL(`../src/locales/${name}.json`, import.meta.url), "utf8")) as Tree;
|
|
7
|
+
const en = load("en");
|
|
8
|
+
const fr = load("fr");
|
|
9
|
+
const ar = load("ar");
|
|
10
|
+
|
|
11
|
+
vi.mock("framework7-vue", () => ({ f7: undefined }));
|
|
12
|
+
|
|
13
|
+
const { fromDevice, pluralRule } = await import("../src/plugins/i18n.plugin.js");
|
|
14
|
+
|
|
15
|
+
type Tree = { [key: string]: string | Tree };
|
|
16
|
+
|
|
17
|
+
function strings(tree: Tree, path = ""): Map<string, string> {
|
|
18
|
+
const out = new Map<string, string>();
|
|
19
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
20
|
+
const here = path ? `${path}.${key}` : key;
|
|
21
|
+
if (typeof value === "string") out.set(here, value);
|
|
22
|
+
else for (const [k, v] of strings(value, here)) out.set(k, v);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const placeholders = (text: string) =>
|
|
28
|
+
[...new Set([...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]))].sort().join();
|
|
29
|
+
|
|
30
|
+
describe("locales", () => {
|
|
31
|
+
const source = strings(en);
|
|
32
|
+
|
|
33
|
+
test.each([
|
|
34
|
+
["fr", fr],
|
|
35
|
+
["ar-DZ", ar],
|
|
36
|
+
])("%s has every English key, with its placeholders", (_, messages) => {
|
|
37
|
+
const target = strings(messages);
|
|
38
|
+
expect([...target.keys()].sort()).toEqual([...source.keys()].sort());
|
|
39
|
+
for (const [key, text] of source) {
|
|
40
|
+
expect(placeholders(target.get(key)!), key).toBe(placeholders(text));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("an Arabic plural carries all six forms", () => {
|
|
45
|
+
for (const [key, text] of strings(ar)) {
|
|
46
|
+
if (text.includes("|")) expect(text.split("|"), key).toHaveLength(6);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("Arabic plurals", () => {
|
|
52
|
+
const pick = pluralRule("ar-DZ");
|
|
53
|
+
test.each([
|
|
54
|
+
[0, 0],
|
|
55
|
+
[1, 1],
|
|
56
|
+
[2, 2],
|
|
57
|
+
[3, 3],
|
|
58
|
+
[10, 3],
|
|
59
|
+
[11, 4],
|
|
60
|
+
[99, 4],
|
|
61
|
+
[100, 5],
|
|
62
|
+
[102, 5],
|
|
63
|
+
])("%i takes form %i of six", (count, form) => {
|
|
64
|
+
expect(pick(count, 6)).toBe(form);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a device language finds its app locale whatever the region", () => {
|
|
69
|
+
expect(fromDevice("ar")).toBe("ar-DZ");
|
|
70
|
+
expect(fromDevice("ar-SA")).toBe("ar-DZ");
|
|
71
|
+
expect(fromDevice("fr-DZ")).toBe("fr");
|
|
72
|
+
expect(fromDevice("de")).toBeNull();
|
|
73
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { expect, test } from "vite-plus/test";
|
|
2
|
+
import { Kysely } from "kysely";
|
|
3
|
+
import { Migrator } from "kysely/migration";
|
|
4
|
+
import { createSqlJsDialect } from "@cavulsqa/mobile-db/testing";
|
|
5
|
+
import { migrations } from "../src/shared/database/migrations.js";
|
|
6
|
+
import type { Database } from "../src/shared/database/schema.js";
|
|
7
|
+
|
|
8
|
+
async function migrate(db: Kysely<Database>, set = migrations) {
|
|
9
|
+
return new Migrator({
|
|
10
|
+
db,
|
|
11
|
+
provider: { getMigrations: () => Promise.resolve(set) },
|
|
12
|
+
}).migrateToLatest();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("a fresh database gets every table the schema declares", async () => {
|
|
16
|
+
const db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
|
|
17
|
+
const result = await migrate(db);
|
|
18
|
+
expect(result.error).toBeUndefined();
|
|
19
|
+
|
|
20
|
+
const names = (await db.introspection.getTables()).map((t) => t.name);
|
|
21
|
+
for (const table of [
|
|
22
|
+
"customer",
|
|
23
|
+
"product",
|
|
24
|
+
"sales_order",
|
|
25
|
+
"order_line",
|
|
26
|
+
"tag",
|
|
27
|
+
"customer_tag",
|
|
28
|
+
"bench_region",
|
|
29
|
+
"bench_city",
|
|
30
|
+
"bench_customer",
|
|
31
|
+
"bench_category",
|
|
32
|
+
"bench_product",
|
|
33
|
+
"bench_warehouse",
|
|
34
|
+
"bench_stock",
|
|
35
|
+
"bench_order",
|
|
36
|
+
"bench_order_line",
|
|
37
|
+
"bench_payment",
|
|
38
|
+
]) {
|
|
39
|
+
expect(names, table).toContain(table);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The two-table benchmark 003 replaced must be gone, not merely unused.
|
|
43
|
+
expect(names).not.toContain("bench_parent");
|
|
44
|
+
expect(names).not.toContain("bench_child");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The failure this exists for: deleting an already-applied migration instead of emptying it. Kysely
|
|
49
|
+
* records applied migrations by key, so a recorded key the provider no longer offers makes it refuse
|
|
50
|
+
* the entire set - applying nothing, reporting the error in its return value rather than throwing,
|
|
51
|
+
* and leaving an app whose first query fails with "no such table".
|
|
52
|
+
*/
|
|
53
|
+
test("dropping an applied migration is refused, and the refusal is visible", async () => {
|
|
54
|
+
const db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
|
|
55
|
+
expect((await migrate(db)).error).toBeUndefined();
|
|
56
|
+
|
|
57
|
+
const { "002_benchmark": _removed, ...withoutTombstone } = migrations;
|
|
58
|
+
const result = await migrate(db, withoutTombstone);
|
|
59
|
+
|
|
60
|
+
expect(result.error, "kysely must object to the missing migration").toBeDefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("running twice is a no-op, and every key is still there", async () => {
|
|
64
|
+
const db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
|
|
65
|
+
const first = await migrate(db);
|
|
66
|
+
const second = await migrate(db);
|
|
67
|
+
|
|
68
|
+
expect(first.error).toBeUndefined();
|
|
69
|
+
expect(second.error).toBeUndefined();
|
|
70
|
+
expect(second.results ?? []).toEqual([]);
|
|
71
|
+
|
|
72
|
+
// The tombstone counts: a key may stop doing work but may not stop existing.
|
|
73
|
+
expect(Object.keys(migrations)).toEqual(["001_sales", "002_benchmark", "003_benchmark_scale"]);
|
|
74
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { expect, test } from "vite-plus/test";
|
|
2
|
+
import { installNavigationGuard } from "../src/shared/composables/navigation/useNavigationGuard.js";
|
|
3
|
+
|
|
4
|
+
function makeRouter() {
|
|
5
|
+
const calls: string[] = [];
|
|
6
|
+
const router = {
|
|
7
|
+
allowPageChange: true,
|
|
8
|
+
navigate(url: string) {
|
|
9
|
+
calls.push(`navigate:${url}`);
|
|
10
|
+
},
|
|
11
|
+
back() {
|
|
12
|
+
calls.push("back");
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
return { router, calls };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("drops a navigate issued while a transition is still in flight", () => {
|
|
19
|
+
const { router, calls } = makeRouter();
|
|
20
|
+
installNavigationGuard(router as never);
|
|
21
|
+
|
|
22
|
+
router.allowPageChange = false;
|
|
23
|
+
router.navigate("/home/feature/1/");
|
|
24
|
+
|
|
25
|
+
expect(calls).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("drops a back issued while a transition is still in flight", () => {
|
|
29
|
+
const { router, calls } = makeRouter();
|
|
30
|
+
installNavigationGuard(router as never);
|
|
31
|
+
|
|
32
|
+
router.allowPageChange = false;
|
|
33
|
+
router.back();
|
|
34
|
+
|
|
35
|
+
expect(calls).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("lets navigation through while the router is idle", () => {
|
|
39
|
+
const { router, calls } = makeRouter();
|
|
40
|
+
installNavigationGuard(router as never);
|
|
41
|
+
|
|
42
|
+
router.navigate("/home/feature/1/");
|
|
43
|
+
router.back();
|
|
44
|
+
|
|
45
|
+
expect(calls).toEqual(["navigate:/home/feature/1/", "back"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("still allows Framework7's own recursive calls after it locks the router", () => {
|
|
49
|
+
// back() re-enters itself with { force: true }, and navigate() re-enters for named routes, both
|
|
50
|
+
// after allowPageChange is already false. Dropping those would break normal navigation.
|
|
51
|
+
const calls: string[] = [];
|
|
52
|
+
const router = {
|
|
53
|
+
allowPageChange: true,
|
|
54
|
+
navigate(url: string) {
|
|
55
|
+
calls.push(`navigate:${url}`);
|
|
56
|
+
router.allowPageChange = false;
|
|
57
|
+
if (url === "outer") router.navigate("inner");
|
|
58
|
+
},
|
|
59
|
+
back() {
|
|
60
|
+
calls.push("back");
|
|
61
|
+
router.allowPageChange = false;
|
|
62
|
+
router.navigate("from-back");
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
installNavigationGuard(router as never);
|
|
66
|
+
|
|
67
|
+
router.navigate("outer");
|
|
68
|
+
router.allowPageChange = true;
|
|
69
|
+
router.back();
|
|
70
|
+
|
|
71
|
+
expect(calls).toEqual(["navigate:outer", "navigate:inner", "back", "navigate:from-back"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("installing twice does not wrap the router twice", () => {
|
|
75
|
+
const { router, calls } = makeRouter();
|
|
76
|
+
installNavigationGuard(router as never);
|
|
77
|
+
installNavigationGuard(router as never);
|
|
78
|
+
|
|
79
|
+
router.navigate("/demo/");
|
|
80
|
+
|
|
81
|
+
expect(calls).toEqual(["navigate:/demo/"]);
|
|
82
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, expect, test, vi } from "vite-plus/test";
|
|
2
|
+
import { storageChain } from "../src/app/storage.config.js";
|
|
3
|
+
import { closeDatabase, openDatabase } from "../src/shared/database/database.js";
|
|
4
|
+
|
|
5
|
+
afterEach(async () => {
|
|
6
|
+
await closeDatabase();
|
|
7
|
+
vi.restoreAllMocks();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Nothing in this environment can open a database, which is the point: what is under test is how
|
|
12
|
+
* many times the chain gets walked, not whether it succeeds.
|
|
13
|
+
*
|
|
14
|
+
* The guard used to be `if (database) return database` - a value, not a promise - so two callers in
|
|
15
|
+
* the same tick both walked it. The pool VFSes take their OPFS directory exclusively, so the second
|
|
16
|
+
* walk collided with the first, retried, failed, and fell through to a slower engine that the app
|
|
17
|
+
* then reported as its choice.
|
|
18
|
+
*/
|
|
19
|
+
test("concurrent callers share one walk of the chain", async () => {
|
|
20
|
+
const probes = storageChain.map((candidate) => vi.spyOn(candidate, "probe"));
|
|
21
|
+
|
|
22
|
+
const [first, second] = await Promise.allSettled([openDatabase(), openDatabase()]);
|
|
23
|
+
|
|
24
|
+
expect(first.status).toBe("rejected");
|
|
25
|
+
expect(second.status).toBe("rejected");
|
|
26
|
+
|
|
27
|
+
for (const [index, probe] of probes.entries()) {
|
|
28
|
+
expect(probe, storageChain[index]?.id).toHaveBeenCalledTimes(1);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a caller arriving after a failed walk gets a fresh attempt", async () => {
|
|
33
|
+
await expect(openDatabase()).rejects.toThrow(/No storage engine could open the database/);
|
|
34
|
+
|
|
35
|
+
const probes = storageChain.map((candidate) => vi.spyOn(candidate, "probe"));
|
|
36
|
+
await expect(openDatabase()).rejects.toThrow(/No storage engine could open the database/);
|
|
37
|
+
|
|
38
|
+
// Memoising the promise must not memoise the failure: a retry has to be able to try again.
|
|
39
|
+
for (const probe of probes) expect(probe).toHaveBeenCalledTimes(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the failure names every candidate and why it was passed over", async () => {
|
|
43
|
+
const failure = await openDatabase().then(
|
|
44
|
+
() => null,
|
|
45
|
+
(error: unknown) => (error instanceof Error ? error.message : String(error)),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// "the database would not open" on its own helps nobody: every attempt has to say what happened.
|
|
49
|
+
expect(failure).toBeTruthy();
|
|
50
|
+
for (const candidate of storageChain) {
|
|
51
|
+
expect(failure).toContain(candidate.id);
|
|
52
|
+
}
|
|
53
|
+
expect(failure).toContain("unsupported");
|
|
54
|
+
});
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { beforeEach, expect, test } from "vite-plus/test";
|
|
2
|
+
import { Kysely } from "kysely";
|
|
3
|
+
import { Migrator } from "kysely/migration";
|
|
4
|
+
import { createSqlJsDialect } from "@cavulsqa/mobile-db/testing";
|
|
5
|
+
import {
|
|
6
|
+
advanceOrderStatus,
|
|
7
|
+
clearAll,
|
|
8
|
+
createOrder,
|
|
9
|
+
ensureReferenceData,
|
|
10
|
+
listCustomers,
|
|
11
|
+
loadDashboardStats,
|
|
12
|
+
searchOrders,
|
|
13
|
+
seedSampleData,
|
|
14
|
+
deleteOrder,
|
|
15
|
+
nextOrderReference,
|
|
16
|
+
saveOrder,
|
|
17
|
+
setOrderStatus,
|
|
18
|
+
loadOrderDetail,
|
|
19
|
+
} from "../src/domains/sales/sales.repository.js";
|
|
20
|
+
import { migrations } from "../src/shared/database/migrations.js";
|
|
21
|
+
import type { Database } from "../src/shared/database/schema.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The queries run against real SQLite here - sql.js, the same dialect the packages use for their own
|
|
25
|
+
* tests - so the joins, the aggregates and the migration are executed rather than just type-checked.
|
|
26
|
+
* A schema this template ships broken would cost whoever generates from it an afternoon.
|
|
27
|
+
*/
|
|
28
|
+
let db: Kysely<Database>;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
|
|
32
|
+
await new Migrator({
|
|
33
|
+
db,
|
|
34
|
+
provider: { getMigrations: () => Promise.resolve(migrations) },
|
|
35
|
+
}).migrateToLatest();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the migration creates every table the schema declares", async () => {
|
|
39
|
+
const tables = await db.introspection.getTables();
|
|
40
|
+
const names = tables.map((table) => table.name).sort();
|
|
41
|
+
|
|
42
|
+
expect(names).toContain("customer");
|
|
43
|
+
expect(names).toContain("product");
|
|
44
|
+
expect(names).toContain("sales_order");
|
|
45
|
+
expect(names).toContain("order_line");
|
|
46
|
+
expect(names).toContain("tag");
|
|
47
|
+
expect(names).toContain("customer_tag");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("an empty database reports zeroes rather than nulls", async () => {
|
|
51
|
+
expect(await loadDashboardStats(db)).toEqual({
|
|
52
|
+
customers: 0,
|
|
53
|
+
products: 0,
|
|
54
|
+
orders: 0,
|
|
55
|
+
draft: 0,
|
|
56
|
+
confirmed: 0,
|
|
57
|
+
delivered: 0,
|
|
58
|
+
revenueCents: 0,
|
|
59
|
+
committedCents: 0,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The app seeds this at startup, so it runs on every launch. Running twice has to leave the same
|
|
65
|
+
* database it left the first time, or a second launch doubles the catalogue.
|
|
66
|
+
*/
|
|
67
|
+
test("reference data gives the app a catalogue and customers, and is idempotent", async () => {
|
|
68
|
+
await ensureReferenceData(db);
|
|
69
|
+
const first = await loadDashboardStats(db);
|
|
70
|
+
|
|
71
|
+
expect(first.products).toBeGreaterThan(0);
|
|
72
|
+
expect(first.customers).toBeGreaterThan(0);
|
|
73
|
+
// Reference data is what you need to write an order, not orders themselves.
|
|
74
|
+
expect(first.orders).toBe(0);
|
|
75
|
+
|
|
76
|
+
await ensureReferenceData(db);
|
|
77
|
+
expect(await loadDashboardStats(db)).toEqual(first);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("seeding adds demo orders on top of the reference data", async () => {
|
|
81
|
+
await ensureReferenceData(db);
|
|
82
|
+
const before = await loadDashboardStats(db);
|
|
83
|
+
|
|
84
|
+
await seedSampleData(db, 4);
|
|
85
|
+
const stats = await loadDashboardStats(db);
|
|
86
|
+
|
|
87
|
+
expect(stats.customers).toBe(before.customers + 4);
|
|
88
|
+
expect(stats.products).toBe(before.products);
|
|
89
|
+
// Two orders per customer the seed adds.
|
|
90
|
+
expect(stats.orders).toBe(8);
|
|
91
|
+
expect(stats.revenueCents).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("the order list joins the customer and totals its lines", async () => {
|
|
95
|
+
await seedSampleData(db, 2);
|
|
96
|
+
const orders = await searchOrders(db, "");
|
|
97
|
+
|
|
98
|
+
expect(orders.length).toBe(4);
|
|
99
|
+
const first = orders[0];
|
|
100
|
+
expect(first?.customerName).toMatch(/^Customer /);
|
|
101
|
+
expect(first?.city.length).toBeGreaterThan(0);
|
|
102
|
+
expect(first?.lines).toBeGreaterThan(0);
|
|
103
|
+
expect(first?.totalCents).toBeGreaterThan(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("search filters on the customer name and the reference together", async () => {
|
|
107
|
+
await seedSampleData(db, 3);
|
|
108
|
+
const all = await searchOrders(db, "");
|
|
109
|
+
|
|
110
|
+
// Taken from the data rather than hard-coded: the reference customers are numbered first, so the
|
|
111
|
+
// ones the seed adds do not start at 1.
|
|
112
|
+
const name = all[0]?.customerName ?? "";
|
|
113
|
+
const byCustomer = await searchOrders(db, name);
|
|
114
|
+
expect(byCustomer.length).toBeGreaterThan(0);
|
|
115
|
+
expect(byCustomer.every((order) => order.customerName === name)).toBe(true);
|
|
116
|
+
|
|
117
|
+
const reference = all[0]?.reference ?? "";
|
|
118
|
+
const byReference = await searchOrders(db, reference);
|
|
119
|
+
expect(byReference.some((order) => order.reference === reference)).toBe(true);
|
|
120
|
+
|
|
121
|
+
expect(await searchOrders(db, "nothing-matches-this")).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("advancing a status cycles draft to confirmed to delivered and back", async () => {
|
|
125
|
+
await seedSampleData(db, 1);
|
|
126
|
+
const [order] = await searchOrders(db, "");
|
|
127
|
+
const id = order?.id ?? 0;
|
|
128
|
+
|
|
129
|
+
expect(order?.status).toBe("draft");
|
|
130
|
+
|
|
131
|
+
await advanceOrderStatus(db, id);
|
|
132
|
+
expect((await searchOrders(db, "")).find((row) => row.id === id)?.status).toBe("confirmed");
|
|
133
|
+
|
|
134
|
+
await advanceOrderStatus(db, id);
|
|
135
|
+
expect((await searchOrders(db, "")).find((row) => row.id === id)?.status).toBe("delivered");
|
|
136
|
+
|
|
137
|
+
await advanceOrderStatus(db, id);
|
|
138
|
+
expect((await searchOrders(db, "")).find((row) => row.id === id)?.status).toBe("draft");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("advancing an order that does not exist is a no-op, not a throw", async () => {
|
|
142
|
+
await expect(advanceOrderStatus(db, 9999)).resolves.toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("customers come back ordered by name", async () => {
|
|
146
|
+
await seedSampleData(db, 3);
|
|
147
|
+
const customers = await listCustomers(db);
|
|
148
|
+
const names = customers.map((customer) => customer.name);
|
|
149
|
+
|
|
150
|
+
expect(names).toEqual([...names].sort());
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("clearing leaves every table empty", async () => {
|
|
154
|
+
await seedSampleData(db, 3);
|
|
155
|
+
await clearAll(db);
|
|
156
|
+
|
|
157
|
+
expect(await loadDashboardStats(db)).toEqual({
|
|
158
|
+
customers: 0,
|
|
159
|
+
products: 0,
|
|
160
|
+
orders: 0,
|
|
161
|
+
draft: 0,
|
|
162
|
+
confirmed: 0,
|
|
163
|
+
delivered: 0,
|
|
164
|
+
revenueCents: 0,
|
|
165
|
+
committedCents: 0,
|
|
166
|
+
});
|
|
167
|
+
expect(await searchOrders(db, "")).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("an order's total is quantity times unit price, summed over its lines", async () => {
|
|
171
|
+
await db
|
|
172
|
+
.insertInto("customer")
|
|
173
|
+
.values({ created_at: "now", name: "Solo", city: "Oran" })
|
|
174
|
+
.execute();
|
|
175
|
+
await db
|
|
176
|
+
.insertInto("product")
|
|
177
|
+
.values({ created_at: "now", name: "Widget", price_cents: 1000 })
|
|
178
|
+
.execute();
|
|
179
|
+
|
|
180
|
+
const customer = await db.selectFrom("customer").select("id").executeTakeFirstOrThrow();
|
|
181
|
+
const product = await db
|
|
182
|
+
.selectFrom("product")
|
|
183
|
+
.select(["id", "price_cents"])
|
|
184
|
+
.executeTakeFirstOrThrow();
|
|
185
|
+
|
|
186
|
+
// Two lines, quantities 1 and 2 at 1000 each: 1000 + 2000.
|
|
187
|
+
await createOrder(db, customer.id, [product, product]);
|
|
188
|
+
|
|
189
|
+
const [order] = await searchOrders(db, "");
|
|
190
|
+
expect(order?.lines).toBe(2);
|
|
191
|
+
expect(order?.totalCents).toBe(3000);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("committed revenue counts only confirmed and delivered orders", async () => {
|
|
195
|
+
await seedSampleData(db, 2);
|
|
196
|
+
const before = await loadDashboardStats(db);
|
|
197
|
+
|
|
198
|
+
// Everything starts as a draft, so nothing is committed yet.
|
|
199
|
+
expect(before.draft).toBe(4);
|
|
200
|
+
expect(before.committedCents).toBe(0);
|
|
201
|
+
expect(before.revenueCents).toBeGreaterThan(0);
|
|
202
|
+
|
|
203
|
+
const [first] = await searchOrders(db, "");
|
|
204
|
+
await setOrderStatus(db, first?.id ?? 0, "confirmed");
|
|
205
|
+
|
|
206
|
+
const after = await loadDashboardStats(db);
|
|
207
|
+
expect(after.draft).toBe(3);
|
|
208
|
+
expect(after.confirmed).toBe(1);
|
|
209
|
+
expect(after.committedCents).toBe(first?.totalCents ?? -1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("the next reference counts up and survives a deletion without reuse", async () => {
|
|
213
|
+
expect(await nextOrderReference(db)).toBe("SO-0001");
|
|
214
|
+
|
|
215
|
+
const customer = await db
|
|
216
|
+
.insertInto("customer")
|
|
217
|
+
.values({ created_at: "now", name: "Solo", city: "Oran" })
|
|
218
|
+
.returning("id")
|
|
219
|
+
.executeTakeFirstOrThrow();
|
|
220
|
+
await db
|
|
221
|
+
.insertInto("product")
|
|
222
|
+
.values({ created_at: "now", name: "Widget", price_cents: 500 })
|
|
223
|
+
.execute();
|
|
224
|
+
const product = await db.selectFrom("product").select("id").executeTakeFirstOrThrow();
|
|
225
|
+
|
|
226
|
+
await saveOrder(db, {
|
|
227
|
+
customerId: customer.id,
|
|
228
|
+
reference: "SO-0001",
|
|
229
|
+
lines: [{ productId: product.id, quantity: 2, unitPriceCents: 500 }],
|
|
230
|
+
});
|
|
231
|
+
expect(await nextOrderReference(db)).toBe("SO-0002");
|
|
232
|
+
|
|
233
|
+
await saveOrder(db, {
|
|
234
|
+
customerId: customer.id,
|
|
235
|
+
reference: "SO-0002",
|
|
236
|
+
lines: [{ productId: product.id, quantity: 1, unitPriceCents: 500 }],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Removing the highest must not hand the same reference out twice.
|
|
240
|
+
const orders = await searchOrders(db, "SO-0002");
|
|
241
|
+
await deleteOrder(db, orders[0]?.id ?? 0);
|
|
242
|
+
expect(await nextOrderReference(db)).toBe("SO-0002");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("saving writes the order and all of its lines together", async () => {
|
|
246
|
+
const customer = await db
|
|
247
|
+
.insertInto("customer")
|
|
248
|
+
.values({ created_at: "now", name: "Solo", city: "Oran" })
|
|
249
|
+
.returning("id")
|
|
250
|
+
.executeTakeFirstOrThrow();
|
|
251
|
+
await db
|
|
252
|
+
.insertInto("product")
|
|
253
|
+
.values({ created_at: "now", name: "Widget", price_cents: 250 })
|
|
254
|
+
.execute();
|
|
255
|
+
const product = await db.selectFrom("product").select("id").executeTakeFirstOrThrow();
|
|
256
|
+
|
|
257
|
+
await saveOrder(db, {
|
|
258
|
+
customerId: customer.id,
|
|
259
|
+
reference: "SO-0007",
|
|
260
|
+
lines: [
|
|
261
|
+
{ productId: product.id, quantity: 3, unitPriceCents: 250 },
|
|
262
|
+
{ productId: product.id, quantity: 1, unitPriceCents: 250 },
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const [order] = await searchOrders(db, "SO-0007");
|
|
267
|
+
expect(order?.lines).toBe(2);
|
|
268
|
+
expect(order?.totalCents).toBe(1000);
|
|
269
|
+
expect(order?.status).toBe("draft");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("deleting an order takes its lines with it", async () => {
|
|
273
|
+
await seedSampleData(db, 1);
|
|
274
|
+
const [order] = await searchOrders(db, "");
|
|
275
|
+
const id = order?.id ?? 0;
|
|
276
|
+
|
|
277
|
+
await deleteOrder(db, id);
|
|
278
|
+
|
|
279
|
+
expect((await searchOrders(db, "")).some((row) => row.id === id)).toBe(false);
|
|
280
|
+
const orphans = await db
|
|
281
|
+
.selectFrom("order_line")
|
|
282
|
+
.select("id")
|
|
283
|
+
.where("order_id", "=", id)
|
|
284
|
+
.execute();
|
|
285
|
+
expect(orphans).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("seeding twice adds customers without duplicating the catalogue or throwing", async () => {
|
|
289
|
+
await seedSampleData(db, 2);
|
|
290
|
+
const first = await loadDashboardStats(db);
|
|
291
|
+
|
|
292
|
+
// This threw "UNIQUE constraint failed: tag.label" before the seed became additive.
|
|
293
|
+
await seedSampleData(db, 2);
|
|
294
|
+
const second = await loadDashboardStats(db);
|
|
295
|
+
|
|
296
|
+
expect(second.customers).toBe(first.customers + 2);
|
|
297
|
+
expect(second.orders).toBe(first.orders + 4);
|
|
298
|
+
// The catalogue is fixed, so a second press must not double it.
|
|
299
|
+
expect(second.products).toBe(first.products);
|
|
300
|
+
|
|
301
|
+
const tags = await db.selectFrom("tag").select("label").execute();
|
|
302
|
+
expect(new Set(tags.map((tag) => tag.label)).size).toBe(tags.length);
|
|
303
|
+
|
|
304
|
+
const names = (await listCustomers(db)).map((customer) => customer.name);
|
|
305
|
+
expect(new Set(names).size).toBe(names.length);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("the order detail reads its lines, product names and customer tags", async () => {
|
|
309
|
+
await seedSampleData(db, 1);
|
|
310
|
+
const [row] = await searchOrders(db, "");
|
|
311
|
+
const detail = await loadOrderDetail(db, row?.id ?? 0);
|
|
312
|
+
|
|
313
|
+
expect(detail?.reference).toBe(row?.reference);
|
|
314
|
+
expect(detail?.customerName).toBe(row?.customerName);
|
|
315
|
+
expect(detail?.lines.length).toBe(row?.lines);
|
|
316
|
+
expect(detail?.lines.every((line) => line.productName.length > 0)).toBe(true);
|
|
317
|
+
expect(detail?.totalCents).toBe(row?.totalCents);
|
|
318
|
+
// Seeding attaches one tag per customer.
|
|
319
|
+
expect(detail?.tags.length).toBeGreaterThan(0);
|
|
320
|
+
|
|
321
|
+
// Each line total is quantity times unit price.
|
|
322
|
+
for (const line of detail?.lines ?? []) {
|
|
323
|
+
expect(line.lineTotalCents).toBe(line.quantity * line.unitPriceCents);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("the order detail is null for an id that does not exist", async () => {
|
|
328
|
+
expect(await loadOrderDetail(db, 4242)).toBeNull();
|
|
329
|
+
});
|