diff --git a/pom.xml b/pom.xml index 11a23df..8fe840f 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ com.reucon.openfire.plugins archive openfire-plugin - 1.0.6-SNAPSHOT + 1.1.0-SNAPSHOT Open Archive http://maven.reucon.com/projects/public/archive XEP-0136 compliant server side message archive for Openfire. diff --git a/src/main/database/archive_hsqldb.sql b/src/main/database/archive_hsqldb.sql index 74faaef..bf7bb29 100644 --- a/src/main/database/archive_hsqldb.sql +++ b/src/main/database/archive_hsqldb.sql @@ -2,6 +2,7 @@ CREATE TABLE archiveConversations ( conversationId BIGINT NOT NULL PRIMARY KEY, startTime BIGINT NOT NULL, endTime BIGINT NOT NULL, + version BIGINT NOT NULL, ownerJid VARCHAR(255) NOT NULL, ownerResource VARCHAR(255), withJid VARCHAR(255) NOT NULL, @@ -37,4 +38,4 @@ CREATE TABLE archiveMessages ( CREATE INDEX idx_archiveMessages_conversationId ON archiveMessages (conversationId); CREATE INDEX idx_archiveMessages_time ON archiveMessages (time); -INSERT INTO ofVersion (name, version) VALUES ('archive', 2); +INSERT INTO ofVersion (name, version) VALUES ('archive', 3); diff --git a/src/main/database/archive_mysql.sql b/src/main/database/archive_mysql.sql index 2501e8d..597d6b2 100644 --- a/src/main/database/archive_mysql.sql +++ b/src/main/database/archive_mysql.sql @@ -2,6 +2,7 @@ CREATE TABLE archiveConversations ( conversationId BIGINT NOT NULL, startTime BIGINT NOT NULL, endTime BIGINT NOT NULL, + version BIGINT NOT NULL, ownerJid VARCHAR(255) NOT NULL, ownerResource VARCHAR(255), withJid VARCHAR(255) NOT NULL, @@ -56,4 +57,4 @@ CREATE TABLE archivePrefMethods ( PRIMARY KEY (username,methodType) ); -INSERT INTO ofVersion (name, version) VALUES ('archive', 2); +INSERT INTO ofVersion (name, version) VALUES ('archive', 3); diff --git a/src/main/database/archive_postgresql.sql b/src/main/database/archive_postgresql.sql index 309a4c2..1e4bcde 100644 --- a/src/main/database/archive_postgresql.sql +++ b/src/main/database/archive_postgresql.sql @@ -2,6 +2,7 @@ CREATE TABLE archiveConversations ( conversationId BIGINT NOT NULL, startTime BIGINT NOT NULL, endTime BIGINT NOT NULL, + version BIGINT NOT NULL, ownerJid VARCHAR(255) NOT NULL, ownerResource VARCHAR(255), withJid VARCHAR(255) NOT NULL, @@ -57,4 +58,4 @@ CREATE TABLE archivePrefMethods ( CONSTRAINT archivePrefMethods_pk PRIMARY KEY (username,methodType) ); -INSERT INTO ofVersion (name, version) VALUES ('archive', 2); +INSERT INTO ofVersion (name, version) VALUES ('archive', 3); diff --git a/src/main/database/archive_sqlserver.sql b/src/main/database/archive_sqlserver.sql index 87ab807..ddf7fef 100644 --- a/src/main/database/archive_sqlserver.sql +++ b/src/main/database/archive_sqlserver.sql @@ -2,6 +2,7 @@ CREATE TABLE archiveConversations ( conversationId BIGINT NOT NULL, startTime BIGINT NOT NULL, endTime BIGINT NOT NULL, +version BIGINT NOT NULL, ownerJid VARCHAR(255) NOT NULL, ownerResource VARCHAR(255), withJid VARCHAR(255) NOT NULL, @@ -56,4 +57,4 @@ methodUsage INTEGER, PRIMARY KEY (username,methodType) ); -INSERT INTO ofVersion (name, version) VALUES ('archive', 2); +INSERT INTO ofVersion (name, version) VALUES ('archive', 3); diff --git a/src/main/database/upgrade/3/archive_hsqldb.sql b/src/main/database/upgrade/3/archive_hsqldb.sql new file mode 100644 index 0000000..a116e4e --- /dev/null +++ b/src/main/database/upgrade/3/archive_hsqldb.sql @@ -0,0 +1,3 @@ +ALTER TABLE archiveConversations add column version BIGINT DEFAULT 0 NOT NULL; + +UPDATE ofVersion SET version=3 WHERE name='archive'; diff --git a/src/main/database/upgrade/3/archive_mysql.sql b/src/main/database/upgrade/3/archive_mysql.sql new file mode 100644 index 0000000..7047571 --- /dev/null +++ b/src/main/database/upgrade/3/archive_mysql.sql @@ -0,0 +1,3 @@ +ALTER TABLE archiveConversations add column version BIGINT NOT NULL default 0; + +UPDATE ofVersion SET version=3 WHERE name='archive'; diff --git a/src/main/database/upgrade/3/archive_postgresql.sql b/src/main/database/upgrade/3/archive_postgresql.sql new file mode 100644 index 0000000..7047571 --- /dev/null +++ b/src/main/database/upgrade/3/archive_postgresql.sql @@ -0,0 +1,3 @@ +ALTER TABLE archiveConversations add column version BIGINT NOT NULL default 0; + +UPDATE ofVersion SET version=3 WHERE name='archive'; diff --git a/src/main/database/upgrade/3/archive_sqlserver.sql b/src/main/database/upgrade/3/archive_sqlserver.sql new file mode 100644 index 0000000..7047571 --- /dev/null +++ b/src/main/database/upgrade/3/archive_sqlserver.sql @@ -0,0 +1,3 @@ +ALTER TABLE archiveConversations add column version BIGINT NOT NULL default 0; + +UPDATE ofVersion SET version=3 WHERE name='archive'; diff --git a/src/main/java/com/reucon/openfire/plugin/archive/impl/ArchiveManagerImpl.java b/src/main/java/com/reucon/openfire/plugin/archive/impl/ArchiveManagerImpl.java index 1eac9a9..5ab6693 100644 --- a/src/main/java/com/reucon/openfire/plugin/archive/impl/ArchiveManagerImpl.java +++ b/src/main/java/com/reucon/openfire/plugin/archive/impl/ArchiveManagerImpl.java @@ -137,6 +137,7 @@ private Conversation determineConversation(JID ownerJid, JID withJid, String sub else { conversation.setEnd(archivedMessage.getTime()); + conversation.setVersion(conversation.getVersion() + 1); persistenceManager.updateConversationEnd(conversation); } } diff --git a/src/main/java/com/reucon/openfire/plugin/archive/impl/JdbcPersistenceManager.java b/src/main/java/com/reucon/openfire/plugin/archive/impl/JdbcPersistenceManager.java index 3552e48..11bc98d 100644 --- a/src/main/java/com/reucon/openfire/plugin/archive/impl/JdbcPersistenceManager.java +++ b/src/main/java/com/reucon/openfire/plugin/archive/impl/JdbcPersistenceManager.java @@ -1,3 +1,23 @@ +/** + * + * Copyright (C) 20xx Stefan Reuter + others + * Copyright (C) 2012 Taylor Raack + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + package com.reucon.openfire.plugin.archive.impl; import com.reucon.openfire.plugin.archive.ArchivedMessageConsumer; @@ -11,10 +31,12 @@ import org.jivesoftware.util.Log; import java.sql.*; +import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.List; +import java.util.TimeZone; /** * Manages database persistence. @@ -29,7 +51,7 @@ public class JdbcPersistenceManager implements PersistenceManager public static final String SELECT_ALL_MESSAGES = "SELECT m.messageId,m.time,m.direction,m.type,m.subject,m.body," + - " c.conversationId,c.startTime,c.endTime," + + " c.conversationId,c.startTime,c.endTime,c.version," + " c.ownerJid,c.ownerResource,c.withJid,c.withResource,c.subject,c.thread " + "FROM archiveMessages AS m, archiveConversations AS c " + "WHERE m.conversationId = c.conversationId " + @@ -41,14 +63,14 @@ public class JdbcPersistenceManager implements PersistenceManager public static final String CREATE_CONVERSATION = "INSERT INTO archiveConversations (conversationId,startTime,endTime," + - " ownerJid,ownerResource,withJid,withResource,subject,thread) " + - "VALUES (?,?,?,?,?,?,?,?,?)"; + " ownerJid,ownerResource,withJid,withResource,subject,thread,version) " + + "VALUES (?,?,?,?,?,?,?,?,?,0)"; public static final String UPDATE_CONVERSATION_END = - "UPDATE archiveConversations SET endTime = ? WHERE conversationId = ?"; + "UPDATE archiveConversations SET endTime = ?, version = ? WHERE conversationId = ?"; public static final String SELECT_CONVERSATIONS = - "SELECT c.conversationId,c.startTime,c.endTime,c.ownerJid,c.ownerResource,c.withJid,c.withResource," + + "SELECT c.conversationId,c.startTime,c.endTime,c.version,c.ownerJid,c.ownerResource,c.withJid,c.withResource," + " c.subject,c.thread " + "FROM archiveConversations AS c"; public static final String COUNT_CONVERSATIONS = @@ -61,7 +83,7 @@ public class JdbcPersistenceManager implements PersistenceManager public static final String CONVERSATION_WITH_JID_RESOURCE = "c.withResource"; public static final String SELECT_ACTIVE_CONVERSATIONS = - "SELECT c.conversationId,c.startTime,c.endTime,c.ownerJid,c.ownerResource,withJid,c.withResource," + + "SELECT c.conversationId,c.startTime,c.endTime,c.version,c.ownerJid,c.ownerResource,withJid,c.withResource," + " c.subject,c.thread " + "FROM archiveConversations AS c WHERE c.endTime > ?"; @@ -129,9 +151,9 @@ public int processAllMessages(ArchivedMessageConsumer callback) if (conversation == null || !conversation.getId().equals(conversationId)) { conversation = new Conversation( - millisToDate(rs.getLong(8)), millisToDate(rs.getLong(9)), - rs.getString(10), rs.getString(11), rs.getString(12), rs.getString(13), - rs.getString(14), rs.getString(15)); + millisToDate(rs.getLong(8)), millisToDate(rs.getLong(9)), rs.getLong(10), + rs.getString(11), rs.getString(12), rs.getString(13), rs.getString(14), + rs.getString(15), rs.getString(16)); conversation.setId(conversationId); } message.setConversation(conversation); @@ -206,7 +228,8 @@ public boolean updateConversationEnd(Conversation conversation) con = DbConnectionManager.getConnection(); pstmt = con.prepareStatement(UPDATE_CONVERSATION_END); pstmt.setLong(1, dateToMillis(conversation.getEnd())); - pstmt.setLong(2, conversation.getId()); + pstmt.setLong(2, conversation.getVersion()); + pstmt.setLong(3, conversation.getId()); pstmt.executeUpdate(); return true; @@ -449,6 +472,101 @@ else if (xmppResultSet.getBefore() != null) } return conversations; } + + public List findModifiedConversationsSince(Date date, String ownerJid, String withJid, XmppResultSet xmppResultSet) { + final List conversations; + final StringBuilder querySB; + final StringBuilder whereSB; + final StringBuilder limitSB; + + conversations = new ArrayList(); + + querySB = new StringBuilder(SELECT_CONVERSATIONS); + whereSB = new StringBuilder(); + limitSB = new StringBuilder(); + + String bareWithJid = null; + String withJidResource = null; + + if(date != null) + { + appendWhere(whereSB, CONVERSATION_END_TIME, " >= ?"); + } + if (ownerJid != null) + { + appendWhere(whereSB, CONVERSATION_OWNER_JID, " = ?"); + } + if (withJid != null) + { + + + // look for resource on with JID + String[] parts = withJid.split("\\/"); + bareWithJid = parts[0]; + + appendWhere(whereSB, CONVERSATION_WITH_JID, " = ?"); + + if(parts.length > 1) { + withJidResource = parts[1]; + appendWhere(whereSB, CONVERSATION_WITH_JID_RESOURCE, " = ?"); + } + } + + if (xmppResultSet != null) + { + // TODO - need the "last" identified logic here + Integer firstIndex = null; + int max = xmppResultSet.getMax() != null ? xmppResultSet.getMax() : DEFAULT_MAX; + + // set as the total count, not just this fragment of collections + xmppResultSet.setCount(countModifiedConversationsSince(date, ownerJid, bareWithJid, withJidResource, whereSB.toString())); + limitSB.append(" LIMIT ").append(max); + } + + if (whereSB.length() != 0) + { + querySB.append(" WHERE ").append(whereSB); + } + + // The server MUST return the changed collections in the chronological order that they were changed (most recent last). + querySB.append(" ORDER BY ").append(CONVERSATION_END_TIME); + querySB.append(limitSB); + + Connection con = null; + PreparedStatement pstmt = null; + ResultSet rs = null; + try + { + con = DbConnectionManager.getConnection(); + pstmt = con.prepareStatement(querySB.toString()); + Date queryDate = date; + /*if(xmppResultSet.getAfter() != null) { + queryDate = new Date(xmppResultSet.getAfter() + 1); + }*/ + bindModifiedConversationSinceParameters(queryDate, ownerJid, bareWithJid, withJidResource, pstmt); + rs = pstmt.executeQuery(); + while (rs.next()) + { + conversations.add(extractConversation(rs)); + } + } + catch (SQLException sqle) + { + Log.error("Error selecting conversations", sqle); + } + finally + { + DbConnectionManager.closeConnection(rs, pstmt, con); + } + + if (xmppResultSet != null && conversations.size() > 0) + { + // set to millis since epoch + Date endDate = conversations.get(conversations.size() - 1).getEnd(); + xmppResultSet.setLast(endDate.getTime()); + } + return conversations; + } private void appendWhere(StringBuilder sb, String... fragments) { @@ -501,6 +619,45 @@ private int countConversations(Date startDate, Date endDate, String ownerJid, St DbConnectionManager.closeConnection(rs, pstmt, con); } } + + private int countModifiedConversationsSince(Date date, String ownerJid, String withBareJid, String withJidResource, String whereClause) + { + StringBuilder querySB; + + querySB = new StringBuilder(COUNT_CONVERSATIONS); + if (whereClause != null && whereClause.length() != 0) + { + querySB.append(" WHERE ").append(whereClause); + } + + Connection con = null; + PreparedStatement pstmt = null; + ResultSet rs = null; + try + { + con = DbConnectionManager.getConnection(); + pstmt = con.prepareStatement(querySB.toString()); + bindModifiedConversationSinceParameters(date, ownerJid, withBareJid, withJidResource, pstmt); + rs = pstmt.executeQuery(); + if (rs.next()) + { + return rs.getInt(1); + } + else + { + return 0; + } + } + catch (SQLException sqle) + { + Log.error("Error counting conversations", sqle); + return 0; + } + finally + { + DbConnectionManager.closeConnection(rs, pstmt, con); + } + } private int countConversationsBefore(Date startDate, Date endDate, String ownerJid, String withJid, Long before, String whereClause) { @@ -568,6 +725,29 @@ private int bindConversationParameters(Date startDate, Date endDate, String owne } return parameterIndex; } + + private int bindModifiedConversationSinceParameters(Date date, String ownerJid, String withBareJid, String withJidResource, PreparedStatement pstmt) throws SQLException + { + int parameterIndex = 1; + + if (date != null) + { + pstmt.setLong(parameterIndex++, dateToMillis(date)); + } + if (ownerJid != null) + { + pstmt.setString(parameterIndex++, ownerJid); + } + if (withBareJid != null) + { + pstmt.setString(parameterIndex++, withBareJid); + } + if (withJidResource != null) + { + pstmt.setString(parameterIndex++, withJidResource); + } + return parameterIndex; + } public Collection getActiveConversations(int conversationTimeout) { @@ -801,8 +981,8 @@ private Conversation extractConversation(ResultSet rs) id = rs.getLong(1); conversation = new Conversation(millisToDate(rs.getLong(2)), millisToDate(rs.getLong(3)), - rs.getString(4), rs.getString(5), rs.getString(6), rs.getString(7), - rs.getString(8), rs.getString(9)); + rs.getLong(4), rs.getString(5), rs.getString(6), rs.getString(7), rs.getString(8), + rs.getString(9), rs.getString(10)); conversation.setId(id); return conversation; } @@ -844,4 +1024,6 @@ private Date millisToDate(Long millis) { return millis == null ? null : new Date(millis); } + + } diff --git a/src/main/java/com/reucon/openfire/plugin/archive/model/Conversation.java b/src/main/java/com/reucon/openfire/plugin/archive/model/Conversation.java index 27a82b8..05d6c8a 100644 --- a/src/main/java/com/reucon/openfire/plugin/archive/model/Conversation.java +++ b/src/main/java/com/reucon/openfire/plugin/archive/model/Conversation.java @@ -1,3 +1,24 @@ +/** + * + * Copyright (C) 20xx Stefan Reuter + others + * Copyright (C) 2012 Taylor Raack + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + + package com.reucon.openfire.plugin.archive.model; import org.jivesoftware.database.JiveID; @@ -13,6 +34,7 @@ public class Conversation private Long id; private final Date start; private Date end; + private Long version; private final String ownerJid; private final String ownerResource; private final String withJid; @@ -25,14 +47,15 @@ public class Conversation public Conversation(Date start, String ownerJid, String ownerResource, String withJid, String withResource, String subject, String thread) { - this(start, start, ownerJid, ownerResource, withJid, withResource, subject, thread); + this(start, start, 0L, ownerJid, ownerResource, withJid, withResource, subject, thread); } - public Conversation(Date start, Date end, String ownerJid, String ownerResource, String withJid, String withResource, + public Conversation(Date start, Date end, Long version, String ownerJid, String ownerResource, String withJid, String withResource, String subject, String thread) { this.start = start; this.end = end; + this.version = version; this.ownerJid = ownerJid; this.ownerResource = ownerResource; this.withJid = withJid; @@ -67,8 +90,16 @@ public void setEnd(Date end) { this.end = end; } + + public Long getVersion() { + return version; + } + + public void setVersion(Long version) { + this.version = version; + } - public String getOwnerJid() + public String getOwnerJid() { return ownerJid; } diff --git a/src/main/java/com/reucon/openfire/plugin/archive/xep0136/IQModifiedHandler.java b/src/main/java/com/reucon/openfire/plugin/archive/xep0136/IQModifiedHandler.java new file mode 100644 index 0000000..719114b --- /dev/null +++ b/src/main/java/com/reucon/openfire/plugin/archive/xep0136/IQModifiedHandler.java @@ -0,0 +1,97 @@ +/** + * + * Copyright (C) 2012 Taylor Raack + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package com.reucon.openfire.plugin.archive.xep0136; + +import java.util.Date; +import java.util.List; + +import org.apache.commons.lang.StringUtils; +import org.dom4j.Element; +import org.jivesoftware.openfire.auth.UnauthorizedException; +import org.xmpp.packet.IQ; +import org.xmpp.packet.JID; + +import com.reucon.openfire.plugin.archive.model.Conversation; +import com.reucon.openfire.plugin.archive.util.XmppDateUtil; +import com.reucon.openfire.plugin.archive.xep0059.XmppResultSet; + +public class IQModifiedHandler extends AbstractIQHandler { + + public IQModifiedHandler() + { + super("Message Archiving Modified Handler", "modified"); + } + + @Override + public IQ handleIQ(IQ packet) throws UnauthorizedException { + IQ reply = IQ.createResultIQ(packet); + ListRequest listRequest = new ListRequest(packet.getChildElement()); + JID from = packet.getFrom(); + + Element modifiedElement = reply.setChildElement("modified", NAMESPACE); + + + List conversations = modified(from, listRequest); + XmppResultSet resultSet = listRequest.getResultSet(); + + for (Conversation conversation : conversations) + { + addConversationElement(modifiedElement, conversation); + } + + if (resultSet != null) + { + modifiedElement.add(resultSet.createResultElement()); + } + + return reply; + } + + private List modified(JID from, ListRequest request) + { + // need to query the persistence manager for conversations whose since the start date provided (paginated) + + Date start = request.getStart(); + if(request.getResultSet().getAfter() != null) { + start = new Date(request.getResultSet().getAfter() + 1); + } + return getPersistenceManager().findModifiedConversationsSince(start, + from.toBareJID(), request.getWith(), request.getResultSet()); + } + + private Element addConversationElement(Element modifiedElement, Conversation conversation) + { + + // TODO - if removal ever is implemented, then we need to mark as either + // changed OR removed + // for now, only changed elements are supported + Element conversationElement = modifiedElement.addElement("changed"); + + StringBuilder builder = new StringBuilder(conversation.getWithJid()); + if(StringUtils.isNotEmpty(conversation.getWithResource())) { + builder.append("/").append(conversation.getWithResource()); + } + conversationElement.addAttribute("with", builder.toString()); + conversationElement.addAttribute("start", XmppDateUtil.formatDate(conversation.getStart())); + conversationElement.addAttribute("version", conversation.getVersion() + ""); + + return conversationElement; + } +} diff --git a/src/main/java/com/reucon/openfire/plugin/archive/xep0136/Xep0136Support.java b/src/main/java/com/reucon/openfire/plugin/archive/xep0136/Xep0136Support.java index b8c71b5..54f4fdc 100644 --- a/src/main/java/com/reucon/openfire/plugin/archive/xep0136/Xep0136Support.java +++ b/src/main/java/com/reucon/openfire/plugin/archive/xep0136/Xep0136Support.java @@ -19,6 +19,7 @@ */ public class Xep0136Support { + private static final String NAMESPACE_BASE = "urn:xmpp:archive"; private static final String NAMESPACE_AUTO = "urn:xmpp:archive:auto"; final XMPPServer server; @@ -58,11 +59,16 @@ public IQ handleIQ(IQ packet) throws UnauthorizedException // support for #ns-manage iqHandlers.add(new IQListHandler()); iqHandlers.add(new IQRetrieveHandler()); + iqHandlers.add(new IQModifiedHandler()); + // TODO -if the remove handler is ever implemented, the IQModifiedHandler must be adjusted to + // send back any removed collections //iqHandlers.add(new IQRemoveHandler()); } public void start() { + server.getIQDiscoInfoHandler().addServerFeature(NAMESPACE_BASE); + for (IQHandler iqHandler : iqHandlers) { try diff --git a/src/main/openfire/plugin.xml b/src/main/openfire/plugin.xml index ff34359..a7b04d1 100644 --- a/src/main/openfire/plugin.xml +++ b/src/main/openfire/plugin.xml @@ -12,7 +12,7 @@ ${openfire-plugin.build.date} 3.4.0 archive - 2 + 3 gpl diff --git a/src/test/java/com/reucon/openfire/plugin/archive/impl/JdbcPersistenceManagerTest.java b/src/test/java/com/reucon/openfire/plugin/archive/impl/JdbcPersistenceManagerTest.java index c7169ab..214f116 100644 --- a/src/test/java/com/reucon/openfire/plugin/archive/impl/JdbcPersistenceManagerTest.java +++ b/src/test/java/com/reucon/openfire/plugin/archive/impl/JdbcPersistenceManagerTest.java @@ -38,7 +38,7 @@ public void retrievingCollectionWithBareWIthJidWorks() throws SQLException { mockStatic(DbConnectionManager.class); // conversation query mocking - String expectedCollectionQuery = "SELECT c.conversationId,c.startTime,c.endTime,c.ownerJid,c.ownerResource," + + String expectedCollectionQuery = "SELECT c.conversationId,c.startTime,c.endTime,c.version,c.ownerJid,c.ownerResource," + "c.withJid,c.withResource, c.subject,c.thread FROM archiveConversations AS c WHERE c.ownerJid = ? " + "AND c.withJid = ? AND c.startTime = ? "; @@ -107,7 +107,7 @@ public void retrievingCollectionSplitsExactJIDForWithBeforeQuery() throws SQLExc mockStatic(DbConnectionManager.class); // conversation query mocking - String expectedCollectionQuery = "SELECT c.conversationId,c.startTime,c.endTime,c.ownerJid,c.ownerResource," + + String expectedCollectionQuery = "SELECT c.conversationId,c.startTime,c.endTime,c.version,c.ownerJid,c.ownerResource," + "c.withJid,c.withResource, c.subject,c.thread FROM archiveConversations AS c WHERE c.ownerJid = ? " + "AND c.withJid = ? AND c.withResource = ? AND c.startTime = ? "; diff --git a/src/test/java/com/reucon/openfire/plugin/archive/xep0136/IQModifiedHandlerTest.java b/src/test/java/com/reucon/openfire/plugin/archive/xep0136/IQModifiedHandlerTest.java new file mode 100644 index 0000000..97c13f1 --- /dev/null +++ b/src/test/java/com/reucon/openfire/plugin/archive/xep0136/IQModifiedHandlerTest.java @@ -0,0 +1,291 @@ +/** + * + * Copyright (C) 2012 Taylor Raack + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package com.reucon.openfire.plugin.archive.xep0136; + +import static org.junit.Assert.assertEquals; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.argThat; +import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.powermock.api.mockito.PowerMockito.mockStatic; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; + +import org.dom4j.Element; +import org.dom4j.Namespace; +import org.dom4j.QName; +import org.dom4j.dom.DOMElement; +import org.dom4j.tree.DefaultElement; +import org.jivesoftware.openfire.auth.UnauthorizedException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatcher; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.xmpp.packet.IQ; + +import com.reucon.openfire.plugin.archive.ArchivePlugin; +import com.reucon.openfire.plugin.archive.PersistenceManager; +import com.reucon.openfire.plugin.archive.model.Conversation; +import com.reucon.openfire.plugin.archive.util.XmppDateUtil; +import com.reucon.openfire.plugin.archive.xep0059.XmppResultSet; + +@RunWith(PowerMockRunner.class) +@PrepareForTest( { ArchivePlugin.class }) +public class IQModifiedHandlerTest { + + private IQModifiedHandler handler; + + private PersistenceManager persistenceManager; + + @Before + public void setup() { + handler = new IQModifiedHandler(); + + ArchivePlugin plugin = mock(ArchivePlugin.class); + persistenceManager = mock(PersistenceManager.class); + mockStatic(ArchivePlugin.class); + when(ArchivePlugin.getInstance()).thenReturn(plugin); + when(plugin.getPersistenceManager()).thenReturn(persistenceManager); + } + + @Test + public void basicEmptyModifiedResults() throws UnauthorizedException { + + // need to capture XMPPResultSet argument + int maxResults = 30; + String startTime = "1469-07-21T02:56:15Z"; + + IQ packet = createModifiedRequest(maxResults, startTime, null); + + final List conversations = new ArrayList(); + + mockPersistenceResponse(XmppDateUtil.parseDate(startTime), null, maxResults, conversations); + + // method under test + IQ reply = handler.handleIQ(packet); + + // verification + assertEquals(IQ.Type.result, reply.getType()); + assertEquals("thejid", reply.getTo().toString()); + assertNotNull(reply.getChildElement()); + Element replyModified = reply.getChildElement(); + assertEquals("modified", replyModified.getName()); + assertEquals("urn:xmpp:archive", replyModified.getNamespace().getText()); + + // should have one child the set + assertEquals(1, replyModified.nodeCount()); + Element replySetElement = replyModified.element(QName.get("set", XmppResultSet.NAMESPACE)); + assertNotNull(replySetElement); + assertEquals("set", replySetElement.getName()); + assertEquals("http://jabber.org/protocol/rsm", replySetElement.getNamespace().getText()); + + Element countElement = replySetElement.element("count"); + assertEquals("0", countElement.getText()); + } + + + + @Test + public void conversationsReturned() throws UnauthorizedException { + + // need to capture XMPPResultSet argument + int maxResults = 30; + String startTime = "1469-07-21T02:56:15Z"; + + IQ packet = createModifiedRequest(maxResults, startTime, null); + + Conversation conversation1 = new Conversation(date("2012/04/27 14:27:28"), date("2012/04/27 14:27:28"), 2L, "thejid", "theresource", "otherjid", "otherresource", null, null); + Conversation conversation2 = new Conversation(date("2012/04/28 12:39:10"), date("2012/04/29 18:34:55"), 4L, "thejid", "theresource", "otherjid2", "otherresource2", null, null); + final List conversations = Arrays.asList(new Conversation[] {conversation1, conversation2}); + + mockPersistenceResponse(XmppDateUtil.parseDate(startTime), null, maxResults, conversations); + + // method under test + IQ reply = handler.handleIQ(packet); + + // verification + Element replyElement = reply.getChildElement(); + assertEquals(IQ.Type.result, reply.getType()); + assertEquals("thejid", reply.getTo().toString()); + assertNotNull(reply.getChildElement()); + Element replyModified = reply.getChildElement(); + assertEquals("modified", replyModified.getName()); + assertEquals("urn:xmpp:archive", replyModified.getNamespace().getText()); + + // should have one child the set + assertEquals(3, replyModified.nodeCount()); + Element replySetElement = replyModified.element(QName.get("set", XmppResultSet.NAMESPACE)); + assertNotNull(replySetElement); + assertEquals("set", replySetElement.getName()); + assertEquals("http://jabber.org/protocol/rsm", replySetElement.getNamespace().getText()); + Element countElement = replySetElement.element("count"); + assertEquals("2", countElement.getText()); + + Element lastElement = replySetElement.element("last"); + assertEquals(conversation2.getEnd().getTime() + "", lastElement.getText()); + + List changed = replyModified.elements("changed"); + assertEquals(2, changed.size()); + verifyChanged(conversation1, changed.get(0)); + verifyChanged(conversation2, changed.get(1)); + } + + @Test + public void conversationsReturnedWithAfterSpecified() throws UnauthorizedException { + + // need to capture XMPPResultSet argument + int maxResults = 30; + String startTime = "1469-07-21T02:56:15.132Z"; + String afterTime = "2012-03-01T02:56:15.765Z"; + + IQ packet = createModifiedRequest(maxResults, startTime, XmppDateUtil.parseDate(afterTime).getTime() + ""); + + Conversation conversation1 = new Conversation(date("2012/04/27 14:27:28"), date("2012/04/27 14:27:28"), 2L, "thejid", "theresource", "otherjid", "otherresource", null, null); + Conversation conversation2 = new Conversation(date("2012/04/28 12:39:10"), date("2012/04/29 18:34:55"), 4L, "thejid", "theresource", "otherjid2", "otherresource2", null, null); + final List conversations = Arrays.asList(new Conversation[] {conversation1, conversation2}); + + // expect the persistence layer to look just after the afterDate + Date afterDate = new Date(XmppDateUtil.parseDate(afterTime).getTime() + 1); + mockPersistenceResponse(XmppDateUtil.parseDate(startTime), afterDate, maxResults, conversations); + + // method under test + IQ reply = handler.handleIQ(packet); + + verify(persistenceManager).findModifiedConversationsSince(eq(afterDate), any(String.class), any(String.class), argThat(hasMaxResults(maxResults))); + + + // verification + Element replyElement = reply.getChildElement(); + assertEquals(IQ.Type.result, reply.getType()); + assertEquals("thejid", reply.getTo().toString()); + assertNotNull(reply.getChildElement()); + Element replyModified = reply.getChildElement(); + assertEquals("modified", replyModified.getName()); + assertEquals("urn:xmpp:archive", replyModified.getNamespace().getText()); + + // should have one child the set + assertEquals(3, replyModified.nodeCount()); + Element replySetElement = replyModified.element(QName.get("set", XmppResultSet.NAMESPACE)); + assertNotNull(replySetElement); + assertEquals("set", replySetElement.getName()); + assertEquals("http://jabber.org/protocol/rsm", replySetElement.getNamespace().getText()); + Element countElement = replySetElement.element("count"); + assertEquals("2", countElement.getText()); + + Element lastElement = replySetElement.element("last"); + assertEquals(conversation2.getEnd().getTime() + "", lastElement.getText()); + + List changed = replyModified.elements("changed"); + assertEquals(2, changed.size()); + verifyChanged(conversation1, changed.get(0)); + verifyChanged(conversation2, changed.get(1)); + + } + + private void verifyChanged(Conversation conversation, Element element) { + assertEquals(conversation.getWithJid() + "/" + conversation.getWithResource(), element.attribute("with").getText()); + assertEquals(dateUTC(conversation.getStart()), element.attribute("start").getText()); + assertEquals(conversation.getVersion() + "", element.attribute("version").getText()); + } + + private String dateUTC(Date date) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + return sdf.format(date); + } + + private Date date(String dateString) { + String format = "yyyy/MM/dd HH:mm:ss"; + try { + return new SimpleDateFormat(format, Locale.ENGLISH).parse(dateString); + } catch (ParseException e) { + throw new RuntimeException("Cannot convert " + dateString + " to " + format, e); + } + } + + private void mockPersistenceResponse(Date date, Date afterDate, int maxResults, final List conversations) { + if(afterDate != null) { + date = afterDate; + } + when(persistenceManager.findModifiedConversationsSince(eq(date), eq("thejid"), isNull(String.class), argThat(hasMaxResults(maxResults)))) + .thenAnswer(new Answer>() { + @Override + public List answer(InvocationOnMock invocation) throws Throwable { + Object args[] = invocation.getArguments(); + + // add empty result set + XmppResultSet resultSet = (XmppResultSet)args[3]; + if(conversations.size() > 0) { + resultSet.setLast(conversations.get(conversations.size() - 1).getEnd().getTime()); + } + resultSet.setCount(conversations.size()); + + return conversations; + }}); + } + + + private ArgumentMatcher hasMaxResults(final int maxResults) { + return new ArgumentMatcher() { + public boolean matches(Object argument) { + XmppResultSet resultSet = (XmppResultSet)argument; + return resultSet.getMax() == maxResults; + }}; + } + + private IQ createModifiedRequest(int maxResults, String startTime, String afterTime) { + Element modifiedElement = new DOMElement("modified", Namespace.get("urn:xmpp:archive")); + modifiedElement.addAttribute("start", startTime); + Element setElement = new DefaultElement("set", Namespace.get("http://jabber.org/protocol/rsm")); + + Element maxElement = new DefaultElement("max"); + maxElement.setText(maxResults + ""); + setElement.add(maxElement); + if(afterTime != null) { + Element afterElement = new DefaultElement("after"); + afterElement.setText(afterTime); + setElement.add(afterElement); + } + modifiedElement.add(setElement); + + IQ packet = new IQ(IQ.Type.get, "sync1"); + packet.setFrom("thejid"); + packet.setChildElement(modifiedElement); + return packet; + } + +} diff --git a/src/test/java/com/reucon/openfire/plugin/archive/xep0136/Xep0136SupportTest.java b/src/test/java/com/reucon/openfire/plugin/archive/xep0136/Xep0136SupportTest.java new file mode 100644 index 0000000..c1a8e14 --- /dev/null +++ b/src/test/java/com/reucon/openfire/plugin/archive/xep0136/Xep0136SupportTest.java @@ -0,0 +1,50 @@ +/** + * + * Copyright (C) 2012 Taylor Raack + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package com.reucon.openfire.plugin.archive.xep0136; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.jivesoftware.openfire.IQRouter; +import org.jivesoftware.openfire.XMPPServer; +import org.jivesoftware.openfire.disco.IQDiscoInfoHandler; +import org.junit.Test; + +public class Xep0136SupportTest { + + @Test + public void featuresSupported() { + XMPPServer server = mock(XMPPServer.class); + IQDiscoInfoHandler discoInfoHandler = mock(IQDiscoInfoHandler.class); + IQRouter router = mock(IQRouter.class); + + when(server.getIQDiscoInfoHandler()).thenReturn(discoInfoHandler); + when(server.getIQRouter()).thenReturn(router); + + Xep0136Support support = new Xep0136Support(server); + + support.start(); + + verify(discoInfoHandler).addServerFeature("urn:xmpp:archive"); + verify(discoInfoHandler).addServerFeature("urn:xmpp:archive:manage"); + verify(discoInfoHandler).addServerFeature("urn:xmpp:archive:auto"); + } +}