@carlonicora/nextjs-jsonapi 3.11.0 → 3.13.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.
Files changed (156) hide show
  1. package/dist/{AssistantInterface-DwdBzS9f.d.ts → AssistantInterface-RvZzlcA_.d.ts} +2 -0
  2. package/dist/{AssistantInterface-DMh-zApr.d.mts → AssistantInterface-jL56dyNc.d.mts} +2 -0
  3. package/dist/{AssistantMessageInterface-0zyVingf.d.ts → AssistantMessageInterface-CuaMDs2i.d.ts} +1 -1
  4. package/dist/{AssistantMessageInterface-ddyG114R.d.mts → AssistantMessageInterface-Du3ShThp.d.mts} +1 -1
  5. package/dist/{BlockNoteEditor-XOVNTXDY.js → BlockNoteEditor-CFVSHS46.js} +19 -19
  6. package/dist/{BlockNoteEditor-XOVNTXDY.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
  7. package/dist/{BlockNoteEditor-NBHDEZ7W.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
  8. package/dist/{BlockNoteViewer-C2UUOBAF.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
  9. package/dist/{BlockNoteViewer-C2UUOBAF.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
  10. package/dist/{BlockNoteViewer-TYLVTIUD.mjs → BlockNoteViewer-P2VKEFRN.mjs} +2 -2
  11. package/dist/{ai-connection.module-28f-hux0.d.mts → HandbookSectionInterface-BOxIdBdd.d.mts} +54 -1
  12. package/dist/{ai-connection.module-Bqwh_DnR.d.ts → HandbookSectionInterface-CAXxbDw0.d.ts} +54 -1
  13. package/dist/{auth.interface-B6ZQLGjS.d.ts → auth.interface-BC1jp_ni.d.ts} +1 -1
  14. package/dist/{auth.interface-jYD8Gc4P.d.mts → auth.interface-dR2-6gPM.d.mts} +1 -1
  15. package/dist/billing/index.d.mts +27 -0
  16. package/dist/billing/index.d.ts +27 -0
  17. package/dist/billing/index.js +697 -450
  18. package/dist/billing/index.js.map +1 -1
  19. package/dist/billing/index.mjs +282 -35
  20. package/dist/billing/index.mjs.map +1 -1
  21. package/dist/{chunk-6J2SQZ3G.js → chunk-IB7DTXWG.js} +847 -129
  22. package/dist/chunk-IB7DTXWG.js.map +1 -0
  23. package/dist/{chunk-YN4LOVL4.js → chunk-LVE3BJ6K.js} +2106 -1109
  24. package/dist/chunk-LVE3BJ6K.js.map +1 -0
  25. package/dist/{chunk-M5QAHSCY.mjs → chunk-OSFYCSTW.mjs} +725 -7
  26. package/dist/chunk-OSFYCSTW.mjs.map +1 -0
  27. package/dist/{chunk-XNGI4HDZ.mjs → chunk-UQXE3SME.mjs} +2100 -1103
  28. package/dist/chunk-UQXE3SME.mjs.map +1 -0
  29. package/dist/{chunk-IMSIVIIC.mjs → chunk-VZKJIAND.mjs} +2 -2
  30. package/dist/{chunk-V6CGCARD.js → chunk-XH4IURUD.js} +7 -7
  31. package/dist/{chunk-V6CGCARD.js.map → chunk-XH4IURUD.js.map} +1 -1
  32. package/dist/client/index.d.mts +2 -2
  33. package/dist/client/index.d.ts +2 -2
  34. package/dist/client/index.js +4 -4
  35. package/dist/client/index.mjs +3 -3
  36. package/dist/components/index.d.mts +258 -7
  37. package/dist/components/index.d.ts +258 -7
  38. package/dist/components/index.js +30 -4
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/components/index.mjs +29 -3
  41. package/dist/contexts/index.d.mts +17 -5
  42. package/dist/contexts/index.d.ts +17 -5
  43. package/dist/contexts/index.js +4 -4
  44. package/dist/contexts/index.mjs +3 -3
  45. package/dist/core/index.d.mts +493 -12
  46. package/dist/core/index.d.ts +493 -12
  47. package/dist/core/index.js +38 -2
  48. package/dist/core/index.js.map +1 -1
  49. package/dist/core/index.mjs +37 -1
  50. package/dist/features/help/index.js +37 -37
  51. package/dist/features/help/index.mjs +3 -3
  52. package/dist/features/tokenusage/index.js +101 -101
  53. package/dist/features/tokenusage/index.mjs +3 -3
  54. package/dist/index.d.mts +7 -7
  55. package/dist/index.d.ts +7 -7
  56. package/dist/index.js +39 -3
  57. package/dist/index.js.map +1 -1
  58. package/dist/index.mjs +38 -2
  59. package/dist/{notification.interface-DYF56CtE.d.ts → notification.interface-CjzV_KII.d.ts} +1 -1
  60. package/dist/{notification.interface-BWOCvKRP.d.mts → notification.interface-MnRt1Tjm.d.mts} +1 -1
  61. package/dist/{s3.service-D_dswjgz.d.mts → s3.service-C0Yq2Sx3.d.mts} +2 -2
  62. package/dist/{s3.service-DserU9VK.d.ts → s3.service-CUGarZs3.d.ts} +2 -2
  63. package/dist/server/index.d.mts +3 -3
  64. package/dist/server/index.d.ts +3 -3
  65. package/dist/server/index.js +3 -3
  66. package/dist/server/index.mjs +1 -1
  67. package/dist/{useSocket-BZG9oX-L.d.mts → useSocket-BcH7l7B8.d.mts} +1 -1
  68. package/dist/{useSocket-CiyTtYFj.d.ts → useSocket-DCZd_eJk.d.ts} +1 -1
  69. package/package.json +1 -1
  70. package/src/components/containers/ReactMarkdownContainer.tsx +61 -11
  71. package/src/components/index.ts +1 -0
  72. package/src/core/index.ts +5 -0
  73. package/src/core/registry/ModuleRegistry.ts +4 -0
  74. package/src/features/assistant/components/parts/AssistantComposer.tsx +22 -3
  75. package/src/features/assistant/components/parts/AssistantSidebar.tsx +13 -3
  76. package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +10 -3
  77. package/src/features/assistant/contexts/AssistantContext.tsx +30 -3
  78. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +32 -0
  79. package/src/features/assistant/data/Assistant.ts +2 -0
  80. package/src/features/assistant/data/AssistantInterface.ts +2 -0
  81. package/src/features/assistant/data/AssistantService.ts +4 -0
  82. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +5 -1
  83. package/src/features/assistant-message/data/AssistantMessage.ts +27 -2
  84. package/src/features/billing/i18n-keys.ts +7 -0
  85. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +108 -2
  86. package/src/features/billing/stripe-price/components/forms/__tests__/PriceEditor.spec.tsx +182 -0
  87. package/src/features/billing/stripe-price/components/lists/PricesList.tsx +152 -25
  88. package/src/features/billing/stripe-price/components/lists/__tests__/PricesList.spec.tsx +268 -0
  89. package/src/features/billing/stripe-price/data/__tests__/stripe-price.transfer.spec.ts +265 -0
  90. package/src/features/billing/stripe-price/data/stripe-price.fields.ts +1 -0
  91. package/src/features/billing/stripe-price/data/stripe-price.transfer.ts +194 -0
  92. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +66 -2
  93. package/src/features/content/data/content.interface.ts +1 -1
  94. package/src/features/handbook/HandbookPageModule.ts +41 -0
  95. package/src/features/handbook/HandbookSectionModule.ts +35 -0
  96. package/src/features/handbook/HandbookThreadMessageModule.ts +21 -0
  97. package/src/features/handbook/HandbookThreadModule.ts +29 -0
  98. package/src/features/handbook/components/containers/HandbookAdminContainer.tsx +17 -0
  99. package/src/features/handbook/components/containers/HandbookAskContainer.tsx +142 -0
  100. package/src/features/handbook/components/containers/HandbookAskSheet.tsx +149 -0
  101. package/src/features/handbook/components/containers/HandbookPageContainer.tsx +143 -0
  102. package/src/features/handbook/components/containers/__tests__/HandbookAskContainer.spec.tsx +268 -0
  103. package/src/features/handbook/components/containers/__tests__/HandbookAskSheet.spec.tsx +143 -0
  104. package/src/features/handbook/components/containers/__tests__/HandbookPageContainer.spec.tsx +288 -0
  105. package/src/features/handbook/components/details/HandbookPageDetails.tsx +27 -0
  106. package/src/features/handbook/components/details/HandbookPageNeighbours.tsx +72 -0
  107. package/src/features/handbook/components/details/HandbookPageToc.tsx +165 -0
  108. package/src/features/handbook/components/forms/HandbookSyncButton.tsx +43 -0
  109. package/src/features/handbook/components/forms/__tests__/HandbookSyncButton.spec.tsx +42 -0
  110. package/src/features/handbook/components/index.ts +12 -0
  111. package/src/features/handbook/components/lists/HandbookContents.tsx +196 -0
  112. package/src/features/handbook/components/lists/HandbookSectionGroup.tsx +153 -0
  113. package/src/features/handbook/components/lists/__tests__/HandbookContents.spec.tsx +247 -0
  114. package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +62 -0
  115. package/src/features/handbook/components/parts/HandbookPageNavigator.tsx +143 -0
  116. package/src/features/handbook/contexts/HandbookContext.tsx +109 -0
  117. package/src/features/handbook/contexts/__tests__/HandbookContext.spec.tsx +131 -0
  118. package/src/features/handbook/data/HandbookPage.spec.ts +136 -0
  119. package/src/features/handbook/data/HandbookPage.ts +99 -0
  120. package/src/features/handbook/data/HandbookPageFields.ts +31 -0
  121. package/src/features/handbook/data/HandbookPageInput.ts +8 -0
  122. package/src/features/handbook/data/HandbookPageInterface.ts +29 -0
  123. package/src/features/handbook/data/HandbookPageService.ts +60 -0
  124. package/src/features/handbook/data/HandbookSection.spec.ts +58 -0
  125. package/src/features/handbook/data/HandbookSection.ts +73 -0
  126. package/src/features/handbook/data/HandbookSectionInput.ts +12 -0
  127. package/src/features/handbook/data/HandbookSectionInterface.ts +26 -0
  128. package/src/features/handbook/data/HandbookSectionService.ts +26 -0
  129. package/src/features/handbook/data/HandbookThread.ts +114 -0
  130. package/src/features/handbook/data/HandbookThreadInterface.ts +31 -0
  131. package/src/features/handbook/data/HandbookThreadMessage.ts +144 -0
  132. package/src/features/handbook/data/HandbookThreadMessageInterface.ts +35 -0
  133. package/src/features/handbook/data/HandbookThreadMessageService.ts +30 -0
  134. package/src/features/handbook/data/HandbookThreadService.ts +86 -0
  135. package/src/features/handbook/data/__tests__/HandbookThreadService.spec.ts +112 -0
  136. package/src/features/handbook/data/handbookDisplay.spec.ts +92 -0
  137. package/src/features/handbook/data/handbookDisplay.ts +55 -0
  138. package/src/features/handbook/data/index.ts +16 -0
  139. package/src/features/handbook/hooks/__tests__/useHandbookAsk.spec.ts +198 -0
  140. package/src/features/handbook/hooks/useHandbookAsk.ts +213 -0
  141. package/src/features/handbook/i18n-keys.ts +46 -0
  142. package/src/features/handbook/index.ts +7 -0
  143. package/src/features/how-to/components/forms/HowToEditor.tsx +0 -1
  144. package/src/features/how-to/components/forms/HowToReindexButton.spec.tsx +84 -0
  145. package/src/features/how-to/components/forms/HowToReindexButton.tsx +46 -0
  146. package/src/features/how-to/components/index.ts +1 -0
  147. package/src/features/how-to/components/lists/HowToList.tsx +5 -1
  148. package/src/features/how-to/data/HowTo.spec.ts +61 -1
  149. package/src/features/how-to/data/HowToService.ts +13 -0
  150. package/dist/chunk-6J2SQZ3G.js.map +0 -1
  151. package/dist/chunk-M5QAHSCY.mjs.map +0 -1
  152. package/dist/chunk-XNGI4HDZ.mjs.map +0 -1
  153. package/dist/chunk-YN4LOVL4.js.map +0 -1
  154. /package/dist/{BlockNoteEditor-NBHDEZ7W.mjs.map → BlockNoteEditor-SERY337S.mjs.map} +0 -0
  155. /package/dist/{BlockNoteViewer-TYLVTIUD.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
  156. /package/dist/{chunk-IMSIVIIC.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
@@ -0,0 +1,268 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { DataClassRegistry } from "../../../../../core/registry/DataClassRegistry";
6
+ import { ApiRequestDataTypeInterface } from "../../../../../core/interfaces/ApiRequestDataTypeInterface";
7
+ import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
8
+ import { HandbookThread } from "../../../data/HandbookThread";
9
+ import { HandbookThreadMessage } from "../../../data/HandbookThreadMessage";
10
+ import { HandbookThreadMessageService } from "../../../data/HandbookThreadMessageService";
11
+ import { HandbookThreadService } from "../../../data/HandbookThreadService";
12
+ import { HandbookAskContainer } from "../HandbookAskContainer";
13
+
14
+ vi.mock("../../../data/HandbookThreadService", () => ({
15
+ HandbookThreadService: {
16
+ findMany: vi.fn(),
17
+ findOne: vi.fn(),
18
+ create: vi.fn(),
19
+ rename: vi.fn(),
20
+ delete: vi.fn(),
21
+ },
22
+ }));
23
+
24
+ vi.mock("../../../data/HandbookThreadMessageService", () => ({
25
+ HandbookThreadMessageService: { ask: vi.fn() },
26
+ }));
27
+
28
+ // RoundPageContainer is page chrome — header, details panel, url rewriting.
29
+ // None of it is under test here, and all of it needs providers this spec has no
30
+ // reason to mount. The stub still renders `details`, because that is where the
31
+ // thread list lives in this layout (copied from AssistantPageContainer).
32
+ vi.mock("../../../../../components/containers/RoundPageContainer", () => ({
33
+ RoundPageContainer: ({ children, details }: { children?: React.ReactNode; details?: React.ReactNode }) => (
34
+ <div>
35
+ {details}
36
+ {children}
37
+ </div>
38
+ ),
39
+ }));
40
+
41
+ // RoundPageContainer reads Modules.HandbookPage; the handbook models resolve
42
+ // Modules.HandbookThread / Modules.HandbookThreadMessage lazily through
43
+ // ModuleRegistry. Registering is the package's spec convention (see
44
+ // HandbookPage.spec.ts) and is preferred to mocking the core barrel, which the
45
+ // reused assistant components also import from.
46
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
47
+ try {
48
+ ModuleRegistry.get(key as any);
49
+ } catch {
50
+ ModuleRegistry.register(key, module);
51
+ }
52
+ };
53
+
54
+ beforeAll(() => {
55
+ registerIfAbsent("HandbookPage", { name: "handbookpages" } as any);
56
+ const threadModule = { name: "handbookthreads", model: HandbookThread } as any;
57
+ const messageModule = { name: "handbookthreadmessages", model: HandbookThreadMessage } as any;
58
+ registerIfAbsent("HandbookThread", threadModule);
59
+ registerIfAbsent("HandbookThreadMessage", messageModule);
60
+ // `_readIncluded` and `rehydrate()` resolve the CONSTRUCTOR through
61
+ // DataClassRegistry, which the app's Bootstrapper fills from the same module
62
+ // definitions — registering only in ModuleRegistry leaves side-loaded
63
+ // messages unrehydratable.
64
+ DataClassRegistry.registerObjectClass(threadModule, HandbookThread as any);
65
+ DataClassRegistry.registerObjectClass(messageModule, HandbookThreadMessage as any);
66
+ });
67
+
68
+ function buildThread(params: {
69
+ id: string;
70
+ title: string;
71
+ updatedAt?: string;
72
+ messages?: { id: string; role: "user" | "assistant"; content: string; position: number; sources?: string[] }[];
73
+ }): HandbookThread {
74
+ const messages = params.messages ?? [];
75
+ return new HandbookThread().rehydrate({
76
+ jsonApi: {
77
+ id: params.id,
78
+ type: "handbookthreads",
79
+ attributes: { title: params.title },
80
+ meta: { updatedAt: params.updatedAt ?? new Date().toISOString() },
81
+ relationships: {
82
+ messages: { data: messages.map((m) => ({ id: m.id, type: "handbookthreadmessages" })) },
83
+ },
84
+ },
85
+ included: messages.map((m) => ({
86
+ id: m.id,
87
+ type: "handbookthreadmessages",
88
+ attributes: { role: m.role, content: m.content, position: m.position, sources: m.sources ?? [] },
89
+ })),
90
+ } as any);
91
+ }
92
+
93
+ /** The send control belongs to the shared AssistantComposer, not to this feature. */
94
+ const sendButton = () => screen.getByRole("button", { name: /handbook\.chat\.ask/i });
95
+
96
+ describe("HandbookAskContainer", () => {
97
+ beforeEach(() => {
98
+ vi.mocked(HandbookThreadService.findMany).mockReset().mockResolvedValue([]);
99
+ vi.mocked(HandbookThreadService.findOne).mockReset();
100
+ vi.mocked(HandbookThreadService.create).mockReset();
101
+ vi.mocked(HandbookThreadService.delete).mockReset().mockResolvedValue(undefined);
102
+ vi.mocked(HandbookThreadService.rename).mockReset().mockResolvedValue(undefined);
103
+ vi.mocked(HandbookThreadMessageService.ask).mockReset().mockResolvedValue(undefined);
104
+ });
105
+
106
+ it("reuses the shared composer and sidebar but never the assistant's copy", async () => {
107
+ render(<HandbookAskContainer />);
108
+
109
+ expect(screen.getByPlaceholderText("handbook.chat.placeholder")).toBeInTheDocument();
110
+ expect(screen.queryByPlaceholderText("features.assistant.composer_placeholder")).not.toBeInTheDocument();
111
+ expect(sendButton()).toBeDisabled();
112
+ expect(screen.getByRole("button", { name: /handbook\.chat\.new/ })).toBeInTheDocument();
113
+ await waitFor(() => expect(screen.getByText("handbook.chat.empty_sidebar")).toBeInTheDocument());
114
+ expect(screen.queryByText("features.assistant.empty_sidebar")).not.toBeInTheDocument();
115
+ });
116
+
117
+ it("shows the handbook empty state until a thread exists", async () => {
118
+ render(<HandbookAskContainer />);
119
+
120
+ expect(screen.getByText("handbook.chat.empty_state.title")).toBeInTheDocument();
121
+ expect(screen.getByText("handbook.chat.empty_state.subtitle")).toBeInTheDocument();
122
+ await waitFor(() => expect(HandbookThreadService.findMany).toHaveBeenCalled());
123
+ });
124
+
125
+ it("lists the previous threads in the sidebar", async () => {
126
+ vi.mocked(HandbookThreadService.findMany).mockResolvedValue([
127
+ buildThread({ id: "t1", title: "Filtri di azienda" }),
128
+ buildThread({ id: "t2", title: "Porte del worker" }),
129
+ ]);
130
+
131
+ render(<HandbookAskContainer />);
132
+
133
+ await waitFor(() => expect(screen.getByRole("button", { name: "Filtri di azienda" })).toBeInTheDocument());
134
+ expect(screen.getByRole("button", { name: "Porte del worker" })).toBeInTheDocument();
135
+ });
136
+
137
+ it("loads the messages of the thread picked from the sidebar", async () => {
138
+ vi.mocked(HandbookThreadService.findMany).mockResolvedValue([
139
+ buildThread({ id: "t1", title: "Filtri di azienda" }),
140
+ ]);
141
+ vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
142
+ buildThread({
143
+ id: "t1",
144
+ title: "Filtri di azienda",
145
+ messages: [
146
+ { id: "m1", role: "user", content: "Come funziona buildDefaultMatch?", position: 0 },
147
+ { id: "m2", role: "assistant", content: "Inietta il filtro di azienda.", position: 1 },
148
+ ],
149
+ }),
150
+ );
151
+
152
+ render(<HandbookAskContainer />);
153
+ await userEvent.click(await screen.findByRole("button", { name: "Filtri di azienda" }));
154
+
155
+ await waitFor(() => expect(HandbookThreadService.findOne).toHaveBeenCalledWith({ id: "t1" }));
156
+ expect(await screen.findByText("Come funziona buildDefaultMatch?")).toBeInTheDocument();
157
+ expect(screen.getByText(/Inietta il filtro di azienda/)).toBeInTheDocument();
158
+ });
159
+
160
+ it("asks the trimmed question, creates a thread and shows its title", async () => {
161
+ vi.mocked(HandbookThreadService.create).mockResolvedValue(buildThread({ id: "t9", title: "Filtri di azienda" }));
162
+ vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
163
+ buildThread({
164
+ id: "t9",
165
+ title: "Filtri di azienda",
166
+ messages: [
167
+ { id: "m1", role: "user", content: "Come funziona buildDefaultMatch?", position: 0 },
168
+ { id: "m2", role: "assistant", content: "Inietta il filtro di azienda.", position: 1 },
169
+ ],
170
+ }),
171
+ );
172
+
173
+ render(<HandbookAskContainer />);
174
+ await userEvent.type(screen.getByRole("textbox"), " Come funziona buildDefaultMatch? ");
175
+ await userEvent.click(sendButton());
176
+
177
+ await waitFor(() =>
178
+ expect(HandbookThreadService.create).toHaveBeenCalledWith(
179
+ expect.objectContaining({ question: "Come funziona buildDefaultMatch?" }),
180
+ ),
181
+ );
182
+
183
+ // The title only exists server-side until the thread is read back, so its
184
+ // appearance in the sidebar row is the proof the transcript was reloaded.
185
+ await waitFor(() => expect(screen.getAllByText("Filtri di azienda").length).toBeGreaterThan(0));
186
+ expect(screen.getByText(/Inietta il filtro di azienda/)).toBeInTheDocument();
187
+
188
+ // There is exactly ONE header on this page — RoundPageContainer's. The
189
+ // assistant's per-thread header is deliberately not rendered, so neither
190
+ // its rename nor its delete control may appear.
191
+ expect(screen.queryByRole("button", { name: "features.assistant.rename" })).toBeNull();
192
+ expect(screen.queryByRole("button", { name: "features.assistant.delete" })).toBeNull();
193
+ });
194
+
195
+ it("appends to the open thread instead of creating a second one", async () => {
196
+ vi.mocked(HandbookThreadService.findMany).mockResolvedValue([
197
+ buildThread({ id: "t1", title: "Filtri di azienda" }),
198
+ ]);
199
+ vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
200
+ buildThread({
201
+ id: "t1",
202
+ title: "Filtri di azienda",
203
+ messages: [{ id: "m1", role: "assistant", content: "Inietta il filtro di azienda.", position: 1 }],
204
+ }),
205
+ );
206
+
207
+ render(<HandbookAskContainer />);
208
+ await userEvent.click(await screen.findByRole("button", { name: "Filtri di azienda" }));
209
+ await waitFor(() => expect(screen.getByText(/Inietta il filtro di azienda/)).toBeInTheDocument());
210
+
211
+ await userEvent.type(screen.getByRole("textbox"), "E i cursori?");
212
+ await userEvent.click(sendButton());
213
+
214
+ await waitFor(() =>
215
+ expect(HandbookThreadMessageService.ask).toHaveBeenCalledWith(
216
+ expect.objectContaining({ threadId: "t1", question: "E i cursori?" }),
217
+ ),
218
+ );
219
+ expect(HandbookThreadService.create).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it("renders the cited sources inside the answer", async () => {
223
+ vi.mocked(HandbookThreadService.create).mockResolvedValue(buildThread({ id: "t9", title: "Fonti" }));
224
+ vi.mocked(HandbookThreadService.findOne).mockResolvedValue(
225
+ buildThread({
226
+ id: "t9",
227
+ title: "Fonti",
228
+ messages: [
229
+ {
230
+ id: "m2",
231
+ role: "assistant",
232
+ content: "Inietta il filtro di azienda.",
233
+ position: 1,
234
+ sources: ["03-backend/repositories.md", "02-framework/security.md"],
235
+ },
236
+ ],
237
+ }),
238
+ );
239
+
240
+ render(<HandbookAskContainer />);
241
+ await userEvent.type(screen.getByRole("textbox"), "Perché?");
242
+ await userEvent.click(sendButton());
243
+
244
+ await waitFor(() => expect(screen.getByText("03-backend/repositories.md")).toBeInTheDocument());
245
+ expect(screen.getByText("02-framework/security.md")).toBeInTheDocument();
246
+ expect(screen.getByText("handbook.chat.sources")).toBeInTheDocument();
247
+ });
248
+
249
+ it("surfaces a failure as an assistant message rather than an error pane", async () => {
250
+ vi.mocked(HandbookThreadService.create).mockRejectedValue(new Error("AI is not configured"));
251
+
252
+ render(<HandbookAskContainer />);
253
+ await userEvent.type(screen.getByRole("textbox"), "Perché?");
254
+ await userEvent.click(sendButton());
255
+
256
+ await waitFor(() => expect(screen.getByText("handbook.chat.failed")).toBeInTheDocument());
257
+ });
258
+
259
+ it("keeps the asked question in the transcript when the answer fails", async () => {
260
+ vi.mocked(HandbookThreadService.create).mockRejectedValue(new Error("boom"));
261
+
262
+ render(<HandbookAskContainer />);
263
+ await userEvent.type(screen.getByRole("textbox"), "Che porte usa il worker?");
264
+ await userEvent.click(sendButton());
265
+
266
+ await waitFor(() => expect(screen.getByText("Che porte usa il worker?")).toBeInTheDocument());
267
+ });
268
+ });
@@ -0,0 +1,143 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { ApiRequestDataTypeInterface } from "../../../../../core/interfaces/ApiRequestDataTypeInterface";
6
+ import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
7
+ import { HandbookAskSheet } from "../HandbookAskSheet";
8
+
9
+ /**
10
+ * The conversation itself is `useHandbookAsk`'s, and its own spec covers it.
11
+ * What this file proves is the sheet: what it opens with, what the recent menu
12
+ * holds, and what the page chip does to the next question. So the hook is
13
+ * mocked — but with real React state behind `ask`, because "the expand link
14
+ * appears once a thread exists" is a transition, not a snapshot.
15
+ */
16
+ const fixtures = vi.hoisted(() => ({
17
+ ask: vi.fn(),
18
+ selectThread: vi.fn(),
19
+ startNew: vi.fn(),
20
+ threads: [] as { id: string; title: string }[],
21
+ }));
22
+
23
+ vi.mock("../../../hooks/useHandbookAsk", async () => {
24
+ const { useState } = await import("react");
25
+ return {
26
+ useHandbookAsk: () => {
27
+ const [thread, setThread] = useState<{ id: string; title: string } | undefined>(undefined);
28
+ return {
29
+ threads: fixtures.threads,
30
+ thread,
31
+ messages: [],
32
+ sending: false,
33
+ ask: async (question: string, options?: { handbookPageId?: string }) => {
34
+ fixtures.ask(question, options);
35
+ setThread({ id: "t1", title: "Filtri di azienda" });
36
+ },
37
+ selectThread: fixtures.selectThread,
38
+ startNew: fixtures.startNew,
39
+ };
40
+ },
41
+ };
42
+ });
43
+
44
+ // The sheet builds its links with `usePageUrlGenerator`, which reads
45
+ // `Modules.HandbookPage.pageUrl` through the registry.
46
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
47
+ try {
48
+ ModuleRegistry.get(key as any);
49
+ } catch {
50
+ ModuleRegistry.register(key, module);
51
+ }
52
+ };
53
+
54
+ beforeAll(() => {
55
+ registerIfAbsent("HandbookPage", { name: "handbookpages", pageUrl: "/administration/handbook" } as any);
56
+ });
57
+
58
+ const open = async () => {
59
+ await userEvent.click(screen.getByRole("button", { name: /handbook\.ask\.open/ }));
60
+ };
61
+
62
+ describe("HandbookAskSheet", () => {
63
+ beforeEach(() => {
64
+ fixtures.ask.mockReset();
65
+ fixtures.selectThread.mockReset();
66
+ fixtures.startNew.mockReset();
67
+ fixtures.threads = [];
68
+ });
69
+
70
+ it("opens on a new thread", async () => {
71
+ render(<HandbookAskSheet handbookPageId="p2" />);
72
+
73
+ await open();
74
+
75
+ expect(await screen.findByText("handbook.chat.empty_state.title")).toBeInTheDocument();
76
+ expect(screen.getByPlaceholderText("handbook.chat.placeholder")).toBeInTheDocument();
77
+ });
78
+
79
+ it("lists the five most recent threads and links to the full route", async () => {
80
+ fixtures.threads = Array.from({ length: 7 }, (_, index) => ({
81
+ id: `t${index}`,
82
+ title: `Conversazione ${index}`,
83
+ }));
84
+
85
+ render(<HandbookAskSheet />);
86
+ await open();
87
+ await userEvent.click(await screen.findByRole("button", { name: "handbook.ask.recent" }));
88
+
89
+ // Five threads plus the "all conversations" row.
90
+ expect(await screen.findAllByRole("menuitem")).toHaveLength(6);
91
+ expect(screen.getByText("Conversazione 4")).toBeInTheDocument();
92
+ expect(screen.queryByText("Conversazione 5")).not.toBeInTheDocument();
93
+ expect(screen.getByRole("link", { name: "handbook.ask.all" })).toHaveAttribute(
94
+ "href",
95
+ "/administration/handbook/chat",
96
+ );
97
+ });
98
+
99
+ it("opens a thread picked from the recent menu", async () => {
100
+ fixtures.threads = [{ id: "t3", title: "Filtri di azienda" }];
101
+
102
+ render(<HandbookAskSheet />);
103
+ await open();
104
+ await userEvent.click(await screen.findByRole("button", { name: "handbook.ask.recent" }));
105
+ await userEvent.click(await screen.findByRole("menuitem", { name: "Filtri di azienda" }));
106
+
107
+ expect(fixtures.selectThread).toHaveBeenCalledWith("t3");
108
+ });
109
+
110
+ it("scopes the question to the page only when the chip is on", async () => {
111
+ render(<HandbookAskSheet handbookPageId="p2" />);
112
+ await open();
113
+
114
+ await userEvent.type(await screen.findByRole("textbox"), "Why?{Enter}");
115
+ expect(fixtures.ask).toHaveBeenLastCalledWith("Why?", { handbookPageId: undefined });
116
+
117
+ await userEvent.click(screen.getByRole("switch", { name: "handbook.ask.thisPageOnly" }));
118
+ await userEvent.type(screen.getByRole("textbox"), "Again?{Enter}");
119
+ expect(fixtures.ask).toHaveBeenLastCalledWith("Again?", { handbookPageId: "p2" });
120
+ });
121
+
122
+ it("offers no page chip when no page is open", async () => {
123
+ render(<HandbookAskSheet />);
124
+ await open();
125
+
126
+ await screen.findByRole("textbox");
127
+ expect(screen.queryByRole("switch", { name: "handbook.ask.thisPageOnly" })).toBeNull();
128
+ });
129
+
130
+ it("expands to the route on the open thread", async () => {
131
+ render(<HandbookAskSheet />);
132
+ await open();
133
+
134
+ expect(screen.queryByRole("link", { name: "handbook.ask.expand" })).toBeNull();
135
+
136
+ await userEvent.type(await screen.findByRole("textbox"), "Why?{Enter}");
137
+
138
+ expect(await screen.findByRole("link", { name: "handbook.ask.expand" })).toHaveAttribute(
139
+ "href",
140
+ "/administration/handbook/chat/t1",
141
+ );
142
+ });
143
+ });