/**
* Licensed to The Apereo Foundation under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
*
* The Apereo Foundation licenses this file to you under the Educational
* Community License, Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a copy of the License
* at:
*
* http://opensource.org/licenses/ecl2.txt
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*
*/
package org.opencastproject.oaipmh.server;
import static java.lang.String.format;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.opencastproject.oaipmh.server.OaiPmhRepositoryTest.OaiPmhResponseStatus.IsError;
import static org.opencastproject.oaipmh.server.OaiPmhRepositoryTest.OaiPmhResponseStatus.IsValid;
import static org.opencastproject.util.EqualsUtil.eq;
import static org.opencastproject.util.IoSupport.withResource;
import static org.opencastproject.util.data.Collections.list;
import static org.opencastproject.util.data.Option.some;
import static org.opencastproject.util.data.functions.Misc.chuck;
import static org.xmlmatchers.transform.XmlConverters.the;
import static org.xmlmatchers.xpath.HasXPath.hasXPath;
import static org.xmlmatchers.xpath.XpathReturnType.returningANumber;
import static org.xmlmatchers.xpath.XpathReturnType.returningAString;
import org.opencastproject.mediapackage.MediaPackage;
import org.opencastproject.metadata.dublincore.DublinCoreCatalog;
import org.opencastproject.metadata.dublincore.DublinCores;
import org.opencastproject.oaipmh.Granularity;
import org.opencastproject.oaipmh.OaiPmhConstants;
import org.opencastproject.oaipmh.harvester.OaiPmhNamespaceContext;
import org.opencastproject.oaipmh.matterhorn.MatterhornInlinedMetadataProvider;
import org.opencastproject.oaipmh.persistence.OaiPmhDatabase;
import org.opencastproject.oaipmh.persistence.OaiPmhDatabaseException;
import org.opencastproject.oaipmh.persistence.Query;
import org.opencastproject.oaipmh.persistence.SearchResult;
import org.opencastproject.oaipmh.persistence.SearchResultItem;
import org.opencastproject.oaipmh.util.XmlGen;
import org.opencastproject.util.HttpUtil;
import org.opencastproject.util.IoSupport;
import org.opencastproject.util.JsonObj;
import org.opencastproject.util.JsonVal;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.util.XmlUtil;
import org.opencastproject.util.data.Option;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.easymock.EasyMock;
import org.hamcrest.Matcher;
import org.junit.BeforeClass;
import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.TimeZone;
import javax.xml.namespace.NamespaceContext;
import javax.xml.transform.Source;
public class OaiPmhRepositoryTest {
private static final Logger logger = LoggerFactory.getLogger(OaiPmhRepositoryTest.class);
private static final NamespaceContext NS_CTX = OaiPmhNamespaceContext.getContext();
private static final long RESULT_LIMIT = 3;
private static final boolean DISABLE_VALIDATION = true;
private static boolean runValidation = false;
// CHECKSTYLE:OFF
@Rule
public Timeout globalTimeout = new Timeout(5000);
// CHECKSTYLE:ON
@BeforeClass
public static void checkHttpConnection() {
final CloseableHttpClient client = HttpClients.createDefault();
try {
runValidation = !DISABLE_VALIDATION && HttpUtil.isOk(client.execute(HttpUtil.get(VALIDATOR_SERVICE)));
logger.info("Using external OAI-PMH validator service (" + VALIDATOR_SERVICE + "): " + runValidation);
} catch (IOException e) {
e.printStackTrace();
} finally {
IoSupport.closeQuietly(client);
}
}
@Test
public void testVerbIdentify() throws Exception {
final OaiPmhRepository repo = repo(null, Granularity.DAY);
runChecks(OaiPmhConstants.VERB_IDENTIFY,
repo.selectVerb(params("Identify", null, null, null, null, null)),
some(IsValid),
list(hasXPath("//oai20:Identify[oai20:deletedRecord='transient']", NS_CTX)));
}
@Test
public void testVerbListIdentifiersBadArgument() throws Exception {
final OaiPmhRepository repo = repo(null, Granularity.DAY);
runChecks(OaiPmhConstants.VERB_LIST_IDENTIFIERS,
repo.selectVerb(params("ListIdentifiers", null, null, null, null, null)),
some(IsError),
list(hasXPath("//oai20:error[@code='badArgument']", NS_CTX)));
}
@Test
@SuppressWarnings("unchecked")
public void testVerbListIdentifiersAll() throws Exception {
final OaiPmhRepository repo = repo(
oaiPmhPersistenceMock(searchResultItem("id-1", new Date(), false),
searchResultItem("id-2", utcDate(2011, 6, 1), false)), Granularity.DAY);
runChecks(OaiPmhConstants.VERB_LIST_IDENTIFIERS,
repo.selectVerb(params("ListIdentifiers", null, "oai_dc", null, null, null)),
some(IsValid),
list(hasXPath("//oai20:ListIdentifiers/oai20:header[oai20:identifier='id-1']", NS_CTX),
hasXPath("//oai20:ListIdentifiers/oai20:header[oai20:identifier='id-2']", NS_CTX),
hasXPath("//oai20:ListIdentifiers/oai20:header[oai20:datestamp='2011-06-01']", NS_CTX),
hasXPath("count(//oai20:ListIdentifiers/oai20:header)", NS_CTX, returningANumber(), equalTo(2.0))));
}
/**
* Date range queries are just checked for the error case, since it doesn't make much sense to test with a mocked
* episode service.
*/
@Test
public void testVerbListIdentifiersDateRangeError() throws Exception {
runChecks(OaiPmhConstants.VERB_LIST_IDENTIFIERS,
repo(null, Granularity.DAY)
.selectVerb(params("ListIdentifiers", null, "oai_dc", "2011-01-02", "2011-01-01", null)),
some(IsError),
list(hasXPath("//oai20:error[@code='badArgument']", NS_CTX)));
runChecks(OaiPmhConstants.VERB_LIST_IDENTIFIERS,
repo(null, Granularity.SECOND)
.selectVerb(params("ListIdentifiers", null, "oai_dc", "2011-01-01T10:20:10Z", "2011-01-01T10:20:00Z", null)),
some(IsError),
list(hasXPath("//oai20:error[@code='badArgument']", NS_CTX)));
}
@Test
@SuppressWarnings("unchecked")
public void testVerbListRecordsAll() throws Exception {
runChecks(OaiPmhConstants.VERB_LIST_RECORDS,
repo(oaiPmhPersistenceMock(searchResultItem("id-1", utcDate(2011, 5, 1), false),
searchResultItem("id-2", utcDate(2011, 6, 1), true)), Granularity.DAY)
.selectVerb(params("ListRecords", null, "oai_dc", null, null, null)),
some(IsValid),
list(hasXPath("//oai20:ListRecords/oai20:record/oai20:header[oai20:identifier='id-1']", NS_CTX),
hasXPath("//oai20:ListRecords/oai20:record/oai20:header[oai20:datestamp='2011-05-01']", NS_CTX),
hasXPath("//oai20:ListRecords/oai20:record/oai20:header[@status='deleted']", NS_CTX),
hasXPath("//oai20:ListRecords/oai20:record/oai20:header[oai20:identifier='id-2']", NS_CTX),
hasXPath("//oai20:ListRecords/oai20:record/oai20:header[oai20:datestamp='2011-06-01']", NS_CTX),
hasXPath("count(//oai20:ListRecords/oai20:record)", NS_CTX, returningANumber(), equalTo(2.0)),
hasXPath("count(//oai20:ListRecords/oai20:record/oai20:metadata)", NS_CTX, returningANumber(), equalTo(1.0))));
}
@Test
@SuppressWarnings("unchecked")
public void testVerbGetRecord() throws Exception {
runChecks(OaiPmhConstants.VERB_GET_RECORD,
repo(oaiPmhPersistenceMock(searchResultItem("id-1", utcDate(2011, 6, 1), false)),
Granularity.DAY)
.selectVerb(params("GetRecord", "id-1", "oai_dc", null, null, null)),
Option.<OaiPmhResponseStatus>none(),
list(hasXPath("//oai20:GetRecord/oai20:record/oai20:header[oai20:identifier='id-1']", NS_CTX),
hasXPath("//oai20:GetRecord/oai20:record/oai20:header[oai20:datestamp='2011-06-01']", NS_CTX),
hasXPath("//oai20:GetRecord/oai20:record/oai20:header[not(@status='deleted')]", NS_CTX),
hasXPath("count(//oai20:GetRecord/oai20:record)", NS_CTX, returningANumber(), equalTo(1.0)),
hasXPath("count(//oai20:GetRecord/oai20:record/oai20:metadata)", NS_CTX, returningANumber(), equalTo(1.0))));
}
@Test
@SuppressWarnings("unchecked")
public void testVerbGetRecordDeleted() throws Exception {
runChecks(OaiPmhConstants.VERB_GET_RECORD,
repo(oaiPmhPersistenceMock(searchResultItem("id-1", utcDate(2011, 5, 1), true)),
Granularity.DAY)
.selectVerb(params("GetRecord", "id-1", "oai_dc", null, null, null)),
Option.<OaiPmhResponseStatus>none(),
list(hasXPath("//oai20:GetRecord/oai20:record/oai20:header[oai20:identifier='id-1']", NS_CTX),
hasXPath("//oai20:GetRecord/oai20:record/oai20:header[oai20:datestamp='2011-05-01']", NS_CTX),
hasXPath("//oai20:GetRecord/oai20:record/oai20:header[@status='deleted']", NS_CTX),
hasXPath("count(//oai20:GetRecord/oai20:record)", NS_CTX, returningANumber(), equalTo(1.0)),
hasXPath("count(//oai20:GetRecord/oai20:record/oai20:metadata)", NS_CTX, returningANumber(), equalTo(0.0))));
}
@Test
public void testMatterhornInlinedMetadataProvider() throws Exception {
runChecks(OaiPmhConstants.VERB_LIST_RECORDS,
repo(oaiPmhPersistenceMock(searchResultItem("id-1", utcDate(2011, 5, 1), false),
searchResultItem("id-2", utcDate(2011, 6, 1), true)), Granularity.DAY)
.selectVerb(params("ListRecords", null, "matterhorn-inlined", null, null, null)),
some(IsValid),
list(hasXPath("//oai20:ListRecords/oai20:record/oai20:header[oai20:identifier='id-1']", NS_CTX),
hasXPath("//oai20:ListRecords/oai20:record/oai20:header[oai20:datestamp='2011-05-01']", NS_CTX),
hasXPath("//oai20:ListRecords/oai20:record/oai20:header[@status='deleted']", NS_CTX),
hasXPath("//oai20:ListRecords/oai20:record/oai20:header[oai20:identifier='id-2']", NS_CTX),
hasXPath("//oai20:ListRecords/oai20:record/oai20:header[oai20:datestamp='2011-06-01']", NS_CTX),
hasXPath("//oai20:ListRecords/oai20:record/oai20:header[oai20:datestamp='2011-06-01']", NS_CTX),
hasXPath("count(//oai20:ListRecords/oai20:record)", NS_CTX, returningANumber(), equalTo(2.0)),
hasXPath("count(//oai20:ListRecords/oai20:record)", NS_CTX, returningANumber(), equalTo(2.0)),
hasXPath("count(//oai20:ListRecords/oai20:record/oai20:metadata)", NS_CTX, returningANumber(), equalTo(1.0))));
}
@Ignore
@Test
@SuppressWarnings("unchecked")
public void testResumption() throws Exception {
List<SearchResultItem> items1 = new ArrayList<SearchResultItem>();
items1.add(searchResultItem("id-1", utcDate(2011, 5, 10), false));
items1.add(searchResultItem("id-2", utcDate(2011, 5, 11), false));
items1.add(searchResultItem("id-3", utcDate(2011, 5, 12), false));
List<SearchResultItem> items2 = new ArrayList<SearchResultItem>();
items2.add(searchResultItem("id-4", utcDate(2011, 5, 13), false));
items2.add(searchResultItem("id-5", utcDate(2011, 5, 14), false));
// setup episode service mock
// this setup is really ugly since it needs knowledge about implementation details
OaiPmhDatabase persistence = EasyMock.createMock(OaiPmhDatabase.class);
SearchResult result = EasyMock.createMock(SearchResult.class);
EasyMock.expect(result.getItems()).andReturn(items1).times(3).andReturn(items2).times(3);
EasyMock.expect(result.getLimit()).andReturn(RESULT_LIMIT).anyTimes();
EasyMock.expect(result.getOffset()).andReturn(0L).times(3).andReturn(RESULT_LIMIT).anyTimes();
EasyMock.expect(result.size()).andReturn((long) items1.size()).times(4).andReturn((long) items2.size()).times(4);
EasyMock.expect(persistence.search(EasyMock.<Query>anyObject())).andReturn(result).anyTimes();
EasyMock.replay(persistence);
EasyMock.replay(result);
// do testing
final OaiPmhRepository repo = repo(persistence, Granularity.DAY);
runChecks(OaiPmhConstants.VERB_LIST_IDENTIFIERS,
repo.selectVerb(params("ListIdentifiers", null, "oai_dc", null, null, null)),
some(IsValid),
list(hasXPath("count(//oai20:ListIdentifiers/oai20:header)", NS_CTX, returningANumber(), equalTo(3.0)),
hasXPath("//oai20:ListIdentifiers/oai20:resumptionToken/text()", NS_CTX, returningAString(), equalTo("r-token")),
hasXPath("//oai20:ListIdentifiers/oai20:header[1]/oai20:identifier/text()", NS_CTX, returningAString(), equalTo("id-1")),
hasXPath("//oai20:ListIdentifiers/oai20:header[2]/oai20:identifier/text()", NS_CTX, returningAString(), equalTo("id-2")),
hasXPath("//oai20:ListIdentifiers/oai20:header[3]/oai20:identifier/text()", NS_CTX, returningAString(), equalTo("id-3"))));
// resume query
runChecks(OaiPmhConstants.VERB_LIST_IDENTIFIERS,
repo.selectVerb(params("ListIdentifiers", null, null, null, null, "r-token")),
some(IsValid),
list(hasXPath("count(//oai20:ListIdentifiers/oai20:header)", NS_CTX, returningANumber(), equalTo(2.0)),
hasXPath("//oai20:ListIdentifiers/oai20:header[1]/oai20:identifier/text()", NS_CTX, returningAString(), equalTo("id-4")),
hasXPath("//oai20:ListIdentifiers/oai20:header[2]/oai20:identifier/text()", NS_CTX, returningAString(), equalTo("id-5")),
// token must be empty now since there are no more pages
hasXPath("//oai20:ListIdentifiers/oai20:resumptionToken/text()", NS_CTX, returningAString(), equalTo(""))));
EasyMock.verify(repo.getPersistence());
}
@Test
public void testDateAdaption() {
final Date d = utcDate(2012, 5, 24, 13, 24, 0);
final Date expect = utcDate(2012, 5, 24, 0, 0, 0);
assertEquals(expect, OaiPmhRepository.granulate(Granularity.DAY, d));
}
// --
private void runChecks(String verb, XmlGen xmlGen, Option<OaiPmhResponseStatus> status, List<Matcher<Source>> matchers) throws Exception {
if (runValidation) {
for (OaiPmhResponseStatus s : status) {
assertTrue("http://validator.oaipmh.com/ reports errors", validate(verb, xmlGen, s));
}
}
final Document doc = xmlGen.generate();
final Source xml = the(doc);
logger.info(XmlUtil.toXmlString(doc));
for (Matcher<Source> m : matchers) {
assertThat(xml, m);
}
}
// private static Source s(XmlGen g) {
// return new DOMSource(g.generate());
// }
private static final String VALIDATOR_SERVICE = "http://validator.oaipmh.com/analysers/validateDirectInput.json";
enum OaiPmhResponseStatus {
IsError, IsValid
}
private static boolean validate(String verb, XmlGen xmlgen, OaiPmhResponseStatus status) {
logger.info("--- TALKING TO EXTERNAL OAI-PMH VALIDATION SERVICE ---");
logger.info("--- " + VALIDATOR_SERVICE);
final CloseableHttpClient client = HttpClients.createDefault();
final String xml = xmlgen.generateAsString();
final HttpPost post = HttpUtil.post(VALIDATOR_SERVICE, HttpUtil.param("xml", xml));
logger.info("--- REQUEST ---");
logger.info(xml);
try {
final HttpResponse res = client.execute(post);
final String json = withResource(res.getEntity().getContent(), IoSupport.readToString);
logger.info("--- RESPONSE ---");
logger.info(json);
boolean ok = true;
for (JsonVal message : JsonObj.jsonObj(json).obj("json").arr("messages")) {
if (message.isObj()) {
final JsonObj messageObj = message.as(JsonVal.asJsonObj);
if (messageObj.has("className")) {
final String className = messageObj.val("className").as(JsonVal.asString).trim();
final String text = messageObj.val("text").as(JsonVal.asString).trim();
logger.info(format("[%s] %s", className, text));
ok = ok && (eq(className, "correct")
// since the validator does not validate everything correctly here are some exclusions
|| (status == IsError && eq(text, "Could not find a valid OAI-PMH command.")))
|| (eq(verb, OaiPmhConstants.VERB_IDENTIFY) && eq(text, "Invalid OAI-PMH protocol version ."))
|| (eq(verb, OaiPmhConstants.VERB_IDENTIFY) && eq(text, "Invalid adminEmail ."));
}
}
}
return ok;
} catch (Exception e) {
return (Boolean) chuck(e);
} finally {
IoSupport.closeQuietly(client);
}
}
private static Params params(final String verb, final String identifier, final String metadataPrefix,
final String from, final String until, final String resumptionToken) {
return new Params() {
@Override String getParameter(String key) {
if ("verb".equals(key))
return verb;
if ("identifier".equals(key))
return identifier;
if ("metadataPrefix".equals(key))
return metadataPrefix;
if ("from".equals(key))
return from;
if ("until".equals(key))
return until;
if ("resumptionToken".equals(key))
return resumptionToken;
return null;
}
@Override String getRepositoryUrl() {
return "http://localhost:8080/oaipmh";
}
};
}
private static Date utcDate(int year, int month, int day) {
return utcDate(year, month, day, 0, 0, 0);
}
private static Date utcDate(int year, int month, int day, int hour, int minute, int second) {
Calendar c = Calendar.getInstance();
c.setTimeZone(TimeZone.getTimeZone("UTC"));
c.set(Calendar.YEAR, year);
c.set(Calendar.MONTH, month - 1);
c.set(Calendar.DAY_OF_MONTH, day);
c.set(Calendar.HOUR_OF_DAY, hour);
c.set(Calendar.MINUTE, minute);
c.set(Calendar.SECOND, second);
c.set(Calendar.MILLISECOND, 0);
return c.getTime();
}
private static OaiPmhDatabase oaiPmhPersistenceMock(SearchResultItem... items) {
final SearchResult result = EasyMock.createNiceMock(SearchResult.class);
final List<SearchResultItem> db = list(items);
EasyMock.expect(result.getItems()).andReturn(db).anyTimes();
EasyMock.expect(result.size()).andReturn((long) items.length).anyTimes();
EasyMock.replay(result);
return new OaiPmhDatabase() {
@Override
public void store(MediaPackage mediaPackage, String repository) throws OaiPmhDatabaseException {
// To change body of implemented methods use File | Settings | File Templates.
}
@Override
public void delete(String mediaPackageId, String repository) throws OaiPmhDatabaseException, NotFoundException {
// To change body of implemented methods use File | Settings | File Templates.
}
@Override
public SearchResult search(Query q) {
return result;
}
};
}
private SearchResultItem searchResultItem(String id, Date modified, boolean deleted) {
final String seriesDcXml = IoSupport.loadFileFromClassPathAsString("/series-dublincore.xml").get();
final String episodeDcXml = IoSupport.loadFileFromClassPathAsString("/episode-dublincore.xml").get();
final DublinCoreCatalog seriesDc = DublinCores.read(IOUtils.toInputStream(seriesDcXml));
final DublinCoreCatalog episodeDc = DublinCores.read(IOUtils.toInputStream(episodeDcXml));
final String mpXml = IoSupport.loadFileFromClassPathAsString("/manifest-full.xml").get();
final String xacml = IoSupport.loadFileFromClassPathAsString("/xacml.xml").get();
//
SearchResultItem item = EasyMock.createNiceMock(SearchResultItem.class);
EasyMock.expect(item.getModificationDate()).andReturn(modified).anyTimes();
EasyMock.expect(item.getId()).andReturn(id).anyTimes();
EasyMock.expect(item.isDeleted()).andReturn(deleted).anyTimes();
EasyMock.expect(item.getEpisodeDublinCore()).andReturn(Option.option(episodeDc)).anyTimes();
EasyMock.expect(item.getEpisodeDublinCoreXml()).andReturn(Option.option(episodeDcXml)).anyTimes();
EasyMock.expect(item.getSeriesDublinCore()).andReturn(Option.option(seriesDc)).anyTimes();
EasyMock.expect(item.getSeriesDublinCoreXml()).andReturn(Option.option(seriesDcXml)).anyTimes();
EasyMock.expect(item.getSeriesAclXml()).andReturn(Option.option(xacml)).anyTimes();
EasyMock.expect(item.getMediaPackageXml()).andReturn(mpXml).anyTimes();
EasyMock.replay(item);
return item;
}
private static OaiPmhRepository repo(final OaiPmhDatabase persistence, final Granularity granularity) {
return new OaiPmhRepository() {
@Override
public Granularity getRepositoryTimeGranularity() {
return granularity;
}
@Override
public String getRepositoryName() {
return "Test OAI Repository";
}
@Override
public String getRepositoryId() {
return "test";
}
@Override
public OaiPmhDatabase getPersistence() {
return persistence;
}
@Override
public String getAdminEmail() {
return "admin@localhost.org";
}
@Override
public String saveQuery(ResumableQuery query) {
return "r-token";
}
@Override
public Option<ResumableQuery> getSavedQuery(String resumptionToken) {
return some(new ResumableQuery("oai_dc", new Date(), new Date(), Option.<String>none()));
}
@Override
public int getResultLimit() {
return (int) RESULT_LIMIT;
}
@Override public List<MetadataProvider> getRepositoryMetadataProviders() {
return Arrays.<MetadataProvider>asList(new MatterhornInlinedMetadataProvider());
}
};
}
}