diff --git a/iped-engine/src/test/java/iped/engine/datasource/ufed/UfedChatRenderingIntegrationTest.java b/iped-engine/src/test/java/iped/engine/datasource/ufed/UfedChatRenderingIntegrationTest.java
new file mode 100644
index 0000000000..da28cacc4b
--- /dev/null
+++ b/iped-engine/src/test/java/iped/engine/datasource/ufed/UfedChatRenderingIntegrationTest.java
@@ -0,0 +1,272 @@
+package iped.engine.datasource.ufed;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.xml.sax.Attributes;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+import org.xml.sax.XMLReader;
+import org.xml.sax.helpers.DefaultHandler;
+
+import iped.data.IItemReader;
+import iped.datasource.IDataSource;
+import iped.engine.datasource.ufed.UfedModelHandler.UfedModelListener;
+import iped.parsers.ufed.ReportGenerator;
+import iped.parsers.ufed.UfedChatParser;
+import iped.parsers.ufed.model.BaseModel;
+import iped.parsers.ufed.model.Chat;
+import iped.search.IItemSearcher;
+import iped.utils.EmptyInputStream;
+
+/**
+ * Integration test proving the REAL XML-driven pipeline works end to end:
+ * {@link UfedModelHandler} (this module) parses a real UFDR-exported XML
+ * fragment into a {@link Chat}, which is then fed into {@link ReportGenerator}
+ * and {@link UfedChatParser} (iped-parsers-impl module).
+ *
+ *
+ * This exists alongside {@link UfedModelHandlerChatTest} (same XML fixture)
+ * and iped-parsers-impl's ReportGeneratorTest/UfedChatParserTest (hand-built
+ * Chat fixtures) to close a gap those leave open: a hand-built Chat can drift
+ * from what the real handler actually produces (e.g. it never calls {@code
+ * chat.indexMessages()} or copies XML attributes like {@code extractionId}),
+ * so passing hand-built-fixture tests do not, by themselves, prove the real
+ * parser output renders correctly. This test exercises code paths the
+ * hand-built fixtures cannot reach — e.g. reply/quote rendering (the fixture's
+ * reply resolves through a directly-embedded {@code InstantMessage} rather
+ * than the {@code chat.indexMessages()} ID-lookup fallback; see
+ * {@link #testHtmlContainsReplyQuote()}), and shared-contact/attachment
+ * fields ({@code ContactEntry}, {@code file_id}, ...) a hand-built fixture
+ * would have to remember to populate by hand.
+ *
+ *
+ * iped-engine has no Mockito dependency (unlike iped-parsers-impl), so
+ * IItemSearcher/IItemReader are stubbed with a small generic
+ * {@link Proxy}-based double instead.
+ */
+public class UfedChatRenderingIntegrationTest {
+
+ // Shared across all @Test methods (parsing the fixture is the expensive
+ // part). Safe regardless of JUnit's execution order: ReportGenerator only
+ // reads the Chat (its own pagination cursor is a per-instance field, not
+ // stored on Chat), and while UfedChatParser.parse() does sort
+ // chat.getMessages() in place and mark Party.referenceLoaded, none of the
+ // assertions in this class depend on message order, and the stub searcher
+ // always resolves references to "not found" deterministically however many
+ // times loadReferences() runs.
+ private static Chat parsedChat;
+
+ @BeforeClass
+ public static void setUp() throws Exception {
+
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ SAXParser saxParser = factory.newSAXParser();
+ XMLReader xmlReader = saxParser.getXMLReader();
+ DefaultHandler parentHandler = new DefaultHandler();
+ UfedModelListener listener = new UfedModelHandler.UfedModelListener() {
+ @Override
+ public void onModelStarted(BaseModel model, Attributes attr) {
+ }
+
+ @Override
+ public void onModelCompleted(BaseModel model) {
+ parsedChat = (Chat) model;
+ }
+ };
+
+ UfedModelHandler handler = new UfedModelHandler(xmlReader, parentHandler, listener);
+
+ InputStream xmlFile = UfedChatRenderingIntegrationTest.class.getResourceAsStream("/ufed-model-chat.xml");
+ saxParser.parse(xmlFile, handler);
+ }
+
+ private static String generateFullHtml(Chat chat) throws Exception {
+ ReportGenerator generator = new ReportGenerator(chat, 6_000_000);
+ StringBuilder html = new StringBuilder();
+ byte[] bytes;
+ while ((bytes = generator.generateNextChatHtml()) != null) {
+ html.append(new String(bytes, StandardCharsets.UTF_8));
+ }
+ return html.toString();
+ }
+
+ // ── ReportGenerator against the real, XML-parsed Chat ────────────────────
+
+ @Test
+ public void testGeneratesHtmlForRealUfedChat() throws Exception {
+ assertNotNull("ReportGenerator must render HTML for a real UfedModelHandler-parsed chat",
+ generateFullHtml(parsedChat));
+ }
+
+ @Test
+ public void testHtmlContainsParticipantName() throws Exception {
+ // "Clefable" is the phone owner and the sender of two messages in the real
+ // fixture (see UfedModelHandlerChatTest). Checking the per-message sender
+ // span specifically -- not just html.contains("Clefable") -- makes this
+ // robust to fixture/chat-type changes: for this one-on-one chat the header
+ // actually only shows the OTHER participant (ChatHandler.getTitle(true,
+ // false) suppresses the owner), so a plain substring check happens to be
+ // safe here too, but the span check is what directly verifies per-message
+ // attribution (the exact gap found and fixed in the sibling
+ // ReportGeneratorTest, where the header DID include the checked name).
+ String html = generateFullHtml(parsedChat);
+ assertTrue("Rendered HTML must attribute at least one message to its sender",
+ html.contains("Clefable"));
+ }
+
+ @Test
+ public void testHtmlContainsAudioAttachmentMarker() throws Exception {
+ // The real attachment's ContentType is "audio/ogg; codecs=opus"
+ String html = generateFullHtml(parsedChat);
+ assertTrue("An audio/* content type must render through the audio branch",
+ html.contains("class=\"audioImg"));
+ }
+
+ @Test
+ public void testHtmlContainsAttachmentTitle() throws Exception {
+ // The real attachment's Title field is "Duration: 00:00:03"
+ String html = generateFullHtml(parsedChat);
+ assertTrue("The attachment title must be rendered when not already present in the body",
+ html.contains("Duration: 00:00:03"));
+ }
+
+ @Test
+ public void testHtmlContainsSharedContactName() throws Exception {
+ String html = generateFullHtml(parsedChat);
+ assertTrue("Rendered HTML must contain the shared contact's name", html.contains("Charmeleon"));
+ }
+
+ @Test
+ public void testHtmlContainsReplyQuote() throws Exception {
+ // Message '2833f15a-...' replies to a message from "Pidgeot" (see
+ // UfedModelHandlerChatTest.testMessageWithReplyMessageData). "Pidgeot"
+ // appears nowhere else in the fixture, so this can only come from
+ // ReportGenerator.printQuote()'s rendering. For THIS message the quoted
+ // InstantMessage is directly embedded in ReplyMessageData (the real
+ // UfedModelHandler wires that on the ReplyMessageData->InstantMessage
+ // parent/child relationship), which InstantMessage.findReplyMessage()
+ // checks before falling back to chat.indexMessages()-based ID lookup --
+ // so this specific case doesn't exercise that ID-lookup fallback, only
+ // the embedded-message path (still one hand-built fixtures never covered,
+ // since none of them populated reply/quote data at all).
+ String html = generateFullHtml(parsedChat);
+ assertTrue("Rendered HTML must contain the quoted message's sender", html.contains("Pidgeot"));
+ }
+
+ // ── UfedChatParser against the real, XML-parsed Chat ─────────────────────
+
+ private static class EmbeddedDocTracker implements EmbeddedDocumentExtractor {
+
+ final List docs = new ArrayList<>();
+
+ @Override
+ public boolean shouldParseEmbedded(Metadata metadata) {
+ return true;
+ }
+
+ @Override
+ public void parseEmbedded(InputStream stream, ContentHandler handler, Metadata metadata, boolean outputHtml)
+ throws SAXException, IOException {
+ docs.add(metadata);
+ }
+ }
+
+ // Scoped workaround: iped-engine has no Mockito dependency (unlike
+ // iped-parsers-impl). Fine for stubbing 2 narrow interfaces here; if a
+ // future iped-engine test needs interaction verification or many more
+ // stubbed methods, add mockito-core as a test-scope dependency instead of
+ // growing this helper.
+ @SuppressWarnings("unchecked")
+ private static T stub(Class iface, Map values) {
+ InvocationHandler handler = (proxy, method, args) -> {
+ if (values.containsKey(method.getName())) {
+ return values.get(method.getName());
+ }
+ Class> r = method.getReturnType();
+ if (r == boolean.class)
+ return Boolean.FALSE;
+ if (r.isPrimitive())
+ return 0;
+ if (List.class.isAssignableFrom(r))
+ return Collections.emptyList();
+ return null;
+ };
+ return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class>[] { iface }, handler);
+ }
+
+ private static IItemReader newItemReaderStub() {
+ Map dataSourceValues = new HashMap<>();
+ dataSourceValues.put("getUUID", "00000000-0000-0000-0000-000000000000");
+ IDataSource dataSource = stub(IDataSource.class, dataSourceValues);
+
+ Map itemValues = new HashMap<>();
+ itemValues.put("getDataSource", dataSource);
+ return stub(IItemReader.class, itemValues);
+ }
+
+ private static IItemSearcher newItemSearcherStub() {
+ Map searcherValues = new HashMap<>();
+ searcherValues.put("search", Collections.emptyList());
+ // best-effort identity escaping is close enough for this stub's purpose
+ InvocationHandler handler = (proxy, method, args) -> {
+ if ("escapeQuery".equals(method.getName())) {
+ return args[0];
+ }
+ if ("search".equals(method.getName()) || "searchIterable".equals(method.getName())) {
+ return Collections.emptyList();
+ }
+ return null;
+ };
+ return (IItemSearcher) Proxy.newProxyInstance(IItemSearcher.class.getClassLoader(),
+ new Class>[] { IItemSearcher.class }, handler);
+ }
+
+ @Test
+ public void testParsesRealUfedChatWithoutErrors() throws IOException, SAXException, TikaException {
+ UfedChatParser parser = new UfedChatParser();
+ Metadata metadata = new Metadata();
+
+ EmbeddedDocTracker tracker = new EmbeddedDocTracker();
+ ParseContext context = new ParseContext();
+ context.set(IItemSearcher.class, newItemSearcherStub());
+ context.set(IItemReader.class, newItemReaderStub());
+ context.set(EmbeddedDocumentExtractor.class, tracker);
+
+ TikaInputStream tikaStream = TikaInputStream.get(new EmptyInputStream());
+ tikaStream.setOpenContainer(parsedChat);
+
+ parser.parse(tikaStream, new BodyContentHandler(1 << 20), metadata, context);
+
+ // 1 chat preview HTML + one embedded doc per message; the real fixture has
+ // no CHAT-LEVEL ActivityLog entry (its single ChatActivity is attached to
+ // one message instead, a distinct code path -- see class Javadoc)
+ int expectedTotal = 1 + parsedChat.getMessages().size() + parsedChat.getActivityLog().size();
+ assertEquals("UfedChatParser must extract one embedded doc per real message, plus the chat preview",
+ expectedTotal, tracker.docs.size());
+ }
+}
diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/ReportGeneratorTest.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/ReportGeneratorTest.java
new file mode 100644
index 0000000000..1aa05504e5
--- /dev/null
+++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/ReportGeneratorTest.java
@@ -0,0 +1,111 @@
+package iped.parsers.ufed;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Test;
+
+import iped.parsers.ufed.model.Chat;
+
+public class ReportGeneratorTest {
+
+ private static final int LARGE_SPLIT_SIZE = 6_000_000;
+
+ private static String generateFullHtml(Chat chat, int minChatSplitSize) throws Exception {
+ ReportGenerator generator = new ReportGenerator(chat, minChatSplitSize);
+ StringBuilder html = new StringBuilder();
+ byte[] bytes;
+ while ((bytes = generator.generateNextChatHtml()) != null) {
+ html.append(new String(bytes, StandardCharsets.UTF_8));
+ }
+ return html.toString();
+ }
+
+ @Test
+ public void testGeneratesNonNullHtmlForNonEmptyChat() throws Exception {
+ Chat chat = UfedChatFixtures.buildSampleChat();
+ ReportGenerator generator = new ReportGenerator(chat, LARGE_SPLIT_SIZE);
+ assertNotNull("First call must return the chat HTML", generator.generateNextChatHtml());
+ }
+
+ @Test
+ public void testHtmlContainsMessageSenderName() throws Exception {
+ // Checks the per-message sender span specifically (not just the chat
+ // header, which also happens to show the participant's name for a
+ // one-on-one chat and would make this assertion pass vacuously)
+ String html = generateFullHtml(UfedChatFixtures.buildSampleChat(), LARGE_SPLIT_SIZE);
+ assertTrue("Rendered HTML must attribute the message to its sender",
+ html.contains("" + UfedChatFixtures.ALICE_NAME));
+ }
+
+ @Test
+ public void testHtmlContainsMessageBody() throws Exception {
+ String html = generateFullHtml(UfedChatFixtures.buildSampleChat(), LARGE_SPLIT_SIZE);
+ assertTrue("Rendered HTML must contain the message body", html.contains(UfedChatFixtures.MSG1_BODY));
+ }
+
+ @Test
+ public void testHtmlContainsSharedContactName() throws Exception {
+ String html = generateFullHtml(UfedChatFixtures.buildSampleChat(), LARGE_SPLIT_SIZE);
+ assertTrue("Rendered HTML must contain the shared contact's name",
+ html.contains(UfedChatFixtures.SHARED_CONTACT_NAME));
+ }
+
+ @Test
+ public void testHtmlContainsAttachmentUrl() throws Exception {
+ String html = generateFullHtml(UfedChatFixtures.buildSampleChat(), LARGE_SPLIT_SIZE);
+ assertTrue("Rendered HTML must contain the attachment URL", html.contains(UfedChatFixtures.ATTACHMENT_URL));
+ }
+
+ @Test
+ public void testMixedCaseContentTypeIsDispatchedAsImage() throws Exception {
+ // ContentType matching is case-insensitive; a mixed-case value must still
+ // route to the image-specific rendering branch, not the generic fallback
+ Chat chat = UfedChatFixtures.buildChatWithSingleAttachment("IMAGE/JPEG");
+ String html = generateFullHtml(chat, LARGE_SPLIT_SIZE);
+ assertTrue("A mixed-case 'IMAGE/JPEG' content type must render through the image branch",
+ html.contains("class=\"imageImg\""));
+ }
+
+ @Test
+ public void testEmptyChatProducesOnlyOneFragment() throws Exception {
+ Chat chat = new Chat();
+ chat.setId("empty-chat");
+ chat.setField("Source", Chat.SOURCE_WHATSAPP);
+ ReportGenerator generator = new ReportGenerator(chat, LARGE_SPLIT_SIZE);
+
+ assertNotNull("An empty chat must still produce a header/footer fragment", generator.generateNextChatHtml());
+ assertNull("A second call on an empty chat must return null", generator.generateNextChatHtml());
+ }
+
+ @Test
+ public void testGetNextMsgNumReachesTotalAfterFullChat() throws Exception {
+ Chat chat = UfedChatFixtures.buildSampleChat();
+ ReportGenerator generator = new ReportGenerator(chat, LARGE_SPLIT_SIZE);
+ generator.generateNextChatHtml();
+ assertEquals("All messages must be consumed in a single large-split-size fragment",
+ chat.getMessages().size(), generator.getNextMsgNum());
+ assertNull("No more fragments must remain", generator.generateNextChatHtml());
+ }
+
+ @Test
+ public void testSmallSplitSizeSplitsOneMessagePerFragment() throws Exception {
+ Chat chat = UfedChatFixtures.buildSampleChat();
+ ReportGenerator generator = new ReportGenerator(chat, 1);
+
+ List fragments = new ArrayList<>();
+ byte[] bytes;
+ while ((bytes = generator.generateNextChatHtml()) != null) {
+ fragments.add(bytes);
+ }
+
+ assertEquals("With minChatSplitSize=1, each fragment must hold exactly one message",
+ chat.getMessages().size(), fragments.size());
+ }
+}
diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatFixtures.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatFixtures.java
new file mode 100644
index 0000000000..b19aa15353
--- /dev/null
+++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatFixtures.java
@@ -0,0 +1,137 @@
+package iped.parsers.ufed;
+
+import java.util.Date;
+import java.util.List;
+
+import iped.parsers.ufed.model.Attachment;
+import iped.parsers.ufed.model.Chat;
+import iped.parsers.ufed.model.ChatActivity;
+import iped.parsers.ufed.model.Contact;
+import iped.parsers.ufed.model.ContactEntry;
+import iped.parsers.ufed.model.InstantMessage;
+import iped.parsers.ufed.model.Party;
+
+/**
+ * Builds a small, hand-crafted {@link Chat} model tree used by
+ * {@link ReportGeneratorTest} and {@link UfedChatParserTest}.
+ *
+ * The model classes are populated directly via {@code setField(...)}, the same
+ * generic mechanism the real XML-driven UfedModelHandler (iped-engine module)
+ * uses, so no UFDR fixture file or cross-module test dependency is needed.
+ */
+final class UfedChatFixtures {
+
+ static final String CHAT_ID = "chat-1";
+ static final String OWNER_ID = "5511900000000@s.whatsapp.net";
+ static final String ALICE_NAME = "Alice Wonder";
+ static final String ALICE_ID = "5511999998888@s.whatsapp.net";
+ static final String MSG1_ID = "msg-1";
+ static final String MSG1_BODY = "Hello team!";
+ static final String MSG2_ID = "msg-2";
+ static final String ATTACHMENT_URL = "https://example.com/shared-photo";
+ static final String MSG3_ID = "msg-3";
+ static final String SHARED_CONTACT_NAME = "Charmeleon";
+ static final String SHARED_CONTACT_PHONE = "+55 11 99999-2222";
+ static final String ACTIVITY_ID = "activity-1";
+ static final String ACTIVITY_SYSTEM_MESSAGE = "Alice Wonder joined the group";
+
+ private UfedChatFixtures() {
+ }
+
+ static Chat buildSampleChat() {
+
+ Chat chat = new Chat();
+ chat.setId(CHAT_ID);
+ chat.setField("Source", Chat.SOURCE_WHATSAPP);
+ chat.setField("ChatType", Chat.TYPE_ONEONONE);
+ chat.setField("StartTime", new Date(1_650_000_000_000L));
+
+ Party owner = new Party();
+ owner.setId("party-owner");
+ owner.setField("Identifier", OWNER_ID);
+ owner.setField("IsPhoneOwner", Boolean.TRUE);
+ chat.getParticipants().add(owner);
+
+ Party alice = new Party();
+ alice.setId("party-alice");
+ alice.setField("Name", ALICE_NAME);
+ alice.setField("Identifier", ALICE_ID);
+ alice.setField("IsPhoneOwner", Boolean.FALSE);
+ chat.getParticipants().add(alice);
+
+ // msg 1: plain text message received from Alice
+ InstantMessage msg1 = new InstantMessage(chat);
+ msg1.setId(MSG1_ID);
+ msg1.setField("Body", MSG1_BODY);
+ msg1.setField("TimeStamp", new Date(1_650_000_100_000L));
+ msg1.setFrom(alice);
+ chat.getMessages().add(msg1);
+
+ // msg 2: sent by the phone owner, with a URL-type attachment
+ InstantMessage msg2 = new InstantMessage(chat);
+ msg2.setId(MSG2_ID);
+ msg2.setField("TimeStamp", new Date(1_650_000_200_000L));
+ msg2.setFrom(owner);
+ Attachment attachment = new Attachment();
+ attachment.setId("att-1");
+ attachment.setAttribute("file_id", "file-1");
+ attachment.setField("ContentType", "URL");
+ attachment.setField("URL", ATTACHMENT_URL);
+ msg2.getAttachments().add(attachment);
+ chat.getMessages().add(msg2);
+
+ // msg 3: received from Alice, sharing a contact
+ InstantMessage msg3 = new InstantMessage(chat);
+ msg3.setId(MSG3_ID);
+ msg3.setField("TimeStamp", new Date(1_650_000_300_000L));
+ msg3.setFrom(alice);
+ Contact sharedContact = new Contact();
+ sharedContact.setId("contact-1");
+ sharedContact.setField("Name", SHARED_CONTACT_NAME);
+ ContactEntry phoneEntry = new ContactEntry("PhoneNumber");
+ phoneEntry.setField("Value", SHARED_CONTACT_PHONE);
+ sharedContact.getContactEntries().put("PhoneNumber", List.of(phoneEntry));
+ msg3.getSharedContacts().add(sharedContact);
+ chat.getMessages().add(msg3);
+
+ // chat activity: Alice joined the chat
+ ChatActivity activity = new ChatActivity();
+ activity.setId(ACTIVITY_ID);
+ activity.setField("Action", "ParticipantJoined");
+ activity.setField("SystemMessageBody", ACTIVITY_SYSTEM_MESSAGE);
+ activity.setParticipant(alice);
+ chat.getActivityLog().add(activity);
+
+ return chat;
+ }
+
+ /**
+ * A minimal one-message chat whose single attachment has the given
+ * ContentType, for tests that exercise {@code ReportGenerator}'s per-type
+ * attachment rendering dispatch.
+ */
+ static Chat buildChatWithSingleAttachment(String contentType) {
+
+ Chat chat = new Chat();
+ chat.setId("chat-attachment");
+ chat.setField("Source", Chat.SOURCE_WHATSAPP);
+ chat.setField("ChatType", Chat.TYPE_ONEONONE);
+
+ Party owner = new Party();
+ owner.setId("party-owner");
+ owner.setField("IsPhoneOwner", Boolean.TRUE);
+ chat.getParticipants().add(owner);
+
+ InstantMessage msg = new InstantMessage(chat);
+ msg.setId("msg-attachment");
+ msg.setField("TimeStamp", new Date(1_650_000_000_000L));
+ msg.setFrom(owner);
+ Attachment attachment = new Attachment();
+ attachment.setId("att-image");
+ attachment.setField("ContentType", contentType);
+ msg.getAttachments().add(attachment);
+ chat.getMessages().add(msg);
+
+ return chat;
+ }
+}
diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatParserTest.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatParserTest.java
new file mode 100644
index 0000000000..11152fb40b
--- /dev/null
+++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/ufed/UfedChatParserTest.java
@@ -0,0 +1,211 @@
+package iped.parsers.ufed;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentExtractor;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.BodyContentHandler;
+import org.junit.Test;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import iped.data.IItemReader;
+import iped.datasource.IDataSource;
+import iped.parsers.standard.StandardParser;
+import iped.parsers.ufed.model.Chat;
+import iped.properties.ExtraProperties;
+import iped.search.IItemSearcher;
+import iped.utils.EmptyInputStream;
+
+/**
+ * Covers the top-level Tika {@code parse()} entry point: embedded-document
+ * counts/metadata, content type per source app, and the ignoreEmptyChats
+ * option.
+ *
+ *
+ * Out of scope for this baseline suite: the searcher-integration depth
+ * (resolving attachments/shared contacts/quoted messages to actual indexed
+ * items and asserting on the resulting LINKED_ITEMS/SHARED_HASHES metadata).
+ * The {@link IItemSearcher} mock is stubbed just enough (see
+ * {@link #newContextMock()}) so the reference-resolution code path in
+ * {@code ChatHandler}/{@code PartyHandler}/{@code AttachmentHandler} runs to
+ * completion instead of crashing on Mockito's null defaults, but it always
+ * resolves to "no match found" — a real search-integration test is a
+ * natural follow-up once this baseline lands.
+ */
+public class UfedChatParserTest {
+
+ private static class EmbeddedDocTracker implements EmbeddedDocumentExtractor {
+
+ final List docs = new ArrayList<>();
+
+ @Override
+ public boolean shouldParseEmbedded(Metadata metadata) {
+ return true;
+ }
+
+ @Override
+ public void parseEmbedded(InputStream stream, ContentHandler handler, Metadata metadata, boolean outputHtml)
+ throws SAXException, IOException {
+ docs.add(metadata);
+ }
+ }
+
+ /**
+ * A minimally-stubbed IItemReader/IItemSearcher pair: enough that reference
+ * -resolution code (e.g. AccountableHandler.createAccountableQuery(), which
+ * dereferences item.getDataSource().getUUID()) runs its real query-building
+ * logic instead of throwing a NullPointerException on Mockito's unstubbed
+ * defaults. The searcher still finds nothing, matching a chat whose
+ * referenced items were not indexed/carved.
+ */
+ private static IItemReader newItemReaderMock() {
+ IItemReader item = mock(IItemReader.class);
+ IDataSource dataSource = mock(IDataSource.class);
+ when(dataSource.getUUID()).thenReturn(UUID.randomUUID().toString());
+ when(item.getDataSource()).thenReturn(dataSource);
+ return item;
+ }
+
+ private static IItemSearcher newItemSearcherMock() {
+ IItemSearcher searcher = mock(IItemSearcher.class);
+ when(searcher.escapeQuery(org.mockito.ArgumentMatchers.anyString()))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+ return searcher;
+ }
+
+ private EmbeddedDocTracker parse(Chat chat) throws IOException, SAXException, TikaException {
+ return parse(chat, parser -> {
+ });
+ }
+
+ private EmbeddedDocTracker parse(Chat chat, Consumer configurer)
+ throws IOException, SAXException, TikaException {
+ UfedChatParser parser = new UfedChatParser();
+ configurer.accept(parser);
+
+ Metadata metadata = new Metadata();
+ EmbeddedDocTracker tracker = new EmbeddedDocTracker();
+ ParseContext context = new ParseContext();
+ context.set(IItemSearcher.class, newItemSearcherMock());
+ context.set(IItemReader.class, newItemReaderMock());
+ context.set(EmbeddedDocumentExtractor.class, tracker);
+
+ TikaInputStream tikaStream = TikaInputStream.get(new EmptyInputStream());
+ tikaStream.setOpenContainer(chat);
+
+ ContentHandler handler = new BodyContentHandler(1 << 20);
+ parser.parse(tikaStream, handler, metadata, context);
+ return tracker;
+ }
+
+ // ── embedded document counts ─────────────────────────────────────────────
+
+ @Test
+ public void testExtractsOneChatPreviewMessagesAndActivity() throws Exception {
+ Chat chat = UfedChatFixtures.buildSampleChat();
+ EmbeddedDocTracker tracker = parse(chat);
+
+ // 1 chat preview HTML + 3 messages + 1 activity log entry (holds for a
+ // chat small enough to render in a single HTML fragment; see
+ // testActivityLogIsDuplicatedAcrossFragments for the multi-fragment case)
+ int expectedTotal = 1 + chat.getMessages().size() + chat.getActivityLog().size();
+ assertEquals(expectedTotal, tracker.docs.size());
+ }
+
+ @Test
+ public void testChatPreviewHasContentTypeForSource() throws Exception {
+ Chat chat = UfedChatFixtures.buildSampleChat();
+ EmbeddedDocTracker tracker = parse(chat);
+
+ boolean hasWhatsAppPreview = tracker.docs.stream()
+ .anyMatch(m -> UfedChatParser.appToMime.get("whatsapp").toString()
+ .equals(m.get(StandardParser.INDEXER_CONTENT_TYPE)));
+ assertTrue("Chat preview doc must use the WhatsApp-specific preview media type", hasWhatsAppPreview);
+ }
+
+ @Test
+ public void testChatPreviewCarriesVirtualId() throws Exception {
+ Chat chat = UfedChatFixtures.buildSampleChat();
+ EmbeddedDocTracker tracker = parse(chat);
+
+ boolean hasVirtualId = tracker.docs.stream()
+ .anyMatch(m -> UfedChatFixtures.CHAT_ID.equals(m.get(ExtraProperties.ITEM_VIRTUAL_ID)));
+ assertTrue("Chat preview doc must carry the chat's virtual id", hasVirtualId);
+ }
+
+ @Test
+ public void testExtractedMessagesReferenceParentChat() throws Exception {
+ Chat chat = UfedChatFixtures.buildSampleChat();
+ EmbeddedDocTracker tracker = parse(chat);
+
+ long messagesWithParent = tracker.docs.stream()
+ .filter(m -> UfedChatFixtures.CHAT_ID.equals(m.get(ExtraProperties.PARENT_VIRTUAL_ID)))
+ .count();
+ // messages + activity log entries are both linked to the chat as parent
+ assertEquals(chat.getMessages().size() + chat.getActivityLog().size(), messagesWithParent);
+ }
+
+ @Test
+ public void testActivityLogTitleIsExtracted() throws Exception {
+ Chat chat = UfedChatFixtures.buildSampleChat();
+ EmbeddedDocTracker tracker = parse(chat);
+
+ // ChatActivityHandler.getTitle() format: "ChatActivity--[]"
+ boolean hasActivityTitle = tracker.docs.stream()
+ .map(m -> m.get(TikaCoreProperties.TITLE))
+ .filter(java.util.Objects::nonNull)
+ .anyMatch(title -> title.contains("ParticipantJoined") && title.contains(UfedChatFixtures.ACTIVITY_ID));
+ assertTrue("Activity log doc title must reference its action and id", hasActivityTitle);
+ }
+
+ @Test
+ public void testEmptyChatIsIgnoredWhenConfigured() throws Exception {
+ Chat chat = new Chat();
+ chat.setId("empty-chat");
+ chat.setField("Source", Chat.SOURCE_WHATSAPP);
+
+ EmbeddedDocTracker tracker = parse(chat, parser -> parser.setIgnoreEmptyChats(true));
+
+ assertEquals("No embedded docs must be produced for an empty, ignored chat", 0, tracker.docs.size());
+ }
+
+ @Test
+ public void testActivityLogIsDuplicatedAcrossFragments() throws Exception {
+ // KNOWN LIMITATION, not fixed here (out of scope for a test-only PR):
+ // UfedChatParser.parse() calls extractActivityLog(chat, ...) once per HTML
+ // fragment inside the pagination loop, but that method always iterates the
+ // FULL chat.getActivityLog() rather than a per-fragment slice (unlike
+ // extractMessages(), which is correctly given a sliced subList). With
+ // minChatSplitSize forcing N fragments, each of the chat's activity log
+ // entries is emitted N times. This test locks in and documents that
+ // observed behavior so a future fix is a deliberate, visible test change
+ // rather than a silent one.
+ Chat chat = UfedChatFixtures.buildSampleChat();
+ int messageCount = chat.getMessages().size();
+ EmbeddedDocTracker tracker = parse(chat, parser -> parser.setMinChatSplitSize(1));
+
+ long activityDocs = tracker.docs.stream()
+ .map(m -> m.get(TikaCoreProperties.TITLE))
+ .filter(java.util.Objects::nonNull)
+ .filter(title -> title.contains("ChatActivity"))
+ .count();
+
+ assertEquals("Each of the " + messageCount + " message fragments currently re-emits the full activity log",
+ messageCount * (long) chat.getActivityLog().size(), activityDocs);
+ }
+}