org.sakaiproject.tool.assessment.facade.ItemHashUtilTest.java Source code

Java tutorial

Introduction

Here is the source code for org.sakaiproject.tool.assessment.facade.ItemHashUtilTest.java

Source

/**
 * Copyright (c) 2005-2017 The Apereo Foundation
 *
 * Licensed 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
 *
 * 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.sakaiproject.tool.assessment.facade;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.internal.util.collections.Sets;
import org.sakaiproject.component.api.ServerConfigurationService;
import org.sakaiproject.content.api.ContentHostingService;
import org.sakaiproject.content.api.ContentResource;
import org.sakaiproject.exception.IdUnusedException;
import org.sakaiproject.exception.PermissionException;
import org.sakaiproject.exception.ServerOverloadException;
import org.sakaiproject.exception.TypeException;
import org.sakaiproject.tool.assessment.data.dao.assessment.Answer;
import org.sakaiproject.tool.assessment.data.dao.assessment.AnswerFeedback;
import org.sakaiproject.tool.assessment.data.dao.assessment.ItemAttachment;
import org.sakaiproject.tool.assessment.data.dao.assessment.ItemData;
import org.sakaiproject.tool.assessment.data.dao.assessment.ItemMetaData;
import org.sakaiproject.tool.assessment.data.dao.assessment.ItemTag;
import org.sakaiproject.tool.assessment.data.dao.assessment.ItemText;
import org.sakaiproject.tool.assessment.data.dao.assessment.ItemTextAttachment;
import org.sakaiproject.tool.assessment.data.ifc.assessment.AnswerFeedbackIfc;
import org.sakaiproject.tool.assessment.data.ifc.assessment.AttachmentIfc;
import org.sakaiproject.tool.assessment.data.ifc.assessment.ItemDataIfc;
import org.sakaiproject.tool.assessment.data.ifc.assessment.ItemMetaDataIfc;
import org.sakaiproject.tool.assessment.data.ifc.assessment.ItemTextAttachmentIfc;
import org.sakaiproject.tool.assessment.data.ifc.assessment.ItemTextIfc;
import org.sakaiproject.tool.assessment.data.ifc.shared.TypeIfc;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Field;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.Iterator;
import java.util.Set;
import java.util.stream.IntStream;

import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.Matchers.lessThan;
import static org.junit.Assert.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
 * Created by dmccallum on 11/3/16.
 */
public class ItemHashUtilTest {

    private ItemHashUtil itemHashUtil = new ItemHashUtil();

    @Mock
    private ContentHostingService contentHostingService;
    @Mock
    private ServerConfigurationService serverConfigurationService;
    private Field serverConfigurationServiceCache;
    private ServerConfigurationService originalServerConfigurationService;

    @Before
    public void setUp() throws NoSuchFieldException, IllegalAccessException {
        MockitoAnnotations.initMocks(this);
        injectDependencies();
        overrideServerConfigurationServiceCover();
    }

    @After
    public void tearDown() throws IllegalAccessException {
        restoreServerConfigurationServiceCover();
    }

    private void injectDependencies() throws NoSuchFieldException, IllegalAccessException {
        overrideServerConfigurationServiceCover();
        itemHashUtil.setContentHostingService(contentHostingService);
    }

    private void overrideServerConfigurationServiceCover() throws NoSuchFieldException, IllegalAccessException {
        serverConfigurationServiceCache = org.sakaiproject.component.cover.ServerConfigurationService.class
                .getDeclaredField("m_instance");
        serverConfigurationServiceCache.setAccessible(true);
        originalServerConfigurationService = (ServerConfigurationService) serverConfigurationServiceCache.get(null);
        serverConfigurationServiceCache.set(null, serverConfigurationService);
    }

    private void restoreServerConfigurationServiceCover() throws IllegalAccessException {
        serverConfigurationServiceCache.set(null, originalServerConfigurationService);
    }

    // Only need a small number of "top level" testHashItem*() checks on the final hash value b/c rest of the tests in this class
    // verify all the variations on how the hash *base* should be constructed. Do at least need to do enough to verify
    // that all hasebase functions are invoked, though.

    @Test
    public void testHashItemGeneratesSha256OfHashBase()
            throws IOException, NoSuchAlgorithmException, ServerOverloadException {

        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.FILL_IN_BLANK);

        item.setInstruction(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[0])));
        item.setCorrectItemFeedback(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[1])));
        item.setInCorrectItemFeedback(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[2])));
        item.setGeneralItemFeedback(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[3])));
        item.setDescription(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[4])));

        // just the "first two" metadata fields should be sufficient to prove metadata is being included
        final ItemMetaDataIfc metaData1 = newItemMetaData(item, ItemMetaDataIfc.RANDOMIZE, 11);
        final ItemMetaDataIfc metaData2 = newItemMetaData(item, ItemMetaDataIfc.REQUIRE_ALL_OK, 12);
        item.setItemMetaDataSet(Sets.newSet(metaData1, metaData2));

        final ItemAttachment attachment = new ItemAttachment(1L, item, idForContentResource(CONTENT_RESOURCES[5]),
                CONTENT_RESOURCES[5][CR_NAME_IDX], null, Long.MAX_VALUE - 1, null, null, null, null, null, null,
                null, null);
        item.setItemAttachmentSet(Sets.newSet(attachment));

        final Pair<Answer, String> answer = answerAndExpectedHashBaseFor(item, 1L, true, "Label 1",
                CONTENT_RESOURCES[6], CONTENT_RESOURCES[7], CONTENT_RESOURCES[8], CONTENT_RESOURCES[9]);
        final ItemText itemText = new ItemText(item, 1L,
                resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[10])),
                Sets.newSet(answerFrom(answer)));
        answerFrom(answer).setItemText(itemText);
        item.setItemTextSet(Sets.newSet(itemText));

        final ItemTag itemTag = new ItemTag(item, "tag1", "taglabel1", "tagcollection1", "tagcollectionname1");
        item.setItemTagSet(Sets.newSet(itemTag));

        expectServerUrlLookup();
        IntStream.rangeClosed(0, 12).forEach(i -> expectResourceLookupUnchecked(CONTENT_RESOURCES[i]));

        final StringBuilder expectedHashBase = new StringBuilder(labeled("TypeId", "" + TypeIfc.FILL_IN_BLANK))
                .append(labeled("ItemText",
                        renderBlanks(resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[10])))))
                .append(labeled("Instruction",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[0]))))
                .append(labeled("CorrectItemFeedback",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[1]))))
                .append(labeled("IncorrectItemFeedback",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[2]))))
                .append(labeled("GeneralCorrectItemFeedback",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[3]))))
                .append(labeled("Description",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[4]))))
                .append(expectedContentResourceHash1(CONTENT_RESOURCES[5])).append(stringFrom(answer))
                .append(labeled("RANDOMIZE",
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[11])))) // this specific MD field not actually treated as resource doc
                .append(labeled("REQUIRE_ALL_OK",
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[12])))); // this specific MD field not actually treated as resource doc

        // nulls for the other 14 "important" metadata keys
        //IntStream.rangeClosed(0, 13).forEach(
        //        i -> expectedHashBase.append("null")
        //);

        // convenient for debugging where the hash goes off the rails by looking at where the underlying "base" string goes off the rails...
        assertThat(itemHashUtil.hashBaseForItem(item).toString(), equalTo(expectedHashBase.toString()));

        final String expectedHash = sha256(bytes(expectedHashBase.toString()));
        final String actualHash1 = itemHashUtil.hashItem(item);
        final String actualHash2 = itemHashUtil.hashItem(item);
        assertThat(actualHash1, equalTo(expectedHash));
        assertThat("Hash is not stable", actualHash2, equalTo(expectedHash)); // believe it or not, this failed at one point (test bug w/r/t stream mgmt)
    }

    /**
     * Same as {@link #testHashItemGeneratesSha256OfHashBase()} but verifies that a slightly different hash base is
     * used if the item type is {@link TypeIfc#EXTENDED_MATCHING_ITEMS}
     */
    @Test
    public void testHashItemGeneratesSha256OfHashBaseForExtendedMatchingItem()
            throws IOException, NoSuchAlgorithmException, ServerOverloadException {
        final ItemData item = newExtendedMatchingItem();

        expectServerUrlLookup();
        IntStream.rangeClosed(0, 18).forEach(i -> expectResourceLookupUnchecked(CONTENT_RESOURCES[i]));

        ArrayList<String[]> contentResourceDefs1 = new ArrayList<>();
        contentResourceDefs1.add(CONTENT_RESOURCES[18]);
        contentResourceDefs1.add(CONTENT_RESOURCES[8]);

        ArrayList<String[]> contentResourceDefs2 = new ArrayList<>();
        contentResourceDefs2.add(CONTENT_RESOURCES[14]); // hash of first ItemTextAttachment contents in first "combination" ItemText
        contentResourceDefs2.add(CONTENT_RESOURCES[13]); // hash of second ItemTextAttachent contents in first "combination" ItemText

        ArrayList<String[]> contentResourceDefs3 = new ArrayList<>();
        contentResourceDefs3.add(CONTENT_RESOURCES[16]); // hash of first ItemTextAttachment contents in second "combination" ItemText
        contentResourceDefs3.add(CONTENT_RESOURCES[17]); // hash of second ItemTextAttachent contents in second "combination" ItemText

        final StringBuilder expectedHashBase = new StringBuilder(
                labeled("TypeId", "" + TypeIfc.EXTENDED_MATCHING_ITEMS))
                        .append(labeled("ThemeText",
                                resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[0])))) // themeText
                        .append(labeled("LeadInText",
                                resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[1])))) // leadInText
                        .append(labeled("CorrectItemFeedback",
                                resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[2])))) // correct feedback
                        .append(labeled("IncorrectItemFeedback",
                                resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[3])))) // incorrect feedback
                        .append(labeled("GeneralCorrectItemFeedback",
                                resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[4])))) // general feedback
                        .append(labeled("Description",
                                resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[5])))) // description
                        // 18 then 8 for same reason 13 and 14 are reversed below
                        .append(expectedContentResourceHashAttachments(contentResourceDefs1)) // first and second attachment
                        .append(labeled("EmiLabel",
                                resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[9])))) // first 'options' answer label
                        .append(labeled("EmiLabel",
                                resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[10])))) // second 'options' answer label
                        .append(labeled("EmiCorrectOptionLabels", "Answer Label 3Answer Label 5")) //correct Answer labels for first "combination" ItemText
                        .append(labeled("EmiSequence", "" + Long.MAX_VALUE)) // sequence value for first "combination" ItemText
                        .append(labeled("EmiText",
                                resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[12])))) // text for first "combination" ItemText
                        // 14 then 13 b/c itemattachments and itemtextattachments are sorted into the hash base by *their*
                        // hashes, rather than by, say, resourceId. as noted in ItemFacadeQueries.hashBaseForResourceIds() this
                        // a bit unfortunate but is really our only means to ensure a consistent sort order for attachments (at
                        // least assuming the hashing implementation itself remains fixed, which it has to be for the overall
                        // item hashing to be stable).
                        .append(expectedContentResourceHashAttachments(contentResourceDefs2))
                        .append(labeled("EmiCorrectOptionLabels", "Answer Label 6Answer Label 8")) //correct Answer labels for second "combination" ItemText
                        .append(labeled("EmiSequence", "" + Long.MAX_VALUE)) // sequence value for second "combination" ItemText
                        .append(labeled("EmiText",
                                resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[15])))) // text for second "combination" ItemText
                        .append(expectedContentResourceHashAttachments(contentResourceDefs3))
                        .append(labeled("RANDOMIZE",
                                resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[6])))) // this specific MD field not actually treated as resource doc
                        .append(labeled("REQUIRE_ALL_OK",
                                resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[7])))); // this specific MD field not actually treated as resource doc

        // nulls for the other 14 "important" metadata keys
        //IntStream.rangeClosed(0, 13).forEach(
        //        i -> expectedHashBase.append("null")
        //);

        final String expectedHash = sha256(bytes(expectedHashBase.toString()));

        // convenient for debugging where the hash goes off the rails by looking at where the underlying "base" string goes off the rails...
        assertThat(itemHashUtil.hashBaseForItem(item).toString(), equalTo(expectedHashBase.toString()));

        final String actualHash1 = itemHashUtil.hashItem(item);
        final String actualHash2 = itemHashUtil.hashItem(item);
        assertThat(actualHash1, equalTo(expectedHash));
        assertThat("Hash is not stable", actualHash2, equalTo(expectedHash)); // believe it or not, this failed at one point (test bug w/r/t stream mgmt)

    }

    @Test
    public void testHashBaseForItemCorePropertiesNormalizesResourceUrls() throws NoSuchAlgorithmException,
            IOException, ServerOverloadException, IdUnusedException, TypeException, PermissionException {
        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.FILL_IN_BLANK);
        final ItemText itemText = new ItemText(item, 1L,
                resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[0])), null);
        item.setItemTextSet(Sets.newSet(itemText));
        item.setInstruction(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[1])));
        item.setCorrectItemFeedback(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[2])));
        item.setInCorrectItemFeedback(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[3])));
        item.setGeneralItemFeedback(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[4])));
        item.setDescription(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[5])));

        expectServerUrlLookup();
        expectResourceLookup(CONTENT_RESOURCES[0]);
        expectResourceLookup(CONTENT_RESOURCES[1]);
        expectResourceLookup(CONTENT_RESOURCES[2]);
        expectResourceLookup(CONTENT_RESOURCES[3]);
        expectResourceLookup(CONTENT_RESOURCES[4]);
        expectResourceLookup(CONTENT_RESOURCES[5]);

        final StringBuilder expectedHashBase = new StringBuilder(labeled("TypeId", "" + TypeIfc.FILL_IN_BLANK))
                .append(labeled("ItemText",
                        renderBlanks(resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[0])))))
                .append(labeled("Instruction",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[1]))))
                .append(labeled("CorrectItemFeedback",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[2]))))
                .append(labeled("IncorrectItemFeedback",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[3]))))
                .append(labeled("GeneralCorrectItemFeedback",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[4]))))
                .append(labeled("Description",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[5]))));

        StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemCoreProperties(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    @Test
    public void testHashBaseForItemCorePropertiesPreservesNullsLiterally()
            throws NoSuchAlgorithmException, IOException, ServerOverloadException {
        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.FILL_IN_BLANK);

        // have to explicitly initialize this collection, else NPE in getText()
        final ItemText itemText = new ItemText(item, 1L, null, null);
        item.setItemTextSet(Sets.newSet(itemText));

        final StringBuilder expectedHashBase = new StringBuilder("TypeId:" + TypeIfc.FILL_IN_BLANK + "::")
                .append("ItemText:null::");
        //.append("null")
        //.append("null")
        //.append("null")
        //.append("null")
        //.append("null")

        final StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemCoreProperties(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    @Test
    public void testHashBaseForItemAnswersNormalizesEmbeddedResourceUrls() throws NoSuchAlgorithmException,
            IOException, ServerOverloadException, IdUnusedException, TypeException, PermissionException {

        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.FILL_IN_BLANK);

        final Pair<Answer, String> answer1 = answerAndExpectedHashBaseFor(item, 1L, true, "Label 1",
                CONTENT_RESOURCES[0], CONTENT_RESOURCES[1], CONTENT_RESOURCES[2], CONTENT_RESOURCES[3]);
        final Pair<Answer, String> answer2 = answerAndExpectedHashBaseFor(item, 2L, false, "Label 2",
                CONTENT_RESOURCES[4], CONTENT_RESOURCES[5], CONTENT_RESOURCES[6], CONTENT_RESOURCES[7]);
        final Pair<Answer, String> answer3 = answerAndExpectedHashBaseFor(item, 3L, true, "Label 3",
                CONTENT_RESOURCES[8], CONTENT_RESOURCES[9], CONTENT_RESOURCES[10], CONTENT_RESOURCES[11]);
        final Pair<Answer, String> answer4 = answerAndExpectedHashBaseFor(item, 4L, false, "Label 4",
                CONTENT_RESOURCES[12], CONTENT_RESOURCES[13], CONTENT_RESOURCES[14], CONTENT_RESOURCES[15]);

        final ItemText itemText1 = new ItemText(item, 1L, null,
                Sets.newSet(answerFrom(answer1), answerFrom(answer2)));
        answerFrom(answer1).setItemText(itemText1);
        answerFrom(answer2).setItemText(itemText1);

        final ItemText itemText2 = new ItemText(item, 2L, null,
                Sets.newSet(answerFrom(answer3), answerFrom(answer4)));
        answerFrom(answer3).setItemText(itemText2);
        answerFrom(answer4).setItemText(itemText2);

        item.setItemTextSet(Sets.newSet(itemText1, itemText2));

        final StringBuilder expectedHashBase = new StringBuilder().append(stringFrom(answer1))
                .append(stringFrom(answer2)).append(stringFrom(answer3)).append(stringFrom(answer4));

        expectServerUrlLookup();
        IntStream.rangeClosed(0, 15).forEach(i -> expectResourceLookupUnchecked(CONTENT_RESOURCES[i]));

        final StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemAnswers(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    @Test
    public void testHashBaseForItemAnswersPreservesNullsLiterally()
            throws IOException, NoSuchAlgorithmException, ServerOverloadException {
        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.FILL_IN_BLANK);

        // sequence, at least, is required, else ordering is completely non-deterministic
        final Pair<Answer, String> answer1 = answerAndExpectedHashBaseFor(item, 1L, null, null, null, null, null,
                null);
        final Pair<Answer, String> answer2 = answerAndExpectedHashBaseFor(item, 2L, null, null, null, null, null,
                null);

        final ItemText itemText1 = new ItemText(item, 1L, null,
                Sets.newSet(answerFrom(answer1), answerFrom(answer2)));
        answerFrom(answer1).setItemText(itemText1);
        answerFrom(answer2).setItemText(itemText1);

        item.setItemTextSet(Sets.newSet(itemText1));

        final StringBuilder expectedHashBase = new StringBuilder().append(stringFrom(answer1))
                .append(stringFrom(answer2));

        final StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemAnswers(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    // Generate the Answer and expected hash bas in one function b/c we need the full *ContentResource arrays to
    // predict the expected hash base and there's just so many of them, a call to a dedicated hash base predictor
    // function is just too much duplication
    private ImmutablePair<Answer, String> answerAndExpectedHashBaseFor(ItemDataIfc item, long sequence,
            Boolean isCorrect, String label, String[] textContentResource, String[] correctFeedbackContentResource,
            String[] incorrectFeedbackContentResource, String[] generalFeedbackContentResource)
            throws UnsupportedEncodingException, NoSuchAlgorithmException {

        final Answer answer = new Answer();
        answer.setItem(item);
        answer.setSequence(sequence);
        answer.setIsCorrect(isCorrect);
        answer.setLabel(label);
        answer.setText(resourceDocTemplate1(fullUrlForContentResource(textContentResource)));
        final AnswerFeedback correctAnswerFeedback = new AnswerFeedback(answer, AnswerFeedbackIfc.CORRECT_FEEDBACK,
                resourceDocTemplate1(fullUrlForContentResource(correctFeedbackContentResource)));
        final AnswerFeedback incorrectAnswerFeedback = new AnswerFeedback(answer,
                AnswerFeedbackIfc.INCORRECT_FEEDBACK,
                resourceDocTemplate1(fullUrlForContentResource(incorrectFeedbackContentResource)));
        final AnswerFeedback generalAnswerFeedback = new AnswerFeedback(answer, AnswerFeedbackIfc.GENERAL_FEEDBACK,
                resourceDocTemplate1(fullUrlForContentResource(generalFeedbackContentResource)));
        answer.setAnswerFeedbackSet(
                Sets.newSet(correctAnswerFeedback, incorrectAnswerFeedback, generalAnswerFeedback));
        String getIsCorrect = "" + isCorrect;
        if (getIsCorrect.equals("null")) {
            getIsCorrect = null;
        }
        final StringBuilder expectedHashBase = new StringBuilder()
                .append(labeled("ItemTextAnswer",
                        resourceDocTemplate1(expectedContentResourceHash1(textContentResource))))
                .append(labeled("CorrectAnswerFeedback",
                        resourceDocTemplate1(expectedContentResourceHash1(correctFeedbackContentResource))))
                .append(labeled("InCorrectAnswerFeedback",
                        resourceDocTemplate1(expectedContentResourceHash1(incorrectFeedbackContentResource))))
                .append(labeled("GeneralAnswerFeedback",
                        resourceDocTemplate1(expectedContentResourceHash1(generalFeedbackContentResource))))
                .append(labeled("AnswerSequence", "" + sequence)).append(labeled("AnswerLabel", label))
                .append(labeled("AnswerIsCorrect", getIsCorrect));

        return new ImmutablePair<>(answer, expectedHashBase.toString());
    }

    private <R> Answer answerFrom(Pair<Answer, R> pair) {
        return pair.getLeft();
    }

    private <L> String stringFrom(Pair<L, String> pair) {
        return pair.getRight();
    }

    @Test
    public void testHashBaseForItemAnswersIncludesSimpleAnswerOptionsForExtendedMatchingItems()
            throws IOException, NoSuchAlgorithmException, ServerOverloadException {
        final ItemData item = newExtendedMatchingItem();

        expectServerUrlLookup();
        IntStream.rangeClosed(0, 18).forEach(i -> expectResourceLookupUnchecked(CONTENT_RESOURCES[i]));

        ArrayList<String[]> contentResourceDefs1 = new ArrayList<>();
        contentResourceDefs1.add(CONTENT_RESOURCES[14]); // hash of first ItemTextAttachment contents in first "combination" ItemText
        contentResourceDefs1.add(CONTENT_RESOURCES[13]); // hash of second ItemTextAttachent contents in first "combination" ItemText

        ArrayList<String[]> contentResourceDefs2 = new ArrayList<>();
        contentResourceDefs2.add(CONTENT_RESOURCES[16]); // hash of first ItemTextAttachent contents in second "combination" ItemText
        contentResourceDefs2.add(CONTENT_RESOURCES[17]); // hash of second ItemTextAttachent contents in second "combination" ItemText

        final StringBuilder expectedHashBase = new StringBuilder()
                .append(labeled("EmiLabel",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[9])))) // first 'options' Answer label
                .append(labeled("EmiLabel",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[10])))) // second 'options' Answer label
                .append(labeled("EmiCorrectOptionLabels", "Answer Label 3Answer Label 5")) //correct Answer labels for first "combination" ItemText
                .append(labeled("EmiSequence", "" + Long.MAX_VALUE)) // sequence value for first "combination" ItemText
                .append(labeled("EmiText",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[12])))) // text for first "combination" ItemText
                // 14 then 13 b/c itemattachments and itemtextattachments are sorted into the hash base by *their*
                // hashes, rather than by, say, resourceId. as noted in ItemFacadeQueries.hashBaseForResourceIds() this
                // a bit unfortunate but is really our only means to ensure a consistent sort order for attachments (at
                // least assuming the hashing implementation itself remains fixed, which it has to be for the overall
                // item hashing to be stable).
                .append(expectedContentResourceHashAttachments(contentResourceDefs1))
                .append(labeled("EmiCorrectOptionLabels", "Answer Label 6Answer Label 8")) //correct Answer labels for second "combination" ItemText
                .append(labeled("EmiSequence", "" + Long.MAX_VALUE)) // sequence value for second "combination" ItemText
                .append(labeled("EmiText",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[15])))) // text for second "combination" ItemText
                .append(expectedContentResourceHashAttachments(contentResourceDefs2));

        StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemAnswers(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    @Test
    public void testHashBaseForItemAnswersIncludesAnswerOptionsRichTextInsteadOfAnswerOptionLabelsForExtendedMatchingItems()
            throws IOException, NoSuchAlgorithmException, ServerOverloadException {
        final ItemData item = newExtendedMatchingItem();

        // the key distinguisher between this test and
        // testHashBaseForItemAnswersIncludesSimpleAnswerOptionsForExtendedMatchingItems()
        item.setAnswerOptionsSimpleOrRich(ItemDataIfc.ANSWER_OPTIONS_RICH);

        expectServerUrlLookup();
        IntStream.rangeClosed(0, 18).forEach(i -> expectResourceLookupUnchecked(CONTENT_RESOURCES[i]));

        ArrayList<String[]> contentResourceDefs1 = new ArrayList<>();
        contentResourceDefs1.add(CONTENT_RESOURCES[14]); // hash of first ItemTextAttachment contents in first "combination" ItemText
        contentResourceDefs1.add(CONTENT_RESOURCES[13]); // hash of second ItemTextAttachent contents in first "combination" ItemText

        ArrayList<String[]> contentResourceDefs2 = new ArrayList<>();
        contentResourceDefs2.add(CONTENT_RESOURCES[16]); // hash of first ItemTextAttachent contents in second "combination" ItemText
        contentResourceDefs2.add(CONTENT_RESOURCES[17]); // hash of second ItemTextAttachent contents in second "combination" ItemText

        final StringBuilder expectedHashBase = new StringBuilder()
                .append(labeled("EmiAnswerOptionsRichText",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[11])))) // rich text
                .append(labeled("EmiCorrectOptionLabels", "Answer Label 3Answer Label 5")) //correct Answer labels for first "combination" ItemText
                .append(labeled("EmiSequence", "" + Long.MAX_VALUE)) // sequence value for first "combination" ItemText
                .append(labeled("EmiText",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[12])))) // text for first "combination" ItemText
                // 14 then 13 b/c itemattachments and itemtextattachments are sorted into the hash base by *their*
                // hashes, rather than by, say, resourceId. as noted in ItemFacadeQueries.hashBaseForResourceIds() this
                // a bit unfortunate but is really our only means to ensure a consistent sort order for attachments (at
                // least assuming the hashing implementation itself remains fixed, which it has to be for the overall
                // item hashing to be stable).
                .append(expectedContentResourceHashAttachments(contentResourceDefs1))
                .append(labeled("EmiCorrectOptionLabels", "Answer Label 6Answer Label 8")) //correct Answer labels for second "combination" ItemText
                .append(labeled("EmiSequence", "" + Long.MAX_VALUE)) // sequence value for second "combination" ItemText
                .append(labeled("EmiText",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[15])))) // text for second "combination" ItemText
                .append(expectedContentResourceHashAttachments(contentResourceDefs2));

        StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemAnswers(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    @Test
    public void testHashBaseForItemAnswersSkipsMissingResourcesForSimpleExtendedMatchingItems()
            throws IdUnusedException, PermissionException, IOException, TypeException, ServerOverloadException,
            NoSuchAlgorithmException {
        final ItemData item = newExtendedMatchingItem();

        expectServerUrlLookup();

        failResourceLookup(CONTENT_RESOURCES[9]); //first options answer label
        expectResourceLookupUnchecked(CONTENT_RESOURCES[10]); //second options answer label

        failResourceLookup(CONTENT_RESOURCES[12]); // first answer combo item text text
        failResourceLookup(CONTENT_RESOURCES[13]); // first ItemTextAttachment contents in first "combination" ItemText
        expectResourceLookupUnchecked(CONTENT_RESOURCES[14]); // second ItemTextAttachment contents in first "combination" ItemText

        expectResourceLookupUnchecked(CONTENT_RESOURCES[15]); // second answer combo item text text
        failResourceLookup(CONTENT_RESOURCES[16]); // first ItemTextAttachment contents in second "combination" ItemText
        expectResourceLookupUnchecked(CONTENT_RESOURCES[17]); // second ItemTextAttachment contents in second "combination" ItemText

        final StringBuilder expectedHashBase = new StringBuilder()
                .append(labeled("EmiLabel", resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[9])))) // first 'options' Answer label
                .append(labeled("EmiLabel",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[10])))) // second 'options' Answer label
                .append(labeled("EmiCorrectOptionLabels", "Answer Label 3Answer Label 5")) //correct Answer labels for first "combination" ItemText
                .append(labeled("EmiSequence", "" + Long.MAX_VALUE)) // sequence value for first "combination" ItemText
                .append(labeled("EmiText", resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[12])))) // text for first "combination" ItemText
                .append(expectedContentResourceHash1(CONTENT_RESOURCES[14])) // hash of second ItemTextAttachment contents in first "combination" ItemText
                .append(labeled("EmiCorrectOptionLabels", "Answer Label 6Answer Label 8")) //correct Answer labels for second "combination" ItemText
                .append(labeled("EmiSequence", "" + Long.MAX_VALUE)) // sequence value for second "combination" ItemText
                .append(labeled("EmiText",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[15])))) // text for second "combination" ItemText
                .append(expectedContentResourceHash1(CONTENT_RESOURCES[17])); // hash of second ItemTextAttachent contents in second "combination" ItemText

        StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemAnswers(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    @Test
    public void testHashBaseForItemAnswersSkipsMissingResourcesForRichExtendedMatchingItems()
            throws IdUnusedException, PermissionException, IOException, TypeException, ServerOverloadException,
            NoSuchAlgorithmException {
        // same as testHashBaseForItemAnswersSkipsMissingResourcesForSimpleExtendedMatchingItems() but has one
        // additional field to check
        final ItemData item = newExtendedMatchingItem();

        // the key distinguisher between this test and
        // testHashBaseForItemAnswersSkipsMissingResourcesForSimpleExtendedMatchingItems()
        item.setAnswerOptionsSimpleOrRich(ItemDataIfc.ANSWER_OPTIONS_RICH);

        expectServerUrlLookup();

        failResourceLookup(CONTENT_RESOURCES[11]); // rich text

        failResourceLookup(CONTENT_RESOURCES[12]); // first answer combo item text text
        failResourceLookup(CONTENT_RESOURCES[13]); // first ItemTextAttachment contents in first "combination" ItemText
        expectResourceLookupUnchecked(CONTENT_RESOURCES[14]); // second ItemTextAttachment contents in first "combination" ItemText

        expectResourceLookupUnchecked(CONTENT_RESOURCES[15]); // second answer combo item text text
        failResourceLookup(CONTENT_RESOURCES[16]); // first ItemTextAttachment contents in second "combination" ItemText
        expectResourceLookupUnchecked(CONTENT_RESOURCES[17]); // second ItemTextAttachment contents in second "combination" ItemText

        final StringBuilder expectedHashBase = new StringBuilder()
                .append(labeled("EmiAnswerOptionsRichText",
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[11])))) // rich text
                .append(labeled("EmiCorrectOptionLabels", "Answer Label 3Answer Label 5")) //correct Answer labels for first "combination" ItemText
                .append(labeled("EmiSequence", "" + Long.MAX_VALUE)) // sequence value for first "combination" ItemText
                .append(labeled("EmiText", resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[12])))) // text for first "combination" ItemText
                .append(expectedContentResourceHash1(CONTENT_RESOURCES[14])) // hash of second ItemTextAttachment contents in first "combination" ItemText
                .append(labeled("EmiCorrectOptionLabels", "Answer Label 6Answer Label 8")) //correct Answer labels for second "combination" ItemText
                .append(labeled("EmiSequence", "" + Long.MAX_VALUE)) // sequence value for second "combination" ItemText
                .append(labeled("EmiText",
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[15])))) // text for second "combination" ItemText
                .append(expectedContentResourceHash1(CONTENT_RESOURCES[17])); // hash of second ItemTextAttachent contents in second "combination" ItemText

        StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemAnswers(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    // setting up EMI items is such an elaborate process, we just give up and create an elaborate factory
    // method for those things. means test methods know more about what goes on in this process than
    // they really should, but without this method, code duplication becomes an even more serious problem
    private ItemData newExtendedMatchingItem() {
        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.EXTENDED_MATCHING_ITEMS);

        // the ItemTextIfc.EMI_ANSWER_OPTIONS_SEQUENCE makes ItemText behave specially for EMI items
        // (for "simple" answer options, anyway). In particular, it results in the answer *label* being treated as
        // a resource document
        final Set<ItemTextIfc> itemTextSet = Sets.newSet();
        final ItemText themeText = new ItemText(item, ItemTextIfc.EMI_THEME_TEXT_SEQUENCE,
                resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[0])), Sets.newSet());
        final ItemText leadInText = new ItemText(item, ItemTextIfc.EMI_LEAD_IN_TEXT_SEQUENCE,
                resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[1])), Sets.newSet());
        itemTextSet.add(themeText);
        itemTextSet.add(leadInText);

        // no instruction property for EMI
        item.setCorrectItemFeedback(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[2])));
        item.setInCorrectItemFeedback(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[3])));
        item.setGeneralItemFeedback(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[4])));
        item.setDescription(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[5])));

        // just the "first two" metadata fields should be sufficient to prove metadata is being included
        final ItemMetaDataIfc metaData1 = newItemMetaData(item, ItemMetaDataIfc.RANDOMIZE, 6);
        final ItemMetaDataIfc metaData2 = newItemMetaData(item, ItemMetaDataIfc.REQUIRE_ALL_OK, 7);
        item.setItemMetaDataSet(Sets.newSet(metaData1, metaData2));

        final ItemAttachment attachment1 = new ItemAttachment(1L, item, idForContentResource(CONTENT_RESOURCES[8]),
                CONTENT_RESOURCES[8][CR_NAME_IDX], null, Long.MAX_VALUE - 1, null, null, null, null, null, null,
                null, null);
        final ItemAttachment attachment2 = new ItemAttachment(2L, item, idForContentResource(CONTENT_RESOURCES[18]),
                CONTENT_RESOURCES[18][CR_NAME_IDX], null, Long.MAX_VALUE - 1, null, null, null, null, null, null,
                null, null);
        item.setItemAttachmentSet(Sets.newSet(attachment1, attachment2));

        // then we need to have "EMI question answer combinations". Each "combination" is implemented as any ItemText
        // having a sequence property >= zero.
        final Answer answer1 = new Answer();
        answer1.setItem(item);
        answer1.setSequence(1L);
        answer1.setIsCorrect(true);
        answer1.setLabel(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[9])));
        answer1.setText("Answer Text 1"); // completely ignored, actually

        final Answer answer2 = new Answer();
        answer2.setItem(item);
        answer2.setSequence(2L);
        answer2.setIsCorrect(true);
        answer2.setLabel(resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[10])));
        answer2.setText("Answer Text 2"); // completely ignored, actually

        final ItemText answerOptions = new ItemText(item, ItemTextIfc.EMI_ANSWER_OPTIONS_SEQUENCE,
                resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[11])),
                Sets.newSet(answer1, answer2));
        answer1.setItemText(answerOptions);
        answer2.setItemText(answerOptions);
        itemTextSet.add(answerOptions);

        // now more ItemTexts and Answers to act as answer *combinations*, as distinct from answer *options*. there are
        // mulitple combinations, but only one options. an ItemText is a combination if the question type is EMI and
        // the ItemText sequence number is greater than zero (we use Long.MAX_VALUE here)
        final Answer answer3 = new Answer();
        answer3.setItem(item);
        answer3.setSequence(3L);
        answer3.setIsCorrect(true); // should be included in hash
        answer3.setLabel("Answer Label 3"); // not interpreted as a resource doc
        answer3.setText("Answer Text 3"); // completely ignored, actually
        final Answer answer4 = new Answer();
        answer4.setItem(item);
        answer4.setSequence(4L);
        answer4.setIsCorrect(false); // should be excluded from hash
        answer4.setLabel("Answer Label 4"); // not interpreted as a resource doc
        answer4.setText("Answer Text 4"); // completely ignored, actually
        final Answer answer5 = new Answer();
        answer5.setItem(item);
        answer5.setSequence(5L);
        answer5.setIsCorrect(true); // should be included in hash
        answer5.setLabel("Answer Label 5"); // not interpreted as a resource doc
        answer5.setText("Answer Text 5"); // completely ignored, actually

        final ItemText answerCombination1 = new ItemText(item, Long.MAX_VALUE,
                resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[12])),
                Sets.newSet(answer3, answer4, answer5));
        answer3.setItemText(answerCombination1);
        answer4.setItemText(answerCombination1);
        answer5.setItemText(answerCombination1);

        final ItemTextAttachmentIfc itemTextAttachment1 = new ItemTextAttachment(1L, answerOptions,
                idForContentResource(CONTENT_RESOURCES[13]), CONTENT_RESOURCES[13][CR_NAME_IDX], "text/text", 1024L,
                "Item Text Attachment Description 1", "Location 1", false, AttachmentIfc.ACTIVE_STATUS, "admin",
                new Date(), "admin", new Date());
        final ItemTextAttachmentIfc itemTextAttachment2 = new ItemTextAttachment(2L, answerOptions,
                idForContentResource(CONTENT_RESOURCES[14]), CONTENT_RESOURCES[14][CR_NAME_IDX], "text/text", 1024L,
                "Item Text Attachment Description 2", "Location 2", false, AttachmentIfc.ACTIVE_STATUS, "admin",
                new Date(), "admin", new Date());
        final Set<ItemTextAttachmentIfc> itemTextAttachmentSet1 = Sets.newSet(itemTextAttachment1,
                itemTextAttachment2);
        answerCombination1.setItemTextAttachmentSet(itemTextAttachmentSet1);
        itemTextSet.add(answerCombination1);

        final Answer answer6 = new Answer();
        answer6.setItem(item);
        answer6.setSequence(6L);
        answer6.setIsCorrect(true); // should be included in hash
        answer6.setLabel("Answer Label 6"); // not interpreted as a resource doc
        answer6.setText("Answer Text 6"); // completely ignored, actually
        final Answer answer7 = new Answer();
        answer7.setItem(item);
        answer7.setSequence(7L);
        answer7.setIsCorrect(false); // should be excluded from hash
        answer7.setLabel("Answer Label 7"); // not interpreted as a resource doc
        answer7.setText("Answer Text 7"); // completely ignored, actually
        final Answer answer8 = new Answer();
        answer8.setItem(item);
        answer8.setSequence(8L);
        answer8.setIsCorrect(true); // should be included in hash
        answer8.setLabel("Answer Label 8"); // not interpreted as a resource doc
        answer8.setText("Answer Text 8"); // completely ignored, actually

        final ItemText answerCombination2 = new ItemText(item, Long.MAX_VALUE,
                resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[15])),
                Sets.newSet(answer6, answer7, answer8));
        answer6.setItemText(answerCombination1);
        answer7.setItemText(answerCombination1);
        answer8.setItemText(answerCombination1);

        final ItemTextAttachmentIfc itemTextAttachment3 = new ItemTextAttachment(3L, answerOptions,
                idForContentResource(CONTENT_RESOURCES[16]), CONTENT_RESOURCES[16][CR_NAME_IDX], "text/text", 1024L,
                "Item Text Attachment Description 3", "Location 3", false, AttachmentIfc.ACTIVE_STATUS, "admin",
                new Date(), "admin", new Date());
        final ItemTextAttachmentIfc itemTextAttachment4 = new ItemTextAttachment(4L, answerOptions,
                idForContentResource(CONTENT_RESOURCES[17]), CONTENT_RESOURCES[17][CR_NAME_IDX], "text/text", 1024L,
                "Item Text Attachment Description 4", "Location 4", false, AttachmentIfc.ACTIVE_STATUS, "admin",
                new Date(), "admin", new Date());
        final Set<ItemTextAttachmentIfc> itemTextAttachmentSet2 = Sets.newSet(itemTextAttachment3,
                itemTextAttachment4);
        answerCombination2.setItemTextAttachmentSet(itemTextAttachmentSet2);
        itemTextSet.add(answerCombination2);

        item.setItemTextSet(itemTextSet);

        final ItemTag itemTag = new ItemTag(item, "tag1", "taglabel1", "tagcollection1", "tagcollectionname1");
        item.setItemTagSet(Sets.newSet(itemTag));

        return item;
    }

    @Test
    public void testHashBaseForItemAttachments() throws IOException, NoSuchAlgorithmException, IdUnusedException,
            PermissionException, TypeException, ServerOverloadException {
        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.FILL_IN_BLANK);

        final ItemAttachment attachment1 = new ItemAttachment(1L, item, idForContentResource(CONTENT_RESOURCES[0]),
                CONTENT_RESOURCES[0][CR_NAME_IDX], null, Long.MAX_VALUE - 1, null, null, null, null, null, null,
                null, null);
        final ItemAttachment attachment2 = new ItemAttachment(2L, item, idForContentResource(CONTENT_RESOURCES[1]),
                CONTENT_RESOURCES[1][CR_NAME_IDX], null, Long.MAX_VALUE, null, null, null, null, null, null, null,
                null);

        // this assertion and the intentional backward add to the attachment set are *mild* attempt at ensuring
        // they're sorted in lexical order before being added to the hash base
        assertThat(attachment1.getResourceId(), lessThan(attachment2.getResourceId()));
        item.setItemAttachmentSet(Sets.newSet(attachment1, attachment2));

        // for a straight-up attachment (as opposed to an inlined HTML doc containing question or answer text),
        // we hash the attachment contents, not a doc referring to the attachment.
        ArrayList<String[]> contentResourceDefs1 = new ArrayList<>();
        contentResourceDefs1.add(CONTENT_RESOURCES[0]);
        contentResourceDefs1.add(CONTENT_RESOURCES[1]);

        final StringBuilder expectedHashBase = new StringBuilder()
                .append(expectedContentResourceHashAttachments(contentResourceDefs1));

        expectServerUrlLookup();
        expectResourceLookup(CONTENT_RESOURCES[0]);
        expectResourceLookup(CONTENT_RESOURCES[1]);

        final StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemAttachments(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    // Little bit of paranoia on this one b/c we want to verify that a single missing resource doesn't result
    // in an edge case where we return literally null or the string "null" as the hash base for the item. we
    // want the empty string in that case since the intention is for the resource to simply be elided from
    // the hash base.
    @Test
    public void testHashBaseForItemAttachmentsSkipSingleMissingResources() throws IOException, IdUnusedException,
            PermissionException, TypeException, ServerOverloadException, NoSuchAlgorithmException {
        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.FILL_IN_BLANK);

        final ItemAttachment attachment1 = new ItemAttachment(1L, item, idForContentResource(CONTENT_RESOURCES[0]),
                CONTENT_RESOURCES[0][CR_NAME_IDX], null, Long.MAX_VALUE - 1, null, null, null, null, null, null,
                null, null);
        item.setItemAttachmentSet(Sets.newSet(attachment1));

        expectServerUrlLookup();
        failResourceLookup(CONTENT_RESOURCES[0]);

        final StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemAttachments(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(""));
    }

    @Test
    public void testHashBaseForItemAttachmentsSkipsMissingResources() throws IOException, IdUnusedException,
            PermissionException, TypeException, ServerOverloadException, NoSuchAlgorithmException {
        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.FILL_IN_BLANK);

        final ItemAttachment attachment1 = new ItemAttachment(1L, item, idForContentResource(CONTENT_RESOURCES[0]),
                CONTENT_RESOURCES[0][CR_NAME_IDX], null, Long.MAX_VALUE - 1, null, null, null, null, null, null,
                null, null);
        final ItemAttachment attachment2 = new ItemAttachment(2L, item, idForContentResource(CONTENT_RESOURCES[1]),
                CONTENT_RESOURCES[1][CR_NAME_IDX], null, Long.MAX_VALUE, null, null, null, null, null, null, null,
                null);
        item.setItemAttachmentSet(Sets.newSet(attachment1, attachment2));

        // for a straight-up attachment (as opposed to an inlined HTML doc containing question or answer text),
        // we hash the attachment contents, not a doc referring to the attachment.
        final StringBuilder expectedHashBase = new StringBuilder()
                .append(expectedContentResourceHash1(CONTENT_RESOURCES[0]));

        expectServerUrlLookup();
        expectResourceLookup(CONTENT_RESOURCES[0]);
        failResourceLookup(CONTENT_RESOURCES[1]);

        final StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemAttachments(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    @Test
    public void testHashBaseForItemMetadata()
            throws IOException, NoSuchAlgorithmException, ServerOverloadException {
        // use a html doc for each (except for IMAGE_MAP_SRC which is an extra special case), even tho code only
        // assumes a small subset are actually html docs. easy way of verifying whether or not individual properties
        // are or are not routed through resource hashing routines.

        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.FILL_IN_BLANK);
        final ItemMetaDataIfc metaData1 = newItemMetaData(item, ItemMetaDataIfc.RANDOMIZE, 0);
        final ItemMetaDataIfc metaData2 = newItemMetaData(item, ItemMetaDataIfc.REQUIRE_ALL_OK, 1);
        final ItemMetaDataIfc metaData3 = new ItemMetaData(item, ItemMetaDataIfc.IMAGE_MAP_SRC,
                serverRelativeUrlForContentResource(CONTENT_RESOURCES[2]));
        final ItemMetaDataIfc metaData4 = newItemMetaData(item, ItemMetaDataIfc.CASE_SENSITIVE_FOR_FIB, 3);
        final ItemMetaDataIfc metaData5 = newItemMetaData(item, ItemMetaDataIfc.MUTUALLY_EXCLUSIVE_FOR_FIB, 4);
        final ItemMetaDataIfc metaData6 = newItemMetaData(item, ItemMetaDataIfc.IGNORE_SPACES_FOR_FIB, 5);
        final ItemMetaDataIfc metaData7 = newItemMetaData(item, ItemMetaDataIfc.MCMS_PARTIAL_CREDIT, 6);
        final ItemMetaDataIfc metaData8 = newItemMetaData(item, ItemMetaDataIfc.FORCE_RANKING, 7);
        final ItemMetaDataIfc metaData9 = newItemMetaData(item, ItemMetaDataIfc.MX_SURVEY_RELATIVE_WIDTH, 8);
        final ItemMetaDataIfc metaData10 = newItemMetaData(item, ItemMetaDataIfc.ADD_COMMENT_MATRIX, 9);
        final ItemMetaDataIfc metaData11 = newItemMetaData(item, ItemMetaDataIfc.MX_SURVEY_QUESTION_COMMENTFIELD,
                10);
        final ItemMetaDataIfc metaData12 = newItemMetaData(item, ItemMetaDataIfc.PREDEFINED_SCALE, 11);
        final ItemMetaDataIfc metaData13 = newItemMetaData(item, ItemMetaDataIfc.TIMEALLOWED, 12);
        final ItemMetaDataIfc metaData14 = newItemMetaData(item, ItemMetaDataIfc.NUMATTEMPTS, 13);
        final ItemMetaDataIfc metaData15 = newItemMetaData(item, ItemMetaDataIfc.SCALENAME, 14);
        final ItemMetaDataIfc metaData16 = newItemMetaData(item, ItemMetaDataIfc.ADD_TO_FAVORITES_MATRIX, 15);
        final ItemMetaDataIfc metaData17 = newItemMetaData(item, ItemMetaDataIfc.IMAGE_MAP_ALT_TEXT, 16);
        item.setItemMetaDataSet(Sets.newSet(metaData1, metaData2, metaData3, metaData4, metaData5, metaData6,
                metaData7, metaData8, metaData9, metaData10, metaData11, metaData12, metaData12, metaData13,
                metaData14, metaData15, metaData16, metaData17));

        expectServerUrlLookup();
        IntStream.rangeClosed(0, 15).forEach(i -> expectResourceLookupUnchecked(CONTENT_RESOURCES[i]));

        final StringBuilder expectedHashBase = new StringBuilder()
                .append(labeled(ItemMetaDataIfc.RANDOMIZE,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[0]))))
                .append(labeled(ItemMetaDataIfc.REQUIRE_ALL_OK,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[1]))))
                .append(labeled(ItemMetaDataIfc.IMAGE_MAP_SRC, expectedContentResourceHash1(CONTENT_RESOURCES[2])))
                .append(labeled(ItemMetaDataIfc.CASE_SENSITIVE_FOR_FIB,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[3]))))
                .append(labeled(ItemMetaDataIfc.MUTUALLY_EXCLUSIVE_FOR_FIB,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[4]))))
                .append(labeled(ItemMetaDataIfc.IGNORE_SPACES_FOR_FIB,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[5]))))
                .append(labeled(ItemMetaDataIfc.MCMS_PARTIAL_CREDIT,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[6]))))
                .append(labeled(ItemMetaDataIfc.FORCE_RANKING,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[7]))))
                .append(labeled(ItemMetaDataIfc.MX_SURVEY_RELATIVE_WIDTH,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[8]))))
                .append(labeled(ItemMetaDataIfc.ADD_COMMENT_MATRIX,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[9]))))
                .append(labeled(ItemMetaDataIfc.MX_SURVEY_QUESTION_COMMENTFIELD,
                        resourceDocTemplate1(expectedContentResourceHash1(CONTENT_RESOURCES[10]))))
                .append(labeled(ItemMetaDataIfc.PREDEFINED_SCALE,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[11]))))
                .append(labeled(ItemMetaDataIfc.TIMEALLOWED,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[12]))))
                .append(labeled(ItemMetaDataIfc.NUMATTEMPTS,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[13]))))
                .append(labeled(ItemMetaDataIfc.SCALENAME,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[14]))))
                .append(labeled(ItemMetaDataIfc.ADD_TO_FAVORITES_MATRIX,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[15]))))
                .append(labeled(ItemMetaDataIfc.IMAGE_MAP_ALT_TEXT,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[16]))));

        final StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemMetadata(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));

    }

    @Test
    public void testHashBaseForItemMetadataSkipsMissingResources()
            throws NoSuchAlgorithmException, IOException, ServerOverloadException {
        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.FILL_IN_BLANK);
        final ItemMetaDataIfc metaData1 = newItemMetaData(item, ItemMetaDataIfc.RANDOMIZE, 0);
        final ItemMetaDataIfc metaData2 = newItemMetaData(item, ItemMetaDataIfc.REQUIRE_ALL_OK, 1);
        final ItemMetaDataIfc metaData3 = new ItemMetaData(item, ItemMetaDataIfc.IMAGE_MAP_SRC,
                serverRelativeUrlForContentResource(CONTENT_RESOURCES[2]));
        final ItemMetaDataIfc metaData4 = newItemMetaData(item, ItemMetaDataIfc.CASE_SENSITIVE_FOR_FIB, 3);
        final ItemMetaDataIfc metaData5 = newItemMetaData(item, ItemMetaDataIfc.MUTUALLY_EXCLUSIVE_FOR_FIB, 4);
        final ItemMetaDataIfc metaData6 = newItemMetaData(item, ItemMetaDataIfc.IGNORE_SPACES_FOR_FIB, 5);
        final ItemMetaDataIfc metaData7 = newItemMetaData(item, ItemMetaDataIfc.MCMS_PARTIAL_CREDIT, 6);
        final ItemMetaDataIfc metaData8 = newItemMetaData(item, ItemMetaDataIfc.FORCE_RANKING, 7);
        final ItemMetaDataIfc metaData9 = newItemMetaData(item, ItemMetaDataIfc.MX_SURVEY_RELATIVE_WIDTH, 8);
        final ItemMetaDataIfc metaData10 = newItemMetaData(item, ItemMetaDataIfc.ADD_COMMENT_MATRIX, 9);
        final ItemMetaDataIfc metaData11 = newItemMetaData(item, ItemMetaDataIfc.MX_SURVEY_QUESTION_COMMENTFIELD,
                10);
        final ItemMetaDataIfc metaData12 = newItemMetaData(item, ItemMetaDataIfc.PREDEFINED_SCALE, 11);
        final ItemMetaDataIfc metaData13 = newItemMetaData(item, ItemMetaDataIfc.TIMEALLOWED, 12);
        final ItemMetaDataIfc metaData14 = newItemMetaData(item, ItemMetaDataIfc.NUMATTEMPTS, 13);
        final ItemMetaDataIfc metaData15 = newItemMetaData(item, ItemMetaDataIfc.SCALENAME, 14);
        final ItemMetaDataIfc metaData16 = newItemMetaData(item, ItemMetaDataIfc.ADD_TO_FAVORITES_MATRIX, 15);
        item.setItemMetaDataSet(Sets.newSet(metaData1, metaData2, metaData3, metaData4, metaData5, metaData6,
                metaData7, metaData8, metaData9, metaData10, metaData11, metaData12, metaData12, metaData13,
                metaData14, metaData15, metaData16));

        expectServerUrlLookup();
        IntStream.rangeClosed(0, 15).forEach(i -> failResourceLookupUnchecked(CONTENT_RESOURCES[i]));

        final StringBuilder expectedHashBase = new StringBuilder()
                .append(labeled(ItemMetaDataIfc.RANDOMIZE,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[0]))))
                .append(labeled(ItemMetaDataIfc.REQUIRE_ALL_OK,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[1]))))
                .append(labeled(ItemMetaDataIfc.IMAGE_MAP_SRC,
                        serverRelativeUrlForContentResource(CONTENT_RESOURCES[2])))
                .append(labeled(ItemMetaDataIfc.CASE_SENSITIVE_FOR_FIB,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[3]))))
                .append(labeled(ItemMetaDataIfc.MUTUALLY_EXCLUSIVE_FOR_FIB,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[4]))))
                .append(labeled(ItemMetaDataIfc.IGNORE_SPACES_FOR_FIB,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[5]))))
                .append(labeled(ItemMetaDataIfc.MCMS_PARTIAL_CREDIT,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[6]))))
                .append(labeled(ItemMetaDataIfc.FORCE_RANKING,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[7]))))
                .append(labeled(ItemMetaDataIfc.MX_SURVEY_RELATIVE_WIDTH,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[8]))))
                .append(labeled(ItemMetaDataIfc.ADD_COMMENT_MATRIX,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[9]))))
                .append(labeled(ItemMetaDataIfc.MX_SURVEY_QUESTION_COMMENTFIELD,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[10]))))
                .append(labeled(ItemMetaDataIfc.PREDEFINED_SCALE,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[11]))))
                .append(labeled(ItemMetaDataIfc.TIMEALLOWED,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[12]))))
                .append(labeled(ItemMetaDataIfc.NUMATTEMPTS,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[13]))))
                .append(labeled(ItemMetaDataIfc.SCALENAME,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[14]))))
                .append(labeled(ItemMetaDataIfc.ADD_TO_FAVORITES_MATRIX,
                        resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[15]))));

        final StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemMetadata(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    private String labeled(String label, String text) {
        if (StringUtils.isNotEmpty(text)) {
            return label + ":" + text + "::";
        } else {
            return "";
        }
    }

    @Test
    public void testHashBaseForItemMetadataSkipsUnimportantKeys()
            throws NoSuchAlgorithmException, IOException, ServerOverloadException {
        final ItemData item = new ItemData();
        item.setTypeId(TypeIfc.FILL_IN_BLANK);
        final ItemMetaDataIfc metaData1 = newItemMetaData(item, ItemMetaDataIfc.RANDOMIZE, 0);
        final ItemMetaDataIfc metaData2 = newItemMetaData(item, ItemMetaDataIfc.OBJECTIVE, 1); // this one should be ignored
        final ItemMetaDataIfc metaData3 = newItemMetaData(item, ItemMetaDataIfc.REQUIRE_ALL_OK, 2);
        item.setItemMetaDataSet(Sets.newSet(metaData1, metaData2, metaData3));

        final StringBuilder expectedHashBase = new StringBuilder()
                .append("RANDOMIZE:" + resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[0])) + "::")
                .append("REQUIRE_ALL_OK:" + resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[2]))
                        + "::");

        // nulls for the other 14 "important" keys
        IntStream.rangeClosed(0, 13).forEach(i -> expectedHashBase.append(""));

        final StringBuilder actualHashBase = new StringBuilder();
        itemHashUtil.hashBaseForItemMetadata(item, actualHashBase);
        assertThat(actualHashBase.toString(), equalTo(expectedHashBase.toString()));
    }

    private ItemMetaDataIfc newItemMetaData(ItemData item, String key, int contentResourceIndex) {
        return new ItemMetaData(item, key,
                resourceDocTemplate1(fullUrlForContentResource(CONTENT_RESOURCES[contentResourceIndex])));
    }

    @Test
    public void testNormalizeResourceUrlsReplacesSingleResourceReference() throws NoSuchAlgorithmException,
            IOException, ServerOverloadException, IdUnusedException, TypeException, PermissionException {
        final String[] contentResourceDef = CONTENT_RESOURCES[0];

        final String originalResourceReferencingDoc = resourceDocTemplate1(
                fullUrlForContentResource(contentResourceDef));
        ArrayList<String[]> contentResourceDefs = new ArrayList<>();
        contentResourceDefs.add(contentResourceDef);
        final String expectedResourceReferencingDoc = "Label:"
                + resourceDocTemplate1(expectedContentResourceHash(contentResourceDefs)) + "::";

        expectServerUrlLookup();
        expectResourceLookup(contentResourceDef);
        final String actualParsedDoc = itemHashUtil.normalizeResourceUrls("Label", originalResourceReferencingDoc);
        assertThat(actualParsedDoc, equalTo(expectedResourceReferencingDoc));
    }

    @Test
    public void testNormalizeResourceUrlsReplacesTwoResourceReferences() throws NoSuchAlgorithmException,
            IOException, ServerOverloadException, IdUnusedException, TypeException, PermissionException {
        final String[] contentResourceDef1 = CONTENT_RESOURCES[0];
        final String[] contentResourceDef2 = CONTENT_RESOURCES[1];

        final String originalResourceReferencingDoc = resourceDocTemplate2(
                fullUrlForContentResource(contentResourceDef1), fullUrlForContentResource(contentResourceDef2));
        ArrayList<String[]> contentResourceDefs1 = new ArrayList<>();
        contentResourceDefs1.add(contentResourceDef1);
        ArrayList<String[]> contentResourceDefs2 = new ArrayList<>();
        contentResourceDefs2.add(contentResourceDef2);
        final String expectedResourceReferencingDoc = "Label:"
                + resourceDocTemplate1(expectedContentResourceHash(contentResourceDefs1))
                + resourceDocTemplate1b(expectedContentResourceHash(contentResourceDefs2)) + "::";

        expectServerUrlLookup();
        expectResourceLookup(contentResourceDef1);
        expectResourceLookup(contentResourceDef2);
        final String actualParsedDoc = itemHashUtil.normalizeResourceUrls("Label", originalResourceReferencingDoc);
        assertThat(actualParsedDoc, equalTo(expectedResourceReferencingDoc));
    }

    @Test
    public void testNormalizeResourceUrlsSkipsMissingResources() throws IOException, NoSuchAlgorithmException,
            IdUnusedException, PermissionException, TypeException, ServerOverloadException {
        final String[] contentResourceDef1 = CONTENT_RESOURCES[0];
        final String[] contentResourceDef2 = CONTENT_RESOURCES[1];

        final String originalResourceReferencingDoc = resourceDocTemplate2(
                fullUrlForContentResource(contentResourceDef1), fullUrlForContentResource(contentResourceDef2));
        ArrayList<String[]> contentResourceDefs = new ArrayList<>();
        contentResourceDefs.add(contentResourceDef2);
        final String expectedResourceReferencingDoc = "Label:"
                + resourceDocTemplate2(fullUrlForContentResource(contentResourceDef1),
                        expectedContentResourceHash(contentResourceDefs))
                + "::";

        expectServerUrlLookup();
        failResourceLookup(contentResourceDef1);
        expectResourceLookup(contentResourceDef2);

        final String actualParsedDoc = itemHashUtil.normalizeResourceUrls("Label", originalResourceReferencingDoc);
        assertThat(actualParsedDoc, equalTo(expectedResourceReferencingDoc));
    }

    @Test
    public void testNormalizeMetadataUrlReplacesResourceReference() throws IOException, NoSuchAlgorithmException,
            IdUnusedException, PermissionException, TypeException, ServerOverloadException {
        final String[] contentResourceDef = CONTENT_RESOURCES[0];

        final String originalResourceReferencingMetadataValue = serverRelativeUrlForContentResource(
                contentResourceDef);
        ArrayList<String[]> contentResourceDefs = new ArrayList<>();
        contentResourceDefs.add(contentResourceDef);
        final String expectedResourceReferencingMetadataValue = "Label:"
                + expectedContentResourceHash(contentResourceDefs) + "::";

        expectResourceLookup(contentResourceDef);
        final String actualParsedValue = itemHashUtil.normalizeMetadataUrl("Label",
                originalResourceReferencingMetadataValue);
        assertThat(actualParsedValue, equalTo(expectedResourceReferencingMetadataValue));
    }

    @Test
    public void testNormalizeMetadataUrlSkipsMissingResources() throws IdUnusedException, PermissionException,
            IOException, TypeException, ServerOverloadException, NoSuchAlgorithmException {
        final String[] contentResourceDef = CONTENT_RESOURCES[0];

        final String originalResourceReferencingMetadataValue = serverRelativeUrlForContentResource(
                contentResourceDef);
        final String expectedResourceReferencingMetadataValue = "Label:" + originalResourceReferencingMetadataValue
                + "::";

        failResourceLookup(contentResourceDef);

        final String actualParsedValue = itemHashUtil.normalizeMetadataUrl("Label",
                originalResourceReferencingMetadataValue);
        assertThat(actualParsedValue, equalTo(expectedResourceReferencingMetadataValue));
    }

    @Test
    public void testCanonicalSha256() throws IOException, NoSuchAlgorithmException {
        String ours = sha256(bytes("hashBase"));
        String theirs = itemHashUtil.hashString("hashBase");
        // from http://www.xorbin.com/tools/sha256-hash-calculator and
        // http://tomeko.net/online_tools/hex_to_base64.php
        String wellKnown = "yStr7Sx/4kg3bIjdI3CUTsihx64LU5huQVnIeItLPrI=";
        assertThat(theirs, equalTo(ours));
        assertThat(theirs, equalTo(wellKnown));
    }

    private void expectResourceLookupUnchecked(String[] resourceDef) {
        try {
            expectResourceLookup(resourceDef);
        } catch (RuntimeException e) {
            throw e;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private ContentResource expectResourceLookup(final String[] resourceDef) throws IdUnusedException,
            TypeException, PermissionException, ServerOverloadException, UnsupportedEncodingException {
        final ContentResource resource = mock(ContentResource.class);
        when(contentHostingService.getResource(idForContentResource(resourceDef))).thenReturn(resource);
        when(resource.getContentLength()).thenReturn(Long.parseLong(resourceDef[CR_SIZE_IDX]));
        when(resource.streamContent()).thenAnswer(invocation -> inputStreamForContentResource(resourceDef));
        return resource;
    }

    private void failResourceLookupUnchecked(String[] resourceDef) {
        try {
            failResourceLookup(resourceDef);
        } catch (RuntimeException e) {
            throw e;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private void failResourceLookup(String[] resourceDef) throws IdUnusedException, TypeException,
            PermissionException, ServerOverloadException, UnsupportedEncodingException {
        when(contentHostingService.getResource(idForContentResource(resourceDef)))
                .thenThrow(IdUnusedException.class);
    }

    private void expectServerUrlLookup() {
        when(serverConfigurationService.getServerUrl()).thenReturn(SERVER_BASE_URL);
    }

    private InputStream inputStreamForContentResource(String[] contentResource)
            throws UnsupportedEncodingException {
        return new ByteArrayInputStream(bytesForContentResource(contentResource));
    }

    private byte[] bytesForContentResource(String[] contentResource) throws UnsupportedEncodingException {
        return bytes(contentResource[CR_CONTENT_IDX]);
    }

    private byte[] bytes(String fromString) throws UnsupportedEncodingException {
        return fromString.getBytes("UTF-8");
    }

    private byte[] bytes(long fromLong) throws UnsupportedEncodingException {
        // yes, we know this is not the same as ByteBuffer.put(long)... but it matches what's
        // going on in the actual hash operations in ItemFacadeQueries where we encode as bytes
        // the string representation of the long
        return ("" + fromLong).getBytes("UTF-8");
    }

    private String sha256(byte[] hashBase) throws NoSuchAlgorithmException {
        return hash(hashBase, "SHA-256");
    }

    private String hash(byte[] hashBase, String algorithm) throws NoSuchAlgorithmException {
        if (hashBase == null) {
            return null;
        }
        final MessageDigest algo = MessageDigest.getInstance(algorithm);
        final byte[] digest = algo.digest(hashBase);
        return Base64.encodeBase64String(digest);
    }

    private byte[] hashBaseBytesForContentResource(String[] contentResource) throws UnsupportedEncodingException {
        final byte[] contentBytes = bytes(contentResource[CR_CONTENT_IDX]);
        final byte[] lengthBytes = bytes(Long.parseLong(contentResource[CR_SIZE_IDX]));
        return ByteBuffer.allocate(contentBytes.length + lengthBytes.length).put(contentBytes).put(lengthBytes)
                .array();
    }

    private String expectedContentResourceHash1(String[] contentResource)
            throws UnsupportedEncodingException, NoSuchAlgorithmException {
        if (contentResource == null) {
            return null;
        }
        final byte[] hashBaseBytes = hashBaseBytesForContentResource(contentResource);
        return "Resources:" + sha256(hashBaseBytes) + "::";
    }

    private String expectedContentResourceHash(ArrayList<String[]> contentResources)
            throws UnsupportedEncodingException, NoSuchAlgorithmException {
        Iterator<String[]> it = contentResources.iterator();
        StringBuilder answer = new StringBuilder();

        while (it.hasNext()) {
            String[] contentResource = it.next();
            if (contentResource != null) {
                answer.append("Resources:" + sha256(hashBaseBytesForContentResource(contentResource)) + "::");
            }
        }
        return answer.toString();
    }

    private String expectedContentResourceHashAttachments(ArrayList<String[]> contentResources)
            throws UnsupportedEncodingException, NoSuchAlgorithmException {
        Iterator<String[]> it = contentResources.iterator();
        StringBuilder answer = new StringBuilder();
        if (contentResources.size() > 0) {
            answer.append("Resources:");
        }
        while (it.hasNext()) {
            String[] contentResource = it.next();
            if (contentResource != null) {
                answer.append(sha256(hashBaseBytesForContentResource(contentResource)));
            }
        }
        if (contentResources.size() > 0) {
            answer.append("::");
        }
        return answer.toString();
    }

    private String fullUrlForContentResource(String[] contentResource) {
        return contentResource == null ? null
                : SERVER_BASE_URL + serverRelativeUrlForContentResource(contentResource);
    }

    private String serverRelativeUrlForContentResource(String[] contentResource) {
        return contentResource == null ? null : CONTENT_BASE_PATH + idForContentResource(contentResource);
    }

    private String idForContentResource(String[] contentResource) {
        return contentResource[CR_GROUP_IDX] + "/" + contentResource[CR_NAME_IDX];
    }

    // copy/paste of some special handling for fill-in-the-blank question text in ItemData.getText()
    private String renderBlanks(String text) {
        return text.replaceAll("\\{", "__").replaceAll("\\}", "__");
    }

    private String resourceDocTemplate1(Object... interpolations) {
        return resourceDocTemplate(HTML_DOC_TEMPLATE_1, interpolations);
    }

    private String resourceDocTemplate1b(Object... interpolations) {
        return resourceDocTemplate(HTML_DOC_TEMPLATE_1b, interpolations);
    }

    private String resourceDocTemplate2(Object... interpolations) {
        return resourceDocTemplate(HTML_DOC_TEMPLATE_2, interpolations);
    }

    private String resourceDocTemplate(String template, Object... interpolations) {
        return (ArrayUtils.isEmpty(interpolations) || Arrays.equals(interpolations, new Object[] { null })) ? null
                : String.format(template, interpolations);
    }

    private static final String CONTENT_BASE_PATH = "/access/content";
    private static final String SERVER_BASE_URL = "http://localhost:8081";
    private static final int CR_GROUP_IDX = 0;
    private static final int CR_NAME_IDX = 1;
    private static final int CR_SIZE_IDX = 2;
    private static final int CR_CONTENT_IDX = 3;
    // each entry these have to sort lexically by its first nested element (a group+context ID)
    private static final String[][] CONTENT_RESOURCES = {
            { "/group/contextId000", "foo.png", "2", "foo content foo content foo content" },
            { "/group/contextId001", "bar.png", "4", "bar content bar content bar content" },
            { "/group/contextId002", "baz.png", "8", "baz content baz content baz content" },
            { "/group/contextId003", "bap.png", "16", "bap content bap content bap content" },
            { "/group/contextId004", "bam.png", "32", "bam content bam content bam content" },
            { "/group/contextId005", "bat.png", "64", "bat content bat content bat content" },
            { "/group/contextId006", "baq.png", "128", "baq content baq content baq content" },
            { "/group/contextId007", "baw.png", "256", "baw content baw content baw content" },
            { "/group/contextId008", "bas.png", "512", "bas content bas content bas content" },
            { "/group/contextId009", "bad.png", "1024", "bad content bad content bad content" },
            { "/group/contextId010", "baf.png", "2048", "baf content baf content baf content" },
            { "/group/contextId011", "bag.png", "4096", "bag content bag content bag content" },
            { "/group/contextId012", "bah.png", "8192", "bah content bah content bah content" },
            { "/group/contextId013", "baj.png", "16384", "baj content baj content baj content" },
            { "/group/contextId014", "bak.png", "32768", "bak content bak content bak content" },
            { "/group/contextId015", "bal.png", "65536", "bal content bal content bal content" },
            { "/group/contextId016", "ban.png", "131072", "ban content ban content ban content" },
            { "/group/contextId017", "bao.png", "262144", "bao content bao content bao content" },
            { "/group/contextId018", "bau.png", "262144", "bau content bau content bau content" } };

    private static final String HTML_DOC_TEMPLATE_1 = "<p>I {response} these images:</p>\n" + "\n"
            + "<p><img alt=\"\" height=\"760\" src=\"%s\" width=\"1082\" /></p>";
    private static final String HTML_DOC_TEMPLATE_1b = "\n"
            + "<p><img alt=\"\" height=\"760\" src=\"%s\" width=\"1082\" /></p>";
    private static final String HTML_DOC_TEMPLATE_2 = "<p>I {response} these images:</p>\n" + "\n"
            + "<p><img alt=\"\" height=\"760\" src=\"%s\" width=\"1082\" /></p>" + "\n"
            + "<p><img alt=\"\" height=\"760\" src=\"%s\" width=\"1082\" /></p>";
}