]> git.giacomolli.uk - onechat/blob - whatsapp_import_test.go
Render Markdown with plain terminal styles
[onechat] / whatsapp_import_test.go
1 package main
2
3 import (
4         "archive/zip"
5         "os"
6         "path/filepath"
7         "strings"
8         "testing"
9         "time"
10
11         "github.com/stretchr/testify/require"
12         "go.mau.fi/whatsmeow/types"
13 )
14
15 func TestWhatsAppImportReadsMadeUpExport(t *testing.T) {
16         path := whatsappImportSample(t)
17         location := time.FixedZone("TEST", -3*60*60)
18         chat, messages, err := whatsappImportRead(path, "Sam Owner", location)
19         require.NoError(t, err)
20         require.Equal(t, "Book Club", chat.Name)
21         require.True(t, chat.Group)
22         require.Len(t, messages, 19)
23         require.Equal(t, "Book Club", messages[0].Sender)
24         require.False(t, messages[0].Mine)
25         require.Equal(t, "2025-05-20T12:35:39-03:00", messages[0].Time.Format(time.RFC3339))
26         require.Contains(t, messages[0].Text, "end-to-end encrypted")
27         require.Equal(t, "image omitted", messages[5].Text)
28         require.True(t, messages[6].Mine)
29         require.Equal(t, "~\u202fBen Carter", messages[7].Sender)
30         require.Equal(t, "Sure.\nThe room costs $40.00 more.\nI will send the booking tomorrow. 👍🏻", messages[7].Text)
31         require.Contains(t, messages[8].Text, "<This message was edited>")
32         require.Equal(t, "Booking.pdf • 3 pages document omitted", messages[9].Text)
33         require.Equal(t, "audio omitted", messages[10].Text)
34         require.True(t, messages[11].Mine)
35         require.Equal(t, "This message was deleted.", messages[11].Text)
36         require.Equal(t, "video omitted", messages[12].Text)
37         require.Equal(t, "Contact card omitted", messages[13].Text)
38         require.Equal(t, "sticker omitted", messages[14].Text)
39         require.Contains(t, messages[15].Text, "changed the group name")
40         require.Equal(t, "A line with [brackets] and: a colon in the middle", messages[16].Text)
41
42         for _, message := range messages {
43                 require.NotContains(t, message.Text, "\r")
44                 require.NotContains(t, message.Text, "\u200e")
45                 require.NotContains(t, message.Sender, "\u200e")
46         }
47 }
48
49 func TestWhatsAppImportSplitsDaysAndMapsOnlyOwner(t *testing.T) {
50         path := whatsappImportSample(t)
51         names := map[string]WhatsAppChat{
52                 "Book Club": {
53                         ID:    "example@g.us",
54                         Name:  "Book Club",
55                         Group: true,
56                 },
57         }
58
59         plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, names, "Sam Owner", time.UTC)
60         require.NoError(t, err)
61         require.Len(t, plan.added, 5)
62         require.Equal(t, "2025-05-20T13:06:44Z", plan.added[0].Date)
63         require.Equal(t, "2025-05-21T09:23:00Z", plan.added[1].Date)
64         require.Equal(t, "2025-12-31T23:59:59Z", plan.added[3].Date)
65         require.Equal(t, "2026-01-01T00:00:01Z", plan.added[4].Date)
66         require.True(t, strings.HasPrefix(plan.added[0].Text, "[WhatsApp] Book Club, group example@g.us, Tuesday 2025-05-20 (imported)\n"))
67         require.Contains(t, plan.added[0].Text, "12:35 Book Club: Messages and calls")
68         require.Contains(t, plan.added[0].Text, "13:02 You: Nice.")
69         require.NotContains(t, plan.added[0].Text, "Sam Owner:")
70         require.Contains(t, plan.added[0].Text, "13:05 ~\u202fBen Carter: Sure.\nThe room costs")
71         require.NotContains(t, plan.added[0].Text, whatsappBatchEmptyReply)
72         require.Equal(t, "2025-05-20T12:35:39Z", plan.first.Format(time.RFC3339))
73         require.Equal(t, "2026-01-01T00:00:01Z", plan.last.Format(time.RFC3339))
74 }
75
76 func TestWhatsAppImportSkipsHeldDaysAndMessagesAfterLiveCutoff(t *testing.T) {
77         path := whatsappImportSample(t)
78         old := []DatedMessage{
79                 {
80                         Kind: "work",
81                         Text: "[WhatsApp] Live, private chat other@lid, Sunday 2025-06-08\n10:00 Ada: Later",
82                         Date: "2025-06-08T10:01:00Z",
83                 },
84                 {
85                         Kind: "work",
86                         Text: "[WhatsApp] Live, private chat other@lid, Tuesday 2025-06-03\n18:41 Ada: First\n18:42 Ada: Last",
87                         Date: "2025-06-03T18:43:00Z",
88                 },
89                 {
90                         Kind: "work",
91                         Text: "[WhatsApp] Book Club, Tuesday 2025-05-20 (imported)\n12:35 Book Club: System",
92                         Date: "2025-05-20T13:06:44Z",
93                 },
94         }
95
96         plan, err := whatsappImportPlan(t.Context(), []string{path}, old, map[string]WhatsAppChat{}, "Sam Owner", time.UTC)
97         require.NoError(t, err)
98         require.Equal(t, "2025-06-03T18:41:00Z", plan.cutoff.Format(time.RFC3339))
99         require.Equal(t, 1, plan.skipped)
100         require.Len(t, plan.added, 2)
101         require.Equal(t, "2025-06-03T18:40:05Z", plan.added[1].Date)
102         require.NotContains(t, plan.added[1].Text, "18:41")
103         require.NotContains(t, plan.added[1].Text, ", group ")
104         require.Equal(t, len(old)+2, plan.messages)
105 }
106
107 func TestWhatsAppImportCutoffUsesLocalTimeAndExcludesBoundary(t *testing.T) {
108         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")
109         old := []DatedMessage{{
110                 Kind: "work",
111                 Text: "[WhatsApp] Live, group live@g.us, Tuesday 2025-06-03\n18:41 Ada: First",
112                 Date: "2025-06-04T01:00:00Z",
113         }}
114
115         plan, err := whatsappImportPlan(t.Context(), []string{path}, old, map[string]WhatsAppChat{}, "", time.FixedZone("TEST", -3*60*60))
116         require.NoError(t, err)
117         require.Equal(t, "2025-06-03T18:41:00-03:00", plan.cutoff.Format(time.RFC3339))
118         require.Len(t, plan.added, 1)
119         require.Contains(t, plan.added[0].Text, "Before")
120         require.NotContains(t, plan.added[0].Text, "Boundary")
121         require.NotContains(t, plan.added[0].Text, "After")
122 }
123
124 func TestWhatsAppImportKeepsSystemSenderEvenWithOwnerName(t *testing.T) {
125         path := whatsappImportZip(t, "Owner", "[01/01/2025, 00:00:00] Owner: System line\n")
126         _, messages, err := whatsappImportRead(path, "Owner", time.UTC)
127         require.NoError(t, err)
128         require.False(t, messages[0].Mine)
129 }
130
131 func TestWhatsAppImportRefusesOverlappingInputs(t *testing.T) {
132         path := whatsappImportSample(t)
133         _, err := whatsappImportPlan(t.Context(), []string{path, path}, nil, map[string]WhatsAppChat{}, "", time.UTC)
134         require.ErrorContains(t, err, "inputs overlap")
135 }
136
137 func TestWhatsAppImportSkipsAllDaysOnALaterStep(t *testing.T) {
138         path := whatsappImportSample(t)
139         first, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, "Sam Owner", time.UTC)
140         require.NoError(t, err)
141
142         second, err := whatsappImportPlan(t.Context(), []string{path}, first.added, nil, "Sam Owner", time.UTC)
143         require.NoError(t, err)
144         require.Empty(t, second.added)
145         require.Equal(t, 5, second.skipped)
146         require.True(t, second.cutoff.IsZero())
147 }
148
149 func TestWhatsAppImportMergesChatsByDate(t *testing.T) {
150         later := whatsappImportZip(t, "Later", "[02/01/2025, 08:00:00] Ada: Later\n")
151         earlier := whatsappImportZip(t, "Earlier", "[01/01/2025, 08:00:00] Bea: Earlier\n")
152         plan, err := whatsappImportPlan(t.Context(), []string{later, earlier}, nil, nil, "", time.UTC)
153         require.NoError(t, err)
154         require.Len(t, plan.added, 2)
155         require.Contains(t, plan.added[0].Text, "[WhatsApp] Earlier,")
156         require.Contains(t, plan.added[1].Text, "[WhatsApp] Later,")
157 }
158
159 func TestWhatsAppImportRefusesMissingOrDuplicateChatText(t *testing.T) {
160         testCases := []struct {
161                 name    string
162                 members []string
163                 want    string
164         }{
165                 {
166                         name:    "missing chat text is refused",
167                         members: []string{"other.txt"},
168                         want:    "_chat.txt is missing",
169                 },
170                 {
171                         name:    "duplicate chat text is refused",
172                         members: []string{"_chat.txt", "nested/_chat.txt"},
173                         want:    "more than one _chat.txt",
174                 },
175         }
176
177         for _, tc := range testCases {
178                 t.Run(tc.name, func(t *testing.T) {
179                         path := filepath.Join(t.TempDir(), "Example.zip")
180                         file, err := os.Create(path)
181                         require.NoError(t, err)
182
183                         archive := zip.NewWriter(file)
184                         for _, member := range tc.members {
185                                 _, err = archive.Create(member)
186                                 require.NoError(t, err)
187                         }
188
189                         require.NoError(t, archive.Close())
190                         require.NoError(t, file.Close())
191
192                         _, _, err = whatsappImportRead(path, "", time.UTC)
193                         require.ErrorContains(t, err, tc.want)
194                 })
195         }
196 }
197
198 func TestWhatsAppImportRefusesMalformedExports(t *testing.T) {
199         testCases := []struct {
200                 name string
201                 text string
202                 want string
203         }{
204                 {
205                         name: "text before the first date is refused",
206                         text: "not a message\n",
207                         want: "before the first",
208                 },
209                 {
210                         name: "invalid local dates are refused",
211                         text: "[31/02/2025, 10:00:00] Ada: Invalid\n",
212                         want: "parse export local date",
213                 },
214                 {
215                         name: "exports with no dated messages are refused",
216                         text: "",
217                         want: "no dated messages",
218                 },
219         }
220
221         for _, tc := range testCases {
222                 t.Run(tc.name, func(t *testing.T) {
223                         _, _, err := whatsappImportRead(whatsappImportZip(t, "Example", tc.text), "", time.UTC)
224                         require.ErrorContains(t, err, tc.want)
225                 })
226         }
227 }
228
229 func TestWhatsAppImportPlanCommandDoesNotChangeAgentOrCallModel(t *testing.T) {
230         agent := newRebuildTestAgent(t)
231         dir := filepath.Dir(agent.dir)
232         t.Setenv("ONECHAT_DIR", dir)
233
234         // A plan does not need model settings or credentials.
235         require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
236         require.NoError(t, os.WriteFile(filepath.Join(dir, "auth.json"), []byte("not credentials"), 0o600))
237
238         rebuildTestLog(t, agent.dir, []DatedMessage{{
239                 Kind: "user",
240                 Text: "Current",
241                 Date: "2026-11-01T00:00:00Z",
242         }})
243
244         before := rebuildTestSnapshot(t, agent.dir)
245
246         var report strings.Builder
247
248         require.NoError(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &report))
249         require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
250
251         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"} {
252                 require.Contains(t, report.String(), want)
253         }
254
255         require.NotContains(t, report.String(), "end-to-end encrypted")
256         require.NotContains(t, report.String(), "Sam Owner")
257
258         lock, err := lockTokens(dir)
259         require.NoError(t, err)
260         require.ErrorIs(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), errTokensInUse)
261         require.NoError(t, lock.Close())
262         require.NoError(t, os.WriteFile(filepath.Join(agent.dir, "work.json"), []byte(`{"running":["BlueBird"]}`), 0o600))
263
264         require.ErrorContains(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), "unfinished work")
265 }
266
267 func TestWhatsAppImportDoesNotRebuildWhenAllDaysAreHeld(t *testing.T) {
268         agent := newRebuildTestAgent(t)
269         dir := filepath.Dir(agent.dir)
270         t.Setenv("ONECHAT_DIR", dir)
271
272         path := whatsappImportSample(t)
273         plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, "", time.UTC)
274         require.NoError(t, err)
275
276         rebuildTestLog(t, agent.dir, plan.added)
277         require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
278
279         before := rebuildTestSnapshot(t, agent.dir)
280
281         var report strings.Builder
282
283         require.NoError(t, whatsappImport(t.Context(), []string{path}, &report))
284         require.Contains(t, report.String(), "batches: 0")
285         require.Contains(t, report.String(), "estimated calls: 0")
286         require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
287 }
288
289 func TestWhatsAppImportReadsOnlyLocalSessionNames(t *testing.T) {
290         dir := t.TempDir()
291         whatsappTestSession(t, dir, true)
292
293         session := &WhatsApp{
294                 dir: filepath.Join(dir, whatsappDirName),
295         }
296
297         err := session.openSession(t.Context())
298         require.NoError(t, err)
299
300         session.device.PushName = "Sam Owner"
301         session.device.Account.Details = []byte{}
302         require.NoError(t, session.device.Save(t.Context()))
303         require.NoError(t, session.device.Contacts.PutContactName(t.Context(), whatsappTestOwn, "", "Owner"))
304         require.NoError(t, session.device.Contacts.PutContactName(t.Context(), session.device.LID.ToNonAD(), "", "Owner LID"))
305
306         friend := types.NewJID("friend", types.HiddenUserServer)
307         require.NoError(t, session.device.Contacts.PutContactName(t.Context(), friend, "Ada", "Ada Example"))
308         require.NoError(t, session.device.Contacts.PutContactName(t.Context(), types.NewJID("other", types.HiddenUserServer), "Ada", ""))
309         require.NoError(t, session.container.Close())
310
311         names, owner, err := whatsappImportNames(t.Context(), dir)
312         require.NoError(t, err)
313         require.Equal(t, "Sam Owner", owner)
314         require.Equal(t, "friend@lid", names["Ada Example"].ID)
315         require.Empty(t, names["Ada"].ID)
316         require.NotContains(t, names, "Owner")
317         require.NotContains(t, names, "Owner LID")
318 }
319
320 func whatsappImportSample(t *testing.T) string {
321         t.Helper()
322
323         data, err := os.ReadFile(filepath.Join("testdata", "whatsapp-export.zip"))
324         require.NoError(t, err)
325
326         path := filepath.Join(t.TempDir(), "WhatsApp Chat - Book Club.zip")
327         require.NoError(t, os.WriteFile(path, data, 0o600))
328
329         return path
330 }
331
332 func whatsappImportZip(t *testing.T, name, text string) string {
333         t.Helper()
334
335         path := filepath.Join(t.TempDir(), "WhatsApp Chat - "+name+".zip")
336         file, err := os.Create(path)
337         require.NoError(t, err)
338
339         archive := zip.NewWriter(file)
340         writer, err := archive.Create("_chat.txt")
341         require.NoError(t, err)
342
343         _, err = writer.Write([]byte(text))
344         require.NoError(t, err)
345         require.NoError(t, archive.Close())
346         require.NoError(t, file.Close())
347
348         return path
349 }