]> git.giacomolli.uk - onechat/blob - whatsapp_import_test.go
Separate multiline Go statements
[onechat] / whatsapp_import_test.go
1 package main
2
3 import (
4 "archive/zip"
5 "encoding/json"
6 "net/http"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/require"
14 "go.mau.fi/whatsmeow/types"
15 )
16
17 func TestWhatsAppImportReadsMadeUpExport(t *testing.T) {
18 path := whatsappImportSample(t)
19 location := time.FixedZone("TEST", -3*60*60)
20 chat, messages, err := whatsappImportRead(path, "Sam Owner", location)
21 require.NoError(t, err)
22 require.Equal(t, "Book Club", chat.Name)
23 require.True(t, chat.Group)
24 require.Len(t, messages, 19)
25 require.Equal(t, "Book Club", messages[0].Sender)
26 require.False(t, messages[0].Mine)
27 require.Equal(t, "2025-05-20T12:35:39-03:00", messages[0].Time.Format(time.RFC3339))
28 require.Contains(t, messages[0].Text, "end-to-end encrypted")
29 require.Equal(t, "image omitted", messages[5].Text)
30 require.True(t, messages[6].Mine)
31 require.Equal(t, "~\u202fBen Carter", messages[7].Sender)
32 require.Equal(t, "Sure.\nThe room costs $40.00 more.\nI will send the booking tomorrow. 👍🏻", messages[7].Text)
33 require.Contains(t, messages[8].Text, "<This message was edited>")
34 require.Equal(t, "Booking.pdf • 3 pages document omitted", messages[9].Text)
35 require.Equal(t, "audio omitted", messages[10].Text)
36 require.True(t, messages[11].Mine)
37 require.Equal(t, "This message was deleted.", messages[11].Text)
38 require.Equal(t, "video omitted", messages[12].Text)
39 require.Equal(t, "Contact card omitted", messages[13].Text)
40 require.Equal(t, "sticker omitted", messages[14].Text)
41 require.Contains(t, messages[15].Text, "changed the group name")
42 require.Equal(t, "A line with [brackets] and: a colon in the middle", messages[16].Text)
43
44 for _, message := range messages {
45 require.NotContains(t, message.Text, "\r")
46 require.NotContains(t, message.Text, "\u200e")
47 require.NotContains(t, message.Sender, "\u200e")
48 }
49 }
50
51 func TestWhatsAppImportSplitsDaysAndMapsOnlyOwner(t *testing.T) {
52 path := whatsappImportSample(t)
53 names := map[string]WhatsAppChat{
54 "Book Club": {
55 ID: "example@g.us",
56 Name: "Book Club",
57 Group: true,
58 },
59 }
60
61 plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, names, "Sam Owner", time.UTC)
62 require.NoError(t, err)
63 require.Len(t, plan.added, 5)
64 require.Equal(t, "2025-05-20T13:06:44Z", plan.added[0].Date)
65 require.Equal(t, "2025-05-21T09:23:00Z", plan.added[1].Date)
66 require.Equal(t, "2025-12-31T23:59:59Z", plan.added[3].Date)
67 require.Equal(t, "2026-01-01T00:00:01Z", plan.added[4].Date)
68 require.True(t, strings.HasPrefix(plan.added[0].Text, "[WhatsApp] Book Club, group example@g.us, Tuesday 2025-05-20 (imported)\n"))
69 require.Contains(t, plan.added[0].Text, "12:35 Book Club: Messages and calls")
70 require.Contains(t, plan.added[0].Text, "13:02 You: Nice.")
71 require.NotContains(t, plan.added[0].Text, "Sam Owner:")
72 require.Contains(t, plan.added[0].Text, "13:05 ~\u202fBen Carter: Sure.\nThe room costs")
73 require.NotContains(t, plan.added[0].Text, whatsappBatchEmptyReply)
74 require.Equal(t, "2025-05-20T12:35:39Z", plan.first.Format(time.RFC3339))
75 require.Equal(t, "2026-01-01T00:00:01Z", plan.last.Format(time.RFC3339))
76 }
77
78 func TestWhatsAppImportSkipsHeldDaysAndMessagesAfterLiveCutoff(t *testing.T) {
79 path := whatsappImportSample(t)
80 old := []DatedMessage{
81 {
82 Kind: "work",
83 Text: "[WhatsApp] Book Club, private chat other@lid, Sunday 2025-06-08\n10:00 Ada: Later",
84 Date: "2025-06-08T10:01:00Z",
85 },
86 {
87 Kind: "work",
88 Text: "[WhatsApp] Book Club, private chat other@lid, Tuesday 2025-06-03\n18:41 Ada: First\n18:42 Ada: Last",
89 Date: "2025-06-03T18:43:00Z",
90 },
91 {
92 Kind: "work",
93 Text: "[WhatsApp] Book Club, Tuesday 2025-05-20 (imported)\n12:35 Book Club: System",
94 Date: "2025-05-20T13:06:44Z",
95 },
96 }
97
98 plan, err := whatsappImportPlan(t.Context(), []string{path}, old, nil, map[string]WhatsAppChat{}, "Sam Owner", time.UTC)
99 require.NoError(t, err)
100 require.Equal(t, "2025-06-03T18:41:00Z", plan.cutoff.Format(time.RFC3339))
101 require.Equal(t, 1, plan.skipped)
102 require.Len(t, plan.added, 2)
103 require.Equal(t, "2025-06-03T18:40:05Z", plan.added[1].Date)
104 require.NotContains(t, plan.added[1].Text, "18:41")
105 require.NotContains(t, plan.added[1].Text, ", group ")
106 require.Equal(t, len(old)+2, plan.messages)
107 }
108
109 func TestWhatsAppImportCutoffUsesLocalTimeAndExcludesBoundary(t *testing.T) {
110 path := whatsappImportZip(t, "Local", "[03/06/2025, 18:40:59] Ada: Before\n[03/06/2025, 18:41:00] Ada: Boundary\n[03/06/2025, 18:41:01] Ada: After\n")
111 old := []DatedMessage{{
112 Kind: "work",
113 Text: "[WhatsApp] Local, group live@g.us, Tuesday 2025-06-03\n18:41 Ada: First",
114 Date: "2025-06-04T01:00:00Z",
115 }}
116
117 plan, err := whatsappImportPlan(t.Context(), []string{path}, old, nil, map[string]WhatsAppChat{}, "", time.FixedZone("TEST", -3*60*60))
118 require.NoError(t, err)
119 require.Equal(t, "2025-06-03T18:41:00-03:00", plan.cutoff.Format(time.RFC3339))
120 require.Len(t, plan.added, 1)
121 require.Contains(t, plan.added[0].Text, "Before")
122 require.NotContains(t, plan.added[0].Text, "Boundary")
123 require.NotContains(t, plan.added[0].Text, "After")
124 }
125
126 func TestWhatsAppImportKeepsSystemSenderEvenWithOwnerName(t *testing.T) {
127 path := whatsappImportZip(t, "Owner", "[01/01/2025, 00:00:00] Owner: System line\n")
128 _, messages, err := whatsappImportRead(path, "Owner", time.UTC)
129 require.NoError(t, err)
130 require.False(t, messages[0].Mine)
131 }
132
133 func TestWhatsAppImportRefusesOverlappingInputs(t *testing.T) {
134 path := whatsappImportSample(t)
135 _, err := whatsappImportPlan(t.Context(), []string{path, path}, nil, nil, map[string]WhatsAppChat{}, "", time.UTC)
136 require.ErrorContains(t, err, "inputs overlap")
137 }
138
139 func TestWhatsAppImportSkipsAllDaysOnALaterStep(t *testing.T) {
140 path := whatsappImportSample(t)
141 first, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, nil, "Sam Owner", time.UTC)
142 require.NoError(t, err)
143
144 second, err := whatsappImportPlan(t.Context(), []string{path}, first.added, nil, nil, "Sam Owner", time.UTC)
145 require.NoError(t, err)
146 require.Empty(t, second.added)
147 require.Equal(t, 5, second.skipped)
148 require.True(t, second.cutoff.IsZero())
149 }
150
151 func TestWhatsAppImportMergesChatsByDate(t *testing.T) {
152 later := whatsappImportZip(t, "Later", "[02/01/2025, 08:00:00] Ada: Later\n")
153 earlier := whatsappImportZip(t, "Earlier", "[01/01/2025, 08:00:00] Bea: Earlier\n")
154 plan, err := whatsappImportPlan(t.Context(), []string{later, earlier}, nil, nil, nil, "", time.UTC)
155 require.NoError(t, err)
156 require.Len(t, plan.added, 2)
157 require.Contains(t, plan.added[0].Text, "[WhatsApp] Earlier,")
158 require.Contains(t, plan.added[1].Text, "[WhatsApp] Later,")
159 }
160
161 func TestWhatsAppImportRefusesMissingOrDuplicateChatText(t *testing.T) {
162 testCases := []struct {
163 name string
164 members []string
165 want string
166 }{
167 {
168 name: "missing chat text is refused",
169 members: []string{"other.txt"},
170 want: "_chat.txt is missing",
171 },
172 {
173 name: "duplicate chat text is refused",
174 members: []string{"_chat.txt", "nested/_chat.txt"},
175 want: "more than one _chat.txt",
176 },
177 }
178
179 for _, tc := range testCases {
180 t.Run(tc.name, func(t *testing.T) {
181 path := filepath.Join(t.TempDir(), "Example.zip")
182 file, err := os.Create(path)
183 require.NoError(t, err)
184
185 archive := zip.NewWriter(file)
186 for _, member := range tc.members {
187 _, err = archive.Create(member)
188 require.NoError(t, err)
189 }
190
191 require.NoError(t, archive.Close())
192 require.NoError(t, file.Close())
193
194 _, _, err = whatsappImportRead(path, "", time.UTC)
195 require.ErrorContains(t, err, tc.want)
196 })
197 }
198 }
199
200 func TestWhatsAppImportRefusesMalformedExports(t *testing.T) {
201 testCases := []struct {
202 name string
203 text string
204 want string
205 }{
206 {
207 name: "text before the first date is refused",
208 text: "not a message\n",
209 want: "before the first",
210 },
211 {
212 name: "invalid local dates are refused",
213 text: "[31/02/2025, 10:00:00] Ada: Invalid\n",
214 want: "parse export local date",
215 },
216 {
217 name: "exports with no dated messages are refused",
218 text: "",
219 want: "no dated messages",
220 },
221 }
222
223 for _, tc := range testCases {
224 t.Run(tc.name, func(t *testing.T) {
225 _, _, err := whatsappImportRead(whatsappImportZip(t, "Example", tc.text), "", time.UTC)
226 require.ErrorContains(t, err, tc.want)
227 })
228 }
229 }
230
231 func TestWhatsAppImportPlanCommandDoesNotChangeAgentOrCallModel(t *testing.T) {
232 agent := newRebuildTestAgent(t)
233 dir := filepath.Dir(agent.dir)
234 t.Setenv("ONECHAT_DIR", dir)
235
236 // A plan does not need model settings or credentials.
237 require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
238 require.NoError(t, os.WriteFile(filepath.Join(dir, "auth.json"), []byte("not credentials"), 0o600))
239
240 rebuildTestLog(t, agent.dir, []DatedMessage{{
241 Kind: "user",
242 Text: "Current",
243 Date: "2026-11-01T00:00:00Z",
244 }})
245
246 before := rebuildTestSnapshot(t, agent.dir)
247
248 var report strings.Builder
249
250 require.NoError(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &report))
251 require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
252
253 for _, want := range []string{"chats: 1", "Book Club", "batches: 5", "dates: ", "cutoff: none", "bytes: ", "messages: 6", "estimated calls: 8 (about 1.3 per message, limit 1500)", "owner: unknown"} {
254 require.Contains(t, report.String(), want)
255 }
256
257 require.NotContains(t, report.String(), "end-to-end encrypted")
258 require.NotContains(t, report.String(), "Sam Owner")
259
260 lock, err := lockTokens(dir)
261 require.NoError(t, err)
262 require.ErrorIs(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), errTokensInUse)
263 require.NoError(t, lock.Close())
264 require.NoError(t, os.WriteFile(filepath.Join(agent.dir, "work.json"), []byte(`{"running":["BlueBird"]}`), 0o600))
265
266 require.ErrorContains(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), "unfinished work")
267 }
268
269 func TestWhatsAppImportDoesNotRebuildWhenAllDaysAreHeld(t *testing.T) {
270 agent := newRebuildTestAgent(t)
271 dir := filepath.Dir(agent.dir)
272 t.Setenv("ONECHAT_DIR", dir)
273
274 path := whatsappImportSample(t)
275 plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, nil, "", time.UTC)
276 require.NoError(t, err)
277
278 rebuildTestLog(t, agent.dir, plan.added)
279 require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
280
281 before := rebuildTestSnapshot(t, agent.dir)
282
283 var report strings.Builder
284
285 require.NoError(t, whatsappImport(t.Context(), []string{path}, &report))
286 require.Contains(t, report.String(), "batches: 0")
287 require.Contains(t, report.String(), "estimated calls: 0")
288 require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
289 }
290
291 func TestWhatsAppImportReadsOnlyLocalSessionNames(t *testing.T) {
292 dir := t.TempDir()
293 whatsappTestSession(t, dir, true)
294
295 session := &WhatsApp{
296 dir: filepath.Join(dir, whatsappDirName),
297 }
298
299 err := session.openSession(t.Context())
300 require.NoError(t, err)
301
302 session.device.PushName = "Sam Owner"
303 session.device.Account.Details = []byte{}
304 require.NoError(t, session.device.Save(t.Context()))
305 require.NoError(t, session.device.Contacts.PutContactName(t.Context(), whatsappTestOwn, "", "Owner"))
306 require.NoError(t, session.device.Contacts.PutContactName(t.Context(), session.device.LID.ToNonAD(), "", "Owner LID"))
307
308 friend := types.NewJID("friend", types.HiddenUserServer)
309 require.NoError(t, session.device.Contacts.PutContactName(t.Context(), friend, "Ada", "Ada Example"))
310 require.NoError(t, session.device.Contacts.PutContactName(t.Context(), types.NewJID("other", types.HiddenUserServer), "Ada", ""))
311 require.NoError(t, session.container.Close())
312
313 names, owner, err := whatsappImportNames(t.Context(), dir)
314 require.NoError(t, err)
315 require.Equal(t, "Sam Owner", owner)
316 require.Equal(t, "friend@lid", names["Ada Example"].ID)
317 require.Empty(t, names["Ada"].ID)
318 require.NotContains(t, names, "Owner")
319 require.NotContains(t, names, "Owner LID")
320 }
321
322 func TestWhatsAppImportCutoffUsesEachChatAndBothLogs(t *testing.T) {
323 path := whatsappImportZip(t, "Plans", "[03/01/2025, 09:59:59] Ada: Before\n[03/01/2025, 10:00:00] Ada: Boundary\n[03/01/2025, 10:01:00] Ada: After\n")
324 target := []DatedMessage{{
325 Kind: "work",
326 Text: "[WhatsApp] Plans, group example@g.us, Friday 2025-01-03\n10:00 Ada: Target first\n10:30 Ada: Target last",
327 Date: "2025-01-03T10:30:00Z",
328 }}
329
330 root := []DatedMessage{
331 {
332 Kind: "work",
333 Text: "[WhatsApp] Other, group other@g.us, Wednesday 2025-01-01\n08:00 Bea: Other chat",
334 Date: "2025-01-01T08:00:00Z",
335 },
336 {
337 Kind: "work",
338 Text: "[WhatsApp] Plans, group example@g.us, Saturday 2025-01-04\n10:00 Ada: Later batch",
339 Date: "2025-01-04T10:00:00Z",
340 },
341 }
342
343 testCases := []struct {
344 name string
345 target []DatedMessage
346 root []DatedMessage
347 }{
348 {
349 name: "target holds the first live batch",
350 target: target,
351 root: root,
352 },
353 {
354 name: "root holds the first live batch",
355 target: root,
356 root: target,
357 },
358 }
359
360 for _, tc := range testCases {
361 t.Run(tc.name, func(t *testing.T) {
362 plan, err := whatsappImportPlan(t.Context(), []string{path}, tc.target, tc.root, nil, "", time.UTC)
363 require.NoError(t, err)
364 require.Equal(t, "2025-01-03T10:00:00Z", plan.cutoff.Format(time.RFC3339))
365 require.Len(t, plan.added, 1)
366 require.Contains(t, plan.added[0].Text, "Before")
367 require.NotContains(t, plan.added[0].Text, "Boundary")
368 require.NotContains(t, plan.added[0].Text, "After")
369 })
370 }
371 }
372
373 func TestWhatsAppImportSelectsOnlyAnExistingDirectChild(t *testing.T) {
374 root := newRebuildTestAgent(t)
375 dir := filepath.Dir(root.dir)
376 t.Setenv("ONECHAT_DIR", dir)
377 daemonChildConfig(t, dir, "House")
378
379 before := rebuildTestSnapshot(t, root.dir)
380 for _, name := range []string{"Missing", "../House", root.config.Name} {
381 err := whatsappImport(t.Context(), []string{"-n", "--agent", name, whatsappImportSample(t)}, &strings.Builder{})
382 require.ErrorContains(t, err, "does not exist")
383 require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
384 }
385 }
386
387 func TestWhatsAppImportParsesGNUFlags(t *testing.T) {
388 testCases := []struct {
389 name string
390 args []string
391 want WhatsAppImportOptions
392 }{
393 {
394 name: "default target remains root",
395 args: []string{"One.zip", "Two.zip"},
396 want: WhatsAppImportOptions{paths: []string{"One.zip", "Two.zip"}},
397 },
398 {
399 name: "long flag takes a separate child name",
400 args: []string{"-n", "--agent", "House", "One.zip"},
401 want: WhatsAppImportOptions{
402 planOnly: true,
403 agent: "House",
404 paths: []string{"One.zip"},
405 },
406 },
407 {
408 name: "flags can follow paths",
409 args: []string{"One.zip", "--agent=House", "-n"},
410 want: WhatsAppImportOptions{
411 planOnly: true,
412 agent: "House",
413 paths: []string{"One.zip"},
414 },
415 },
416 {
417 name: "double dash ends flag parsing",
418 args: []string{"-n", "--", "-Example.zip"},
419 want: WhatsAppImportOptions{
420 planOnly: true,
421 paths: []string{"-Example.zip"},
422 },
423 },
424 }
425
426 for _, tc := range testCases {
427 t.Run(tc.name, func(t *testing.T) {
428 options, err := whatsappImportParse(tc.args)
429 require.NoError(t, err)
430 require.Equal(t, tc.want, options)
431 })
432 }
433 }
434
435 func TestWhatsAppImportRefusesInvalidFlags(t *testing.T) {
436 testCases := []struct {
437 name string
438 args []string
439 want string
440 }{
441 {
442 name: "missing exports are refused",
443 args: []string{"-n", "--agent", "House"},
444 want: "usage:",
445 },
446 {
447 name: "missing child name is refused",
448 args: []string{"--agent"},
449 want: "needs a child name",
450 },
451 {
452 name: "empty child name is refused",
453 args: []string{"--agent=", "One.zip"},
454 want: "needs a child name",
455 },
456 {
457 name: "single dash long flag is refused",
458 args: []string{"-agent", "House", "One.zip"},
459 want: "unknown import flag",
460 },
461 }
462
463 for _, tc := range testCases {
464 t.Run(tc.name, func(t *testing.T) {
465 _, err := whatsappImportParse(tc.args)
466 require.ErrorContains(t, err, tc.want)
467 })
468 }
469 }
470
471 func TestWhatsAppImportChildPlanReadsRootCutoffButNotRootImportedDays(t *testing.T) {
472 root := newRebuildTestAgent(t)
473 dir := filepath.Dir(root.dir)
474 t.Setenv("ONECHAT_DIR", dir)
475
476 childDir := daemonChildConfig(t, dir, "House")
477 rebuildTestLog(t, root.dir, []DatedMessage{
478 {
479 Kind: "work",
480 Text: "[WhatsApp] Plans, Wednesday 2025-01-01 (imported)\n08:00 Ada: Root history",
481 Date: "2025-01-01T08:00:00Z",
482 },
483 {
484 Kind: "work",
485 Text: "[WhatsApp] Plans, group example@g.us, Friday 2025-01-03\n10:00 Ada: Root live\n10:20 Ada: Last",
486 Date: "2025-01-03T10:20:00Z",
487 },
488 })
489
490 rebuildTestLog(t, childDir, []DatedMessage{
491 {
492 Kind: "work",
493 Text: "[WhatsApp] Plans, Thursday 2025-01-02 (imported)\n08:00 Ada: Child history",
494 Date: "2025-01-02T08:00:00Z",
495 },
496 {
497 Kind: "work",
498 Text: "[WhatsApp] Plans, group example@g.us, Saturday 2025-01-04\n10:00 Ada: Child live",
499 Date: "2025-01-04T10:00:00Z",
500 },
501 })
502
503 path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: Root history\n[02/01/2025, 08:00:00] Ada: Child history\n[03/01/2025, 09:59:59] Ada: Before\n[03/01/2025, 10:00:00] Ada: Boundary\n[03/01/2025, 10:01:00] Ada: After\n")
504 before := rebuildTestSnapshot(t, root.dir)
505
506 var report strings.Builder
507
508 require.NoError(t, whatsappImport(t.Context(), []string{"-n", "--agent", "House", path}, &report))
509 require.Contains(t, report.String(), "agent: House\n")
510 require.Contains(t, report.String(), "batches: 2 (1 chat-days skipped)")
511 require.Contains(t, report.String(), "2025-01-03T10:00:00")
512 require.Contains(t, report.String(), "messages: 4 (merged log)")
513 require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
514
515 report.Reset()
516 require.NoError(t, whatsappImport(t.Context(), []string{"-n", path}, &report))
517 require.Contains(t, report.String(), "agent: "+root.config.Name+"\n")
518 require.Contains(t, report.String(), "batches: 2 (1 chat-days skipped)")
519 require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
520 }
521
522 func TestWhatsAppImportChildRebuildUsesOwnPromptAndCache(t *testing.T) {
523 root := newRebuildTestAgent(t)
524 dir := filepath.Dir(root.dir)
525 childDir := daemonChildConfig(t, dir, "House")
526 daemonChildConfig(t, dir, "Calendar")
527 require.NoError(t, os.WriteFile(filepath.Join(childDir, "instructions.md"), []byte("Keep room dimensions exact."), 0o600))
528 rebuildTestLog(t, root.dir, []DatedMessage{{
529 Kind: "user",
530 Text: "Keep the root history.",
531 Date: "2025-02-01T00:00:00Z",
532 }})
533
534 rebuildTestLog(t, childDir, []DatedMessage{{
535 Kind: "user",
536 Text: "Keep the child history.",
537 Date: "2025-02-02T00:00:00Z",
538 }})
539
540 child, err := whatsappImportAgent(root.dir, "House")
541 require.NoError(t, err)
542
543 old, err := rebuildReadLog(t.Context(), child.dir)
544 require.NoError(t, err)
545
546 rootLog, err := rebuildReadLog(t.Context(), root.dir)
547 require.NoError(t, err)
548
549 path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: "+strings.Repeat("The room is ready. ", 80)+"\n")
550 plan, err := whatsappImportPlan(t.Context(), []string{path}, old, rootLog, nil, "", time.UTC)
551 require.NoError(t, err)
552
553 before := rebuildTestSnapshot(t, child.dir)
554 sibling := rebuildTestSnapshot(t, filepath.Join(root.dir, "children", "Calendar"))
555 server := newModelFake(t)
556 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
557 children, err := root.Children()
558 if err != nil || len(children) != 2 {
559 w.WriteHeader(http.StatusBadRequest)
560
561 return
562 }
563
564 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The room is ready.")}, 100, 0, 5))
565 }
566
567 client := newModelClient(server.URL, ModelAPIKey(modelTestKey), child.CacheKey(), filepath.Join(t.TempDir(), "usage.jsonl"))
568 backup, err := rebuild(t.Context(), child, plan.added, client, newCompactor(client, child.Prompt, defaultConfig().Compactor, child.Tools(), child.Images()), &strings.Builder{})
569 require.NoError(t, err)
570 require.Equal(t, before, rebuildTestSnapshot(t, backup))
571 require.Equal(t, root.dir, filepath.Dir(backup))
572 require.Equal(t, sibling, rebuildTestSnapshot(t, filepath.Join(root.dir, "children", "Calendar")))
573
574 children, err := root.Children()
575 require.NoError(t, err)
576 require.Len(t, children, 2)
577
578 entries, err := rebuildReadLog(t.Context(), child.dir)
579 require.NoError(t, err)
580 require.Equal(t, append(plan.added, old...), entries)
581
582 unchanged, err := rebuildReadLog(t.Context(), root.dir)
583 require.NoError(t, err)
584 require.Equal(t, rootLog, unchanged)
585
586 prompt, err := child.Prompt()
587 require.NoError(t, err)
588 require.NotEqual(t, root.CacheKey(), child.CacheKey())
589 require.Positive(t, server.Calls())
590
591 for i := range server.Calls() {
592 call := server.Call(i)
593 require.Equal(t, child.CacheKey(), call.body["prompt_cache_key"])
594 require.Equal(t, prompt, call.body["instructions"])
595 require.Contains(t, prompt, "Keep room dimensions exact.")
596 require.NotContains(t, string(call.raw), `"name":"whatsapp_send"`)
597 }
598 }
599
600 func TestWhatsAppImportChildFailureKeepsConfiguredChildren(t *testing.T) {
601 root := newRebuildTestAgent(t)
602 daemonChildConfig(t, filepath.Dir(root.dir), "House")
603
604 child, err := whatsappImportAgent(root.dir, "House")
605 require.NoError(t, err)
606
607 before := rebuildTestSnapshot(t, root.dir)
608 server := newModelFake(t)
609 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
610 w.WriteHeader(http.StatusBadRequest)
611 }
612
613 client := newModelClient(server.URL, ModelAPIKey(modelTestKey), child.CacheKey(), filepath.Join(t.TempDir(), "usage.jsonl"))
614 _, err = rebuild(t.Context(), child, []DatedMessage{{
615 Kind: "work",
616 Text: strings.Repeat("The plan is ready. ", 80),
617 Date: "2025-01-01T08:00:00Z",
618 }}, client, newCompactor(client, child.Prompt, defaultConfig().Compactor, child.Tools(), child.Images()), &strings.Builder{})
619 require.ErrorContains(t, err, "rebuild")
620 require.Equal(t, before, rebuildTestSnapshot(t, root.dir))
621
622 children, err := root.Children()
623 require.NoError(t, err)
624 require.Len(t, children, 1)
625 }
626
627 func TestWhatsAppImportRefusesPendingDeliveriesAndKeepsRestartSafe(t *testing.T) {
628 testCases := []struct {
629 name string
630 child bool
631 }{
632 {
633 name: "pending child delivery keeps its log ID",
634 child: true,
635 },
636 {
637 name: "pending root report keeps its log ID",
638 },
639 }
640
641 for _, tc := range testCases {
642 t.Run(tc.name, func(t *testing.T) {
643 dir := t.TempDir()
644 t.Setenv("ONECHAT_DIR", dir)
645
646 server := newModelFake(t)
647 server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
648 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
649 }
650
651 test := newDaemonMessageTest(t, dir, server)
652 index := 0
653 args := []string{whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: The room is ready.\n")}
654 if tc.child {
655 index = 1
656 args = append([]string{"--agent", "Garden"}, args...)
657
658 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
659 } else {
660 require.NoError(t, test.messages.Finish(test.loops[1], nil, "The plan is ready."))
661 }
662
663 queue := test.Queue(t)
664 require.Len(t, queue.Pending, 1)
665 require.NotNil(t, queue.Pending[0].LogID)
666
667 before, err := test.agents[index].memory.Message(*queue.Pending[0].LogID)
668 require.NoError(t, err)
669 test.Close(t)
670
671 root := filepath.Join(dir, "agent")
672 snapshot := rebuildTestSnapshot(t, root)
673 require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("invalid model settings"), 0o600))
674
675 for _, planOnly := range []bool{true, false} {
676 input := append([]string(nil), args...)
677 if planOnly {
678 input = append([]string{"-n"}, input...)
679 }
680
681 err = whatsappImport(t.Context(), input, &strings.Builder{})
682 require.ErrorContains(t, err, "has pending deliveries")
683 require.Equal(t, snapshot, rebuildTestSnapshot(t, root))
684 require.Zero(t, server.Calls())
685 }
686
687 restarted := newDaemonMessageTest(t, dir, server)
688 require.Equal(t, queue, restarted.Queue(t))
689 require.Equal(t, 1, restarted.agents[index].memory.Len())
690
691 after, err := restarted.agents[index].memory.Message(before.ID)
692 require.NoError(t, err)
693 require.Equal(t, before, after)
694 restarted.Start(t, index)
695 restarted.events[index].WaitFor(t, "idle", 1)
696 require.Empty(t, restarted.Queue(t).Pending)
697 require.Equal(t, 1, server.Calls())
698 restarted.Close(t)
699
700 again := newDaemonMessageTest(t, dir, server)
701 require.False(t, again.loops[index].waiting())
702 require.Equal(t, 1, server.Calls())
703 })
704 }
705 }
706
707 func TestWhatsAppImportPendingDeliveriesBlockOnlyTheirTarget(t *testing.T) {
708 dir := t.TempDir()
709 t.Setenv("ONECHAT_DIR", dir)
710
711 server := newModelFake(t)
712 test := newDaemonMessageTest(t, dir, server)
713 require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan."))
714
715 queue := test.Queue(t)
716 test.Close(t)
717
718 path := whatsappImportZip(t, "Plans", "[01/01/2025, 08:00:00] Ada: The room is ready.\n")
719 for _, name := range []string{"", "Library"} {
720 args := []string{"-n", path}
721 if name != "" {
722 args = append(args, "--agent", name)
723 }
724
725 require.NoError(t, whatsappImport(t.Context(), args, &strings.Builder{}))
726 }
727
728 queue.Pending[0].LogID = nil
729 data, err := json.Marshal(queue)
730 require.NoError(t, err)
731 require.NoError(t, os.WriteFile(filepath.Join(dir, "agent", "messages.json"), data, 0o600))
732 require.ErrorContains(t, whatsappImport(t.Context(), []string{"-n", "--agent", "Garden", path}, &strings.Builder{}), "has pending deliveries")
733 require.Zero(t, server.Calls())
734 }
735
736 func TestWhatsAppImportDefaultPlanDoesNotCreateAgent(t *testing.T) {
737 dir := t.TempDir()
738 t.Setenv("ONECHAT_DIR", dir)
739
740 var report strings.Builder
741
742 require.NoError(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &report))
743 require.Contains(t, report.String(), "agent: OneChat\n")
744
745 _, err := os.Stat(filepath.Join(dir, "agent"))
746 require.ErrorIs(t, err, os.ErrNotExist)
747 }
748
749 func whatsappImportSample(t *testing.T) string {
750 t.Helper()
751
752 data, err := os.ReadFile(filepath.Join("testdata", "whatsapp-export.zip"))
753 require.NoError(t, err)
754
755 path := filepath.Join(t.TempDir(), "WhatsApp Chat - Book Club.zip")
756 require.NoError(t, os.WriteFile(path, data, 0o600))
757
758 return path
759 }
760
761 func whatsappImportZip(t *testing.T, name, text string) string {
762 t.Helper()
763
764 path := filepath.Join(t.TempDir(), "WhatsApp Chat - "+name+".zip")
765 file, err := os.Create(path)
766 require.NoError(t, err)
767
768 archive := zip.NewWriter(file)
769 writer, err := archive.Create("_chat.txt")
770 require.NoError(t, err)
771
772 _, err = writer.Write([]byte(text))
773 require.NoError(t, err)
774 require.NoError(t, archive.Close())
775 require.NoError(t, file.Close())
776
777 return path
778 }