+package main
+
+import (
+ "archive/zip"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "go.mau.fi/whatsmeow/types"
+)
+
+func TestWhatsAppImportReadsMadeUpExport(t *testing.T) {
+ path := whatsappImportSample(t)
+ location := time.FixedZone("TEST", -3*60*60)
+ chat, messages, err := whatsappImportRead(path, "Sam Owner", location)
+ require.NoError(t, err)
+ require.Equal(t, "Book Club", chat.Name)
+ require.True(t, chat.Group)
+ require.Len(t, messages, 19)
+ require.Equal(t, "Book Club", messages[0].Sender)
+ require.False(t, messages[0].Mine)
+ require.Equal(t, "2025-05-20T12:35:39-03:00", messages[0].Time.Format(time.RFC3339))
+ require.Contains(t, messages[0].Text, "end-to-end encrypted")
+ require.Equal(t, "image omitted", messages[5].Text)
+ require.True(t, messages[6].Mine)
+ require.Equal(t, "~\u202fBen Carter", messages[7].Sender)
+ require.Equal(t, "Sure.\nThe room costs $40.00 more.\nI will send the booking tomorrow. 👍🏻", messages[7].Text)
+ require.Contains(t, messages[8].Text, "<This message was edited>")
+ require.Equal(t, "Booking.pdf • 3 pages document omitted", messages[9].Text)
+ require.Equal(t, "audio omitted", messages[10].Text)
+ require.True(t, messages[11].Mine)
+ require.Equal(t, "This message was deleted.", messages[11].Text)
+ require.Equal(t, "video omitted", messages[12].Text)
+ require.Equal(t, "Contact card omitted", messages[13].Text)
+ require.Equal(t, "sticker omitted", messages[14].Text)
+ require.Contains(t, messages[15].Text, "changed the group name")
+ require.Equal(t, "A line with [brackets] and: a colon in the middle", messages[16].Text)
+
+ for _, message := range messages {
+ require.NotContains(t, message.Text, "\r")
+ require.NotContains(t, message.Text, "\u200e")
+ require.NotContains(t, message.Sender, "\u200e")
+ }
+}
+
+func TestWhatsAppImportSplitsDaysAndMapsOnlyOwner(t *testing.T) {
+ path := whatsappImportSample(t)
+ names := map[string]WhatsAppChat{
+ "Book Club": {
+ ID: "example@g.us",
+ Name: "Book Club",
+ Group: true,
+ },
+ }
+
+ plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, names, "Sam Owner", time.UTC)
+ require.NoError(t, err)
+ require.Len(t, plan.added, 5)
+ require.Equal(t, "2025-05-20T13:06:44Z", plan.added[0].Date)
+ require.Equal(t, "2025-05-21T09:23:00Z", plan.added[1].Date)
+ require.Equal(t, "2025-12-31T23:59:59Z", plan.added[3].Date)
+ require.Equal(t, "2026-01-01T00:00:01Z", plan.added[4].Date)
+ require.True(t, strings.HasPrefix(plan.added[0].Text, "[WhatsApp] Book Club, group example@g.us, Tuesday 2025-05-20 (imported)\n"))
+ require.Contains(t, plan.added[0].Text, "12:35 Book Club: Messages and calls")
+ require.Contains(t, plan.added[0].Text, "13:02 You: Nice.")
+ require.NotContains(t, plan.added[0].Text, "Sam Owner:")
+ require.Contains(t, plan.added[0].Text, "13:05 ~\u202fBen Carter: Sure.\nThe room costs")
+ require.NotContains(t, plan.added[0].Text, whatsappBatchEmptyReply)
+ require.Equal(t, "2025-05-20T12:35:39Z", plan.first.Format(time.RFC3339))
+ require.Equal(t, "2026-01-01T00:00:01Z", plan.last.Format(time.RFC3339))
+}
+
+func TestWhatsAppImportSkipsHeldDaysAndMessagesAfterLiveCutoff(t *testing.T) {
+ path := whatsappImportSample(t)
+ old := []DatedMessage{
+ {
+ Kind: "work",
+ Text: "[WhatsApp] Live, private chat other@lid, Sunday 2025-06-08\n10:00 Ada: Later",
+ Date: "2025-06-08T10:01:00Z",
+ },
+ {
+ Kind: "work",
+ Text: "[WhatsApp] Live, private chat other@lid, Tuesday 2025-06-03\n18:41 Ada: First\n18:42 Ada: Last",
+ Date: "2025-06-03T18:43:00Z",
+ },
+ {
+ Kind: "work",
+ Text: "[WhatsApp] Book Club, Tuesday 2025-05-20 (imported)\n12:35 Book Club: System",
+ Date: "2025-05-20T13:06:44Z",
+ },
+ }
+
+ plan, err := whatsappImportPlan(t.Context(), []string{path}, old, map[string]WhatsAppChat{}, "Sam Owner", time.UTC)
+ require.NoError(t, err)
+ require.Equal(t, "2025-06-03T18:41:00Z", plan.cutoff.Format(time.RFC3339))
+ require.Equal(t, 1, plan.skipped)
+ require.Len(t, plan.added, 2)
+ require.Equal(t, "2025-06-03T18:40:05Z", plan.added[1].Date)
+ require.NotContains(t, plan.added[1].Text, "18:41")
+ require.NotContains(t, plan.added[1].Text, ", group ")
+ require.Equal(t, len(old)+2, plan.messages)
+}
+
+func TestWhatsAppImportCutoffUsesLocalTimeAndExcludesBoundary(t *testing.T) {
+ 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")
+ old := []DatedMessage{{
+ Kind: "work",
+ Text: "[WhatsApp] Live, group live@g.us, Tuesday 2025-06-03\n18:41 Ada: First",
+ Date: "2025-06-04T01:00:00Z",
+ }}
+
+ plan, err := whatsappImportPlan(t.Context(), []string{path}, old, map[string]WhatsAppChat{}, "", time.FixedZone("TEST", -3*60*60))
+ require.NoError(t, err)
+ require.Equal(t, "2025-06-03T18:41:00-03:00", plan.cutoff.Format(time.RFC3339))
+ require.Len(t, plan.added, 1)
+ require.Contains(t, plan.added[0].Text, "Before")
+ require.NotContains(t, plan.added[0].Text, "Boundary")
+ require.NotContains(t, plan.added[0].Text, "After")
+}
+
+func TestWhatsAppImportKeepsSystemSenderEvenWithOwnerName(t *testing.T) {
+ path := whatsappImportZip(t, "Owner", "[01/01/2025, 00:00:00] Owner: System line\n")
+ _, messages, err := whatsappImportRead(path, "Owner", time.UTC)
+ require.NoError(t, err)
+ require.False(t, messages[0].Mine)
+}
+
+func TestWhatsAppImportRefusesOverlappingInputs(t *testing.T) {
+ path := whatsappImportSample(t)
+ _, err := whatsappImportPlan(t.Context(), []string{path, path}, nil, map[string]WhatsAppChat{}, "", time.UTC)
+ require.ErrorContains(t, err, "inputs overlap")
+}
+
+func TestWhatsAppImportSkipsAllDaysOnALaterStep(t *testing.T) {
+ path := whatsappImportSample(t)
+ first, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, "Sam Owner", time.UTC)
+ require.NoError(t, err)
+
+ second, err := whatsappImportPlan(t.Context(), []string{path}, first.added, nil, "Sam Owner", time.UTC)
+ require.NoError(t, err)
+ require.Empty(t, second.added)
+ require.Equal(t, 5, second.skipped)
+ require.True(t, second.cutoff.IsZero())
+}
+
+func TestWhatsAppImportMergesChatsByDate(t *testing.T) {
+ later := whatsappImportZip(t, "Later", "[02/01/2025, 08:00:00] Ada: Later\n")
+ earlier := whatsappImportZip(t, "Earlier", "[01/01/2025, 08:00:00] Bea: Earlier\n")
+ plan, err := whatsappImportPlan(t.Context(), []string{later, earlier}, nil, nil, "", time.UTC)
+ require.NoError(t, err)
+ require.Len(t, plan.added, 2)
+ require.Contains(t, plan.added[0].Text, "[WhatsApp] Earlier,")
+ require.Contains(t, plan.added[1].Text, "[WhatsApp] Later,")
+}
+
+func TestWhatsAppImportRefusesMissingOrDuplicateChatText(t *testing.T) {
+ testCases := []struct {
+ name string
+ members []string
+ want string
+ }{
+ {
+ name: "missing chat text is refused",
+ members: []string{"other.txt"},
+ want: "_chat.txt is missing",
+ },
+ {
+ name: "duplicate chat text is refused",
+ members: []string{"_chat.txt", "nested/_chat.txt"},
+ want: "more than one _chat.txt",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "Example.zip")
+ file, err := os.Create(path)
+ require.NoError(t, err)
+
+ archive := zip.NewWriter(file)
+ for _, member := range tc.members {
+ _, err = archive.Create(member)
+ require.NoError(t, err)
+ }
+
+ require.NoError(t, archive.Close())
+ require.NoError(t, file.Close())
+
+ _, _, err = whatsappImportRead(path, "", time.UTC)
+ require.ErrorContains(t, err, tc.want)
+ })
+ }
+}
+
+func TestWhatsAppImportRefusesMalformedExports(t *testing.T) {
+ testCases := []struct {
+ name string
+ text string
+ want string
+ }{
+ {
+ name: "text before the first date is refused",
+ text: "not a message\n",
+ want: "before the first",
+ },
+ {
+ name: "invalid local dates are refused",
+ text: "[31/02/2025, 10:00:00] Ada: Invalid\n",
+ want: "parse export local date",
+ },
+ {
+ name: "exports with no dated messages are refused",
+ text: "",
+ want: "no dated messages",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, _, err := whatsappImportRead(whatsappImportZip(t, "Example", tc.text), "", time.UTC)
+ require.ErrorContains(t, err, tc.want)
+ })
+ }
+}
+
+func TestWhatsAppImportPlanCommandDoesNotChangeAgentOrCallModel(t *testing.T) {
+ agent := newRebuildTestAgent(t)
+ dir := filepath.Dir(agent.dir)
+ t.Setenv("ONECHAT_DIR", dir)
+
+ // A plan does not need model settings or credentials.
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "auth.json"), []byte("not credentials"), 0o600))
+
+ rebuildTestLog(t, agent.dir, []DatedMessage{{
+ Kind: "user",
+ Text: "Current",
+ Date: "2026-11-01T00:00:00Z",
+ }})
+
+ before := rebuildTestSnapshot(t, agent.dir)
+ var report strings.Builder
+ require.NoError(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &report))
+ require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
+
+ for _, want := range []string{"chats: 1", "Book Club", "batches: 5", "dates: ", "cutoff: none", "bytes: ", "messages: 6", "estimated calls: 8", "owner: unknown"} {
+ require.Contains(t, report.String(), want)
+ }
+
+ require.NotContains(t, report.String(), "end-to-end encrypted")
+ require.NotContains(t, report.String(), "Sam Owner")
+
+ lock, err := lockTokens(dir)
+ require.NoError(t, err)
+ require.ErrorIs(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), errTokensInUse)
+ require.NoError(t, lock.Close())
+ require.NoError(t, os.WriteFile(filepath.Join(agent.dir, "work.json"), []byte(`{"running":["BlueBird"]}`), 0o600))
+
+ require.ErrorContains(t, whatsappImport(t.Context(), []string{"-n", whatsappImportSample(t)}, &strings.Builder{}), "unfinished work")
+}
+
+func TestWhatsAppImportDoesNotRebuildWhenAllDaysAreHeld(t *testing.T) {
+ agent := newRebuildTestAgent(t)
+ dir := filepath.Dir(agent.dir)
+ t.Setenv("ONECHAT_DIR", dir)
+ path := whatsappImportSample(t)
+ plan, err := whatsappImportPlan(t.Context(), []string{path}, nil, nil, "", time.UTC)
+ require.NoError(t, err)
+
+ rebuildTestLog(t, agent.dir, plan.added)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("not model settings"), 0o600))
+
+ before := rebuildTestSnapshot(t, agent.dir)
+ var report strings.Builder
+ require.NoError(t, whatsappImport(t.Context(), []string{path}, &report))
+ require.Contains(t, report.String(), "batches: 0")
+ require.Contains(t, report.String(), "estimated calls: 0")
+ require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
+}
+
+func TestWhatsAppImportReadsOnlyLocalSessionNames(t *testing.T) {
+ dir := t.TempDir()
+ whatsappTestSession(t, dir, true)
+ session := &WhatsApp{
+ dir: filepath.Join(dir, whatsappDirName),
+ }
+
+ err := session.openSession(t.Context())
+ require.NoError(t, err)
+
+ session.device.PushName = "Sam Owner"
+ session.device.Account.Details = []byte{}
+ require.NoError(t, session.device.Save(t.Context()))
+ require.NoError(t, session.device.Contacts.PutContactName(t.Context(), whatsappTestOwn, "", "Owner"))
+ require.NoError(t, session.device.Contacts.PutContactName(t.Context(), session.device.LID.ToNonAD(), "", "Owner LID"))
+
+ friend := types.NewJID("friend", types.HiddenUserServer)
+ require.NoError(t, session.device.Contacts.PutContactName(t.Context(), friend, "Ada", "Ada Example"))
+ require.NoError(t, session.device.Contacts.PutContactName(t.Context(), types.NewJID("other", types.HiddenUserServer), "Ada", ""))
+ require.NoError(t, session.container.Close())
+
+ names, owner, err := whatsappImportNames(t.Context(), dir)
+ require.NoError(t, err)
+ require.Equal(t, "Sam Owner", owner)
+ require.Equal(t, "friend@lid", names["Ada Example"].ID)
+ require.Empty(t, names["Ada"].ID)
+ require.NotContains(t, names, "Owner")
+ require.NotContains(t, names, "Owner LID")
+}
+
+func whatsappImportSample(t *testing.T) string {
+ t.Helper()
+
+ data, err := os.ReadFile(filepath.Join("testdata", "whatsapp-export.zip"))
+ require.NoError(t, err)
+
+ path := filepath.Join(t.TempDir(), "WhatsApp Chat - Book Club.zip")
+ require.NoError(t, os.WriteFile(path, data, 0o600))
+
+ return path
+}
+
+func whatsappImportZip(t *testing.T, name, text string) string {
+ t.Helper()
+
+ path := filepath.Join(t.TempDir(), "WhatsApp Chat - "+name+".zip")
+ file, err := os.Create(path)
+ require.NoError(t, err)
+
+ archive := zip.NewWriter(file)
+ writer, err := archive.Create("_chat.txt")
+ require.NoError(t, err)
+
+ _, err = writer.Write([]byte(text))
+ require.NoError(t, err)
+ require.NoError(t, archive.Close())
+ require.NoError(t, file.Close())
+
+ return path
+}