]> git.giacomolli.uk - onechat/blob - whatsapp_import_test.go
Wait for the send back line in the retry test
[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 }