BaseXMLBuilderTests.java

package com.jamesmurty.utils;

import java.io.File;
import java.io.FileWriter;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.Properties;

import javax.xml.transform.OutputKeys;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;

import junit.framework.TestCase;
import net.iharder.Base64;

import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;

public abstract class BaseXMLBuilderTests extends TestCase {

	public static final String EXAMPLE_XML_DOC_START =
		"<Projects>" +
		  "<java-xmlbuilder language=\"Java\" scm=\"SVN\">" +
		    "<Location type=\"URL\">http://code.google.com/p/java-xmlbuilder/</Location>" +
		  "</java-xmlbuilder>" +
		  "<JetS3t language=\"Java\" scm=\"CVS\">" +
		    "<Location type=\"URL\">http://jets3t.s3.amazonaws.com/index.html</Location>";

	public static final String EXAMPLE_XML_DOC_END =
		  "</JetS3t>" +
		"</Projects>";

	public static final String EXAMPLE_XML_DOC = EXAMPLE_XML_DOC_START + EXAMPLE_XML_DOC_END;

	protected abstract Class<? extends BaseXMLBuilder> XMLBuilderToTest() throws Exception;

	protected abstract boolean isRuntimeExceptionsOnly();

	protected BaseXMLBuilder XMLBuilder_create(String name) throws Exception {
        return (BaseXMLBuilder) XMLBuilderToTest().getMethod(
            "create", String.class).invoke(null, name);
	}

    protected BaseXMLBuilder XMLBuilder_create(String name, String nsURI) throws Exception {
        return (BaseXMLBuilder) XMLBuilderToTest().getMethod(
            "create", String.class, String.class).invoke(null, name, nsURI);
    }

    protected BaseXMLBuilder XMLBuilder_parse(InputSource source) throws Exception {
        return (BaseXMLBuilder) XMLBuilderToTest().getMethod(
            "parse", InputSource.class).invoke(null, source);
    }

    protected BaseXMLBuilder XMLBuilder_parse(
        String documentString, boolean enableExternalEntities,
        boolean isNamespaceAware) throws Exception
    {
        return (BaseXMLBuilder) XMLBuilderToTest().getMethod(
            "parse", String.class, boolean.class, boolean.class).invoke(
                null, documentString, enableExternalEntities, isNamespaceAware);
    }

    protected BaseXMLBuilder XMLBuilder_parse(String documentString) throws Exception {
        return (BaseXMLBuilder) XMLBuilderToTest().getMethod(
            "parse", String.class).invoke(null, documentString);
    }

    public void testXmlDocumentCreation() throws Exception {
		/* Build XML document in-place */
		BaseXMLBuilder builder = XMLBuilder_create("Projects")
		    .e("java-xmlbuilder")
		        .a("language", "Java")
		        .a("scm","SVN")
		        .e("Location")
		            .a("type", "URL")
		            .t("http://code.google.com/p/java-xmlbuilder/")
		        .up()
		    .up()
		    .e("JetS3t")
		        .a("language", "Java")
		        .a("scm","CVS")
		        .e("Location")
		            .a("type", "URL")
		            .t("http://jets3t.s3.amazonaws.com/index.html");

		/* Set output properties */
		Properties outputProperties = new Properties();
		// Explicitly identify the output as an XML document
		outputProperties.put(javax.xml.transform.OutputKeys.METHOD, "xml");
		// Pretty-print the XML output (doesn't work in all cases)
		outputProperties.put(javax.xml.transform.OutputKeys.INDENT, "no");
		// Omit the XML declaration, which can differ depending on the test's run context.
		outputProperties.put(javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION, "yes");

		/* Serialize builder document */
		StringWriter writer = new StringWriter();
		builder.toWriter(writer, outputProperties);

		assertEquals(EXAMPLE_XML_DOC, writer.toString());

		/* Build XML document in segments*/
		BaseXMLBuilder projectsB = XMLBuilder_create("Projects");
		projectsB.e("java-xmlbuilder")
		        .a("language", "Java")
		        .a("scm","SVN")
		        .e("Location")
		            .a("type", "URL")
		            .t("http://code.google.com/p/java-xmlbuilder/");
		BaseXMLBuilder jets3tB = projectsB.e("JetS3t")
		        .a("language", "Java")
		        .a("scm","CVS");
		jets3tB.e("Location")
		            .a("type", "URL")
		            .t("http://jets3t.s3.amazonaws.com/index.html");

		assertEquals(builder.asString(), projectsB.asString());
	}

	public void testParseAndXPath() throws Exception {
		// Parse an existing XML document
		BaseXMLBuilder builder = XMLBuilder_parse(
				new InputSource(new StringReader(EXAMPLE_XML_DOC)));
		assertEquals("Projects", builder.root().getElement().getNodeName());
		assertEquals("Invalid current element", "Projects", builder.getElement().getNodeName());

		// Find the first Location element
		builder = builder.xpathFind("//Location");
		assertEquals("Location", builder.getElement().getNodeName());
		assertEquals("http://code.google.com/p/java-xmlbuilder/",
				builder.getElement().getTextContent());

		// Find JetS3t's Location element
		builder = builder.xpathFind("//JetS3t/Location");
		assertEquals("Location", builder.getElement().getNodeName());
		assertEquals("http://jets3t.s3.amazonaws.com/index.html",
				builder.getElement().getTextContent());

		// Find the project with the scm attribute 'CVS' (should be JetS3t)
		builder = builder.xpathFind("//*[@scm = 'CVS']");
		assertEquals("JetS3t", builder.getElement().getNodeName());

		// Try an invalid XPath that does not resolve to an element
		try {
			builder.xpathFind("//@language");
			fail("Non-Element XPath expression should have failed");
		} catch (Exception e) {
            if (isRuntimeExceptionsOnly()) {
                assertEquals(XMLBuilderRuntimeException.class, e.getClass());
                e = (Exception) e.getCause();
            }
            assertEquals(XPathExpressionException.class, e.getClass());
			assertTrue(e.getMessage().contains("does not resolve to an Element"));
		}

		/* Perform full-strength XPath queries that do not have to
		 * resolve to an Element, and do not return BaseXMLBuilder instances
		 */

	    // Find the Location value for the JetS3t project
        String location = (String) builder.xpathQuery(
            "//JetS3t/Location/.", XPathConstants.STRING);
        assertEquals("http://jets3t.s3.amazonaws.com/index.html", location);

        // Count the number of projects (count returned as String)
        String countAsString = (String) builder.xpathQuery(
            "count(/Projects/*)", XPathConstants.STRING);
        assertEquals("2", countAsString);

        // Count the number of projects (count returned as "Number" - actually Double)
        Number countAsNumber = (Number) builder.xpathQuery(
            "count(/Projects/*)", XPathConstants.NUMBER);
        assertEquals(2.0, countAsNumber);

        // Find all nodes under Projects
        NodeList nodes = (NodeList) builder.xpathQuery(
            "/Projects/*", XPathConstants.NODESET);
        assertEquals(2, nodes.getLength());
        assertEquals("JetS3t", nodes.item(1).getNodeName());

        // Returns null if nothing found when a NODE type is requested...
        assertNull(builder.xpathQuery("//WrongName", XPathConstants.NODE));
        // ... or an empty String if a STRING type is requested...
        assertEquals("", builder.xpathQuery("//WrongName", XPathConstants.STRING));
        // ... or NaN if a NUMBER type is requested...
        assertEquals(Double.NaN, builder.xpathQuery("//WrongName", XPathConstants.NUMBER));

		/* Add a new XML element at a specific XPath location in an existing document */

		// Use XPath to get a builder at the insert location
		BaseXMLBuilder xpathLocB = builder.xpathFind("//JetS3t");
		assertEquals("JetS3t", xpathLocB.getElement().getNodeName());

		// Append a new element with the location's builder
		BaseXMLBuilder location2B = xpathLocB.elem("Location2").attr("type", "Testing");
		assertEquals("Location2", location2B.getElement().getNodeName());
		assertEquals("JetS3t", location2B.up().getElement().getNodeName());
		assertEquals(xpathLocB.getElement(), location2B.up().getElement());
		assertEquals(builder.root(), location2B.root());

		// Sanity-check the entire resultant XML document
		Properties outputProperties = new Properties();
		outputProperties.put(javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION, "yes");
		String xmlAsString = location2B.asString(outputProperties);

		assertFalse(EXAMPLE_XML_DOC.equals(xmlAsString));
		assertTrue(xmlAsString.contains("<Location2 type=\"Testing\"/>"));
		assertEquals(
			EXAMPLE_XML_DOC_START + "<Location2 type=\"Testing\"/>" + EXAMPLE_XML_DOC_END,
			xmlAsString);
	}

    public void testParseAndAmendDocWithWhitespaceNodes() throws Exception {
        // Parse example XML document and output with indenting, to add whitespace nodes
        Properties outputProperties = new Properties();
        outputProperties.put(OutputKeys.INDENT, "yes");
        outputProperties.put("{http://xml.apache.org/xslt}indent-amount", "2");
        String xmlWithWhitespaceNodes =
            XMLBuilder_parse(EXAMPLE_XML_DOC).asString(outputProperties);

        // Re-parse document that now has whitespace nodes
        BaseXMLBuilder builder = XMLBuilder_parse(xmlWithWhitespaceNodes);

        // Ensure we can add a node to the document (re issue #17)
        builder.xpathFind("//JetS3t")
            .elem("AnotherLocation").attr("type", "Testing");
        String xmlWithAmendments = builder.asString(outputProperties);
        assertTrue(xmlWithAmendments.contains("<AnotherLocation type=\"Testing\"/>"));
    }

    public void testStripWhitespaceNodesFromDocument() throws Exception {
        // Parse example XML document and output with indenting, to add whitespace nodes
        Properties outputProperties = new Properties();
        outputProperties.put(OutputKeys.INDENT, "yes");
        outputProperties.put("{http://xml.apache.org/xslt}indent-amount", "2");
        String xmlWithWhitespaceNodes =
            XMLBuilder_parse(EXAMPLE_XML_DOC).asString(outputProperties);

        // Re-parse document that now has whitespace text nodes
        BaseXMLBuilder builder = XMLBuilder_parse(xmlWithWhitespaceNodes);
        assertTrue(builder.asString().contains("\n"));
        assertTrue(builder.asString().contains("  "));

        // Strip whitespace nodes
        builder.stripWhitespaceOnlyTextNodes();
        assertFalse(builder.asString().contains("\n"));
        assertFalse(builder.asString().contains("  "));
    }

	public void testSimpleXpath() throws Exception {
	    String xmlDoc = "<template_objects><report_objects/></template_objects>";
	    BaseXMLBuilder builder = XMLBuilder_parse(xmlDoc);
	    BaseXMLBuilder builderNode = builder.xpathFind("report_objects");
	    assertTrue("report_objects".equals(builderNode.getElement().getNodeName()));
	    assertTrue("<report_objects/>".equals(builderNode.elementAsString()));
	}

	/**
	 * Test for issue #11: https://code.google.com/p/java-xmlbuilder/issues/detail?id=11
	 * @throws Exception
	 */
	public void testAddElementsInLoop() throws Exception {
        BaseXMLBuilder builder = XMLBuilder_create("DocRoot");
        BaseXMLBuilder parentBuilder = builder.element("Parent");

        // Add set of elements to Parent using a loop...
        for (int i = 1; i <= 10; i++) {
            parentBuilder.elem("IntegerValue" + i).text("" + i);
        }

        // ...and confirm element set is within parent after a call to up()
	    parentBuilder.up();

	    assertEquals("Parent", parentBuilder.getElement().getNodeName());
        assertEquals("DocRoot", builder.getElement().getNodeName());
        assertEquals(1, builder.getElement().getChildNodes().getLength());
        assertEquals("Parent", builder.getElement().getChildNodes().item(0).getNodeName());
        assertEquals(10, parentBuilder.getElement().getChildNodes().getLength());
        assertEquals("IntegerValue1", parentBuilder.getElement().getChildNodes().item(0).getNodeName());
        assertEquals("1", parentBuilder.getElement().getChildNodes().item(0).getTextContent());
	}

    public void testTraversalDuringBuild() throws Exception {
        BaseXMLBuilder builder = XMLBuilder_create("ElemDepth1")
            .e("ElemDepth2")
            .e("ElemDepth3")
            .e("ElemDepth4");
        assertEquals("ElemDepth3", builder.up().getElement().getNodeName());
        assertEquals("ElemDepth1", builder.up(3).getElement().getNodeName());
        // Traverse too far up the node tree...
        assertEquals("ElemDepth1", builder.up(4).getElement().getNodeName());
        // Traverse way too far up the node tree...
        assertEquals("ElemDepth1", builder.up(100).getElement().getNodeName());
    }

    public void testImport() throws Exception {
        BaseXMLBuilder importer = XMLBuilder_create("Importer")
            .elem("Imported")
            .elem("Element")
            .elem("Goes").attr("are-we-there-yet", "almost")
            .elem("Here");
        BaseXMLBuilder importee = XMLBuilder_create("Importee")
            .elem("Importee").attr("awating-my", "new-home")
            .elem("IsEntireSubtree")
            .elem("Included");
        importer.importXMLBuilder(importee);

        // Ensure we're at the same point in the XML doc
        assertEquals("Here", importer.getElement().getNodeName());

        try {
            importer.xpathFind("//Importee");
            importer.xpathFind("//IsEntireSubtree");
            importer.xpathFind("//IsEntireSubtree");
            importer.xpathFind("//Included");
        } catch (XPathExpressionException e) {
            fail("XMLBuilder import failed: " + e.getMessage());
        }

        BaseXMLBuilder invalidImporter = XMLBuilder_create("InvalidImporter")
            .text("BadBadBad");
        try {
            invalidImporter.importXMLBuilder(importee);
            fail("Should not be able to import XMLBuilder into "
                + "an element containing text nodes");
        } catch (IllegalStateException e) {
            // Expected
        }
    }

    public void testCDataNodes() throws Exception {
        String text = "Text data -- left as it is";
        String textForBytes = "Byte data is automatically base64-encoded";
        String textEncoded = Base64.encodeBytes(textForBytes.getBytes("UTF-8"));

        BaseXMLBuilder builder = XMLBuilder_create("TestCDataNodes")
            .elem("CDataTextElem")
                .cdata(text)
                .up()
            .elem("CDataBytesElem")
                .cdata(textForBytes.getBytes("UTF-8"));

        Node cdataTextNode = builder.xpathFind("//CDataTextElem")
            .getElement().getChildNodes().item(0);
        assertEquals(Node.CDATA_SECTION_NODE, cdataTextNode.getNodeType());
        assertEquals(text, cdataTextNode.getNodeValue());

        Node cdataBytesNode = builder.xpathFind("//CDataBytesElem")
            .getElement().getChildNodes().item(0);
        assertEquals(Node.CDATA_SECTION_NODE, cdataBytesNode.getNodeType());
        assertEquals(textEncoded, cdataBytesNode.getNodeValue());
        String base64Decoded = new String(Base64.decode(cdataBytesNode.getNodeValue()));
        assertEquals(textForBytes, base64Decoded);
    }

    public void testElementAsString() throws Exception {
        BaseXMLBuilder builder = XMLBuilder_create("This")
            .elem("Is").elem("My").text("Test");
        // By default, entire XML document is serialized regardless of starting-point
        assertEquals("<This><Is><My>Test</My></Is></This>", builder.asString());
        assertEquals("<This><Is><My>Test</My></Is></This>", builder.xpathFind("//My").asString());
        // Serialize a specific Element and its descendants with elementAsString
        assertEquals("<My>Test</My>", builder.xpathFind("//My").elementAsString());
    }

    public void testNamespaces() throws Exception {
        BaseXMLBuilder builder = XMLBuilder_create("NamespaceTest", "urn:default")
                .namespace("prefix1", "urn:ns1")

                .element("NSDefaultImplicit").up()
                .element("NSDefaultExplicit", "urn:default").up()

                .element("NS1Explicit", "urn:ns1").up()
                .element("prefix1:NS1WithPrefixExplicit", "urn:ns1").up()
                .element("prefix1:NS1WithPrefixImplicit").up();

        // Build a namespace context from the builder's document
        NamespaceContextImpl context = builder.buildDocumentNamespaceContext();

        // All elements in a namespaced document inherit a namespace URI,
        // for namespaced document any non-namespaced XPath query will fail.
        try {
            builder.xpathFind("//:NSDefaultImplicit");
            fail("Namespaced xpath query without context is invalid");
        } catch (Exception e) {
            if (isRuntimeExceptionsOnly()) {
                assertEquals(XMLBuilderRuntimeException.class, e.getClass());
                e = (Exception) e.getCause();
            }
            assertEquals(XPathExpressionException.class, e.getClass());
        }
        try {
            builder.xpathFind("//NSDefaultImplicit", context);
            fail("XPath query without prefixes on namespaced docs is invalid");
        } catch (Exception e) {
            if (isRuntimeExceptionsOnly()) {
                assertEquals(XMLBuilderRuntimeException.class, e.getClass());
                e = (Exception) e.getCause();
            }
            assertEquals(XPathExpressionException.class, e.getClass());
        }

        // Find nodes with default namespace
        builder.xpathFind("/:NamespaceTest", context);
        builder.xpathFind("//:NSDefaultImplicit", context);
        builder.xpathFind("//:NSDefaultExplicit", context);

        // Must use namespace-aware xpath to find namespaced nodes
        try {
            builder.xpathFind("//NSDefaultExplicit");
            fail();
        } catch (Exception e) {
            if (isRuntimeExceptionsOnly()) {
                assertEquals(XMLBuilderRuntimeException.class, e.getClass());
                e = (Exception) e.getCause();
            }
            assertEquals(XPathExpressionException.class, e.getClass());
        }
        try {
            builder.xpathFind("//:NSDefaultExplicit");
            fail();
        } catch (Exception e) {
            if (isRuntimeExceptionsOnly()) {
                assertEquals(XMLBuilderRuntimeException.class, e.getClass());
                e = (Exception) e.getCause();
            }
            assertEquals(XPathExpressionException.class, e.getClass());
        }
        try {
            builder.xpathFind("//NSDefaultExplicit", context);
            fail();
        } catch (Exception e) {
            if (isRuntimeExceptionsOnly()) {
                assertEquals(XMLBuilderRuntimeException.class, e.getClass());
                e = (Exception) e.getCause();
            }
            assertEquals(XPathExpressionException.class, e.getClass());
        }

        // Find node with namespace prefix
        builder.xpathFind("//prefix1:NS1Explicit", context);
        builder.xpathFind("//prefix1:NS1WithPrefixExplicit", context);
        builder.xpathFind("//prefix1:NS1WithPrefixImplicit", context);

        // Find nodes with user-defined prefix "aliases"
        context.addNamespace("default-alias", "urn:default");
        context.addNamespace("prefix1-alias", "urn:ns1");
        builder.xpathFind("//default-alias:NSDefaultExplicit", context);
        builder.xpathFind("//prefix1-alias:NS1Explicit", context);

        // User can override context mappings, for better or worse
        context.addNamespace("", "urn:default");
        builder.xpathFind("//:NSDefaultExplicit", context);

        context.addNamespace("", "urn:wrong");
        try {
            builder.xpathFind("//:NSDefaultExplicit", context);
            fail();
        } catch (Exception e) {
            if (isRuntimeExceptionsOnly()) {
                assertEquals(XMLBuilderRuntimeException.class, e.getClass());
                e = (Exception) e.getCause();
            }
            assertEquals(XPathExpressionException.class, e.getClass());
        }

        // Users are not prevented from creating elements that reference
        // an undefined namespace prefix -- user beware
        builder.element("undefined-prefix:ElementName");
    }

    public void testNamespaceUnawareBuilder() throws Exception {
        String XML_WITH_NAMESPACES =
            "<ns1:NamespaceUnwareTest xmlns:ns1=\"urn:1\" xmlns:ns2=\"urn:2\">" +
                "<ns2:NestedElement>Found me</ns2:NestedElement>" +
            "</ns1:NamespaceUnwareTest>";

        // Builder set to be unaware of namespaces can traverse DOM with
        // namespaces without using namespace prefixes
        BaseXMLBuilder result = XMLBuilder_parse(
            XML_WITH_NAMESPACES,
            false,  // enableExternalEntities
            false  // isNamespaceAware
        ).xpathFind("/NamespaceUnwareTest/NestedElement");
        assertEquals("Found me", result.getElement().getTextContent());

        // Builder set to be aware of namespaces (per default) cannot traverse
        // DOM with namespaces without using namespace prefixes
        try {
            result = XMLBuilder_parse(XML_WITH_NAMESPACES)
                .xpathFind("/NamespaceUnwareTest/NestedElement");
        } catch (Exception ex) {
            Throwable cause = null;
            if (this instanceof TestXMLBuilder2) {
                cause = ex.getCause();  // Exception wrapped in runtime ex
            } else {
                cause = ex;
            }
            assertEquals(
                cause.getClass(), XPathExpressionException.class);
            assertTrue(
                cause.getMessage().contains(
                    "XPath expression \"/NamespaceUnwareTest/NestedElement\""
                    + " does not resolve to an Element in context"
                ));
        }
        assertEquals("Found me", result.getElement().getTextContent());
    }

    public void testElementBefore() throws Exception {
        BaseXMLBuilder builder = XMLBuilder_create("TestDocument", "urn:default")
                .namespace("custom", "urn:custom")
                .elem("Before").up()
                .elem("After");
        NamespaceContextImpl context = builder.buildDocumentNamespaceContext();

        // Ensure XML structure is correct before insert
        assertEquals("<TestDocument xmlns=\"urn:default\" xmlns:custom=\"urn:custom\">"
            + "<Before/><After/></TestDocument>", builder.asString());

        // Insert an element before the "After" element, no explicit namespace (will use default)
        BaseXMLBuilder testDoc = XMLBuilder_parse(builder.asString())
            .xpathFind("/:TestDocument/:After", context);
        BaseXMLBuilder insertedBuilder = testDoc.elementBefore("Inserted");
        assertEquals("Inserted", insertedBuilder.getElement().getNodeName());
        assertEquals("<TestDocument xmlns=\"urn:default\" xmlns:custom=\"urn:custom\">"
            + "<Before/><Inserted/><After/></TestDocument>", testDoc.asString());

        // Insert another element, this time with a custom namespace prefix
        insertedBuilder = insertedBuilder.elementBefore("custom:InsertedAgain");
        assertEquals("custom:InsertedAgain", insertedBuilder.getElement().getNodeName());
        assertEquals("<TestDocument xmlns=\"urn:default\" xmlns:custom=\"urn:custom\">"
            + "<Before/><custom:InsertedAgain/><Inserted/><After/></TestDocument>",
            testDoc.asString());

        // Insert another element, this time with a custom namespace ref
        insertedBuilder = insertedBuilder.elementBefore("InsertedYetAgain", "urn:custom2");
        assertEquals("InsertedYetAgain", insertedBuilder.getElement().getNodeName());
        assertEquals("<TestDocument xmlns=\"urn:default\" xmlns:custom=\"urn:custom\">"
            + "<Before/><InsertedYetAgain xmlns=\"urn:custom2\"/><custom:InsertedAgain/>"
            + "<Inserted/><After/></TestDocument>",
            testDoc.asString());
    }

    public void testTextNodes() throws Exception {
        BaseXMLBuilder builder = XMLBuilder_create("TestDocument")
                .elem("TextElement")
                    .text("Initial");

        BaseXMLBuilder textElementBuilder = builder.xpathFind("//TextElement");
        assertEquals("Initial", textElementBuilder.getElement().getTextContent());

        // By default, text methods append value to existing text
        textElementBuilder.text("Appended");
        assertEquals("InitialAppended", textElementBuilder.getElement().getTextContent());

        // Use boolean flag to replace text nodes with a new value
        textElementBuilder.text("Replacement", true);
        assertEquals("Replacement", textElementBuilder.getElement().getTextContent());

        // Fail-fast if a null text value is provided.
        try {
            textElementBuilder.text(null);
            fail("null text value should cause IllegalArgumentException");
        } catch (IllegalArgumentException ex) {
            assertEquals("Illegal null text value", ex.getMessage());
        }

        try {
            textElementBuilder.text(null, true);
            fail("null text value should cause IllegalArgumentException");
        } catch (IllegalArgumentException ex) {
            assertEquals("Illegal null text value", ex.getMessage());
        }

    }

    public void testProcessingInstructionNodes() throws Exception {
        // Add instruction to root document element node (usual append-in-node behaviour)
        BaseXMLBuilder builder = XMLBuilder_create("TestDocument").instruction("test", "data");
        assertEquals("<TestDocument><?test data?></TestDocument>", builder.asString());

        // Add instruction after the root document element (not within it)
        builder = XMLBuilder_create("TestDocument3").document().instruction("test", "data");
        assertEquals("<TestDocument3/><?test data?>", builder.asString().trim());

        // Insert instruction as first node of the root document
        builder = XMLBuilder_create("TestDocument3").insertInstruction("test", "data");
        assertEquals(
            "<?test data?><TestDocument3/>",
            // Remove newlines from XML as this differs across platforms
            builder.asString().replace("\n", ""));

        // Insert instruction as first node of the root document, second example
        builder = XMLBuilder_create("TestDocument4").elem("ChildElem")
            .root().insertInstruction("test", "data");
        assertEquals(
            "<?test data?><TestDocument4><ChildElem/></TestDocument4>",
            // Remove newlines from XML as this differs across platforms
            builder.asString().replace("\n", ""));
    }

    /**
     * Test for strange issue raised by user on comments form where OutputKeys.STANDALONE setting
     * in transformer is ignored.
     *
     * @throws Exception
     */
    public void testSetStandaloneToYes() throws Exception {
        String xmlDoc = "<RootNode><InnerNode/></RootNode>";
        BaseXMLBuilder builder = XMLBuilder_parse(
            new InputSource(new StringReader(xmlDoc)));

        // Basic output settings
        Properties outputProperties = new Properties();
        outputProperties.put(javax.xml.transform.OutputKeys.VERSION, "1.0");
        outputProperties.put(javax.xml.transform.OutputKeys.METHOD, "xml");
        outputProperties.put(javax.xml.transform.OutputKeys.ENCODING, "UTF-8");

        // Use Document@setXmlStandalone(true) to ensure OutputKeys.STANDALONE is respected.
        builder.getDocument().setXmlStandalone(true);
        outputProperties.put(javax.xml.transform.OutputKeys.STANDALONE, "yes");

        /* Serialize builder document */
        StringWriter writer = new StringWriter();
        builder.toWriter(writer, outputProperties);

        assertEquals(
            "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>" + xmlDoc,
            writer.toString());
    }

    /**
     * Test the {@link BaseXMLBuilder#asString(Properties)} method output a document
     * of the correct size when the document is moderately large, re issue
     * #1 (on GitHub).
     *
     * @throws Exception
     */
    public void testModerateDocumentSizeAsString() throws Exception {
        // Create a moderate document around 0.5 MB
        long expectedByteSize = 505021;
        BaseXMLBuilder builder = XMLBuilder_create("RootNode");
        for (int i = 0; i < 5000; i++) {
            builder
                .e("TreeRoot")
                    .e("TreeTrunk")
                        .e("TreeBranch")
                            .e("TreeLeaf")
                                .t("Some Aphids");

        }
        // Omit XML declaration, which will otherwise be included in file
        // via #toWriter but not in string via #asString
        Properties outputProperties = new Properties();
        outputProperties.put(
            javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION, "yes");
        // Ensure XML as string has expected length...
        String xmlString = builder.asString(outputProperties);
        assertEquals(expectedByteSize, xmlString.length());
        // ...and matches size of XML written to file
        File f = File.createTempFile(
            "java-xmlbuilder-testmoderatedocumentsizeasstring", ".xml");
        builder.toWriter(new FileWriter(f), outputProperties);
        assertEquals(expectedByteSize, f.length());
        f.delete();
    }

    /**
     * Ensure XML Builder parse methods use a default configuration that
     * prevents XML External Entity (XXE) injection attacks.
     *
     * @throws Exception
     */
    public void testXMLBuilderParserImmuneToXXEAttackByDefault() throws Exception {
        String externalFilePath = "src/test/java/com/jamesmurty/utils/external.txt";
        File externalFile = new File(externalFilePath);
        String XML_DOC_WITH_XXE =
            "<?xml version=\"1.0\"?>" +
            "<!DOCTYPE Projects [" +
                "<!ELEMENT JetS3t ANY>" +
                "<!ENTITY xx1 SYSTEM \"" + externalFile.toURI() + "\"> ]>" +
            EXAMPLE_XML_DOC_START + "&xx1;" + EXAMPLE_XML_DOC_END;
        // By default, builder is immune from XXE injection
        BaseXMLBuilder builder = XMLBuilder_parse(XML_DOC_WITH_XXE);
        String parsedXml = builder.asString();
        assertFalse(parsedXml.indexOf("Injected XXE Data") >= 0);
        // If you enable external entity processing, builder becomes subject to XXE injection
        builder = XMLBuilder_parse(XML_DOC_WITH_XXE, true, true);
        parsedXml = builder.asString();
        assertTrue(parsedXml.indexOf("Injected XXE Data") >= 0);
    }

}