/*
* Copyright (C) 2014 SCVNGR, Inc. d/b/a LevelUp
*
* Licensed under the Apache 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://www.apache.org/licenses/LICENSE-2.0
*
* 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 com.scvngr.levelup.core.test;
import android.support.annotation.NonNull;
import android.test.MoreAsserts;
import com.scvngr.levelup.core.annotation.JsonValueType;
import com.scvngr.levelup.core.annotation.JsonValueType.JsonType;
import com.scvngr.levelup.core.model.factory.json.AbstractJsonModelFactory;
import com.scvngr.levelup.core.util.LogManager;
import com.scvngr.levelup.core.util.NullUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
/**
* Utility methods for testing.
*/
public final class JsonTestUtil {
/**
* <p>
* Checks that modifying individual fields in a model will result in its equals/hashCode methods
* failing. Uses reflection on {@link JsonValueType} annotations on fields of a passed class to
* figure out how to modify the JSON representation of the model in different ways, then parses
* the JSON with a {@link AbstractJsonModelFactory} subclass before checking equals/hashcode on
* both the original and a modified object.
* </p>
* <p>
* This effectively checks that equals/hashcode works across any value changes from fields we
* read from JSON, but also checks some other potential issues. We're implicitly checking that
* the JSON typing declared in annotations for the fields matches what we actually use when
* parsing our JSON (since if it doesn't, we'll get JSON errors when reading the data during the
* clone/modify). We're also checking for fields that may have been added to the JSON keys and
* the model without updating equals/hashcode to reflect them (as long as they're declared in
* the JSONKeys class used here).
* </p>
* <p>
* Note that this is only intended for test use and will turn all checked exceptions it might
* throw into unchecked ones.
* </p>
*
* @param jsonKeysClass Class of the underlying keys class to test all fields (except
* blacklistFields) from. Must have visible fields to read from.
* @param jsonFactory Factory object to construct model instances from out of the base and
* generated-variant JSON objects before checking equals/hashcode.
* @param baseJsonObject Fully-populated JSON object for the model to use for comparison with
* modified copies.
* @param blacklistFields Fields to exclude from variant testing (either because we need to test
* them manually or because they don't reflect fields that are used for parsing into the
* model). Note that this is the jsonKeysClass's field name as a string, not the JSON key
* value (eg "ID", not "id").
*/
public static void checkEqualsAndHashCodeOnJsonVariants(@NonNull final Class<?> jsonKeysClass,
@NonNull final AbstractJsonModelFactory<?> jsonFactory,
@NonNull final JSONObject baseJsonObject, @NonNull final String[] blacklistFields) {
Object originalModel;
Object differentModel;
Object differentModelReparse;
try {
originalModel = jsonFactory.from(baseJsonObject);
} catch (final JSONException e1) {
throw new RuntimeException(e1);
}
MoreAsserts.checkEqualsAndHashCodeMethods(originalModel, null, false);
final Field[] jsonKeyFields = jsonKeysClass.getFields();
final List<String> blacklisted = Arrays.asList(blacklistFields);
final String key = null;
MoreAsserts.assertNotEmpty("JSON keys class visible fields",
Arrays.asList(jsonKeyFields));
for (final Field field : jsonKeyFields) {
if (!blacklisted.contains(field.getName())) {
JSONObject copiedDifferingObject;
String fieldString;
// Don't check exceptions, just let tests fail.
try {
fieldString = NullUtils.nonNullContract((String) field.get(key));
copiedDifferingObject =
cloneObjectDifferingOnParam(baseJsonObject, fieldString,
reflectJsonType(field));
differentModel = jsonFactory.from(copiedDifferingObject);
differentModelReparse = jsonFactory.from(copiedDifferingObject);
} catch (final IllegalArgumentException e) {
throw new RuntimeException(e);
} catch (final IllegalAccessException e) {
throw new RuntimeException(e);
} catch (final JSONException e) {
throw new RuntimeException(e);
}
MoreAsserts.checkEqualsAndHashCodeMethods(String.format(Locale.US,
"Modified %s and checked equals and hash", fieldString),
originalModel, differentModel, false);
MoreAsserts.checkEqualsAndHashCodeMethods(String.format(Locale.US,
"Modified %s and checked equals and hash", fieldString),
differentModel, differentModel, true);
MoreAsserts.checkEqualsAndHashCodeMethods(String.format(Locale.US,
"Modified %s and checked equals and hash", fieldString),
differentModel, differentModelReparse, true);
}
}
}
/**
* Reflects a {@link JsonType} from a field's {@link JsonValueType} annotation.
*
* @param field Field with a JsonValueType annotation to read from.
* @return the {@link JsonType} of the field.
*/
@NonNull
private static JsonType reflectJsonType(@NonNull final Field field) {
final JsonValueType annotation = field.getAnnotation(JsonValueType.class);
return annotation.value();
}
/**
* Makes a deep copy of a JSONObject, modifying one key based on its {@link JsonType} (e.g.
* flipping the value for a boolean, adding 1 to numbers, appending data to a string).
*
* @param baseObject JSONObject to create a modified deep copy of.
* @param key Key to modify the value of in the copy.
* @param jsonType {@link JsonType} to use to infer how to modify the copy's value.
* @return a deep copy of the JSON object, with one modified field.
* @throws JSONException if there was a parsing error.
*/
@NonNull
private static JSONObject cloneObjectDifferingOnParam(@NonNull final JSONObject baseObject,
@NonNull final String key, @NonNull final JsonType jsonType) throws JSONException {
final JSONObject object = new JSONObject(baseObject.toString());
LogManager.d("Testing field %s", key);
if (JsonType.BOOLEAN.equals(jsonType)) {
object.put(key, !object.getBoolean(key));
} else if (JsonType.DOUBLE.equals(jsonType)) {
object.put(key, object.getDouble(key) + 1);
} else if (JsonType.INT.equals(jsonType)) {
object.put(key, object.getInt(key) + 1);
} else if (JsonType.LONG.equals(jsonType)) {
object.put(key, object.getLong(key) + 1);
} else if (JsonType.STRING.equals(jsonType)) {
String currentString = object.getString(key);
if (null == currentString) {
currentString = "";
}
object.put(key, currentString + "_testdifferent");
} else if (JsonType.JSON_ARRAY.equals(jsonType)) {
JSONArray currentArray = object.getJSONArray(key);
if (null == currentArray) {
currentArray = new JSONArray();
}
final JSONArray modifiedArray = new JSONArray(currentArray.toString());
modifiedArray.put(1);
object.put(key, modifiedArray);
} else {
throw new UnsupportedOperationException(String.format(Locale.US,
"Can only use for JsonTypes(int/long/bool/string/array) not JsonType(%s)",
jsonType.name()));
}
return object;
}
/**
* Private constructor prevents instantiation.
*
* @throws UnsupportedOperationException because this class cannot be instantiated.
*/
private JsonTestUtil() {
throw new UnsupportedOperationException("This class is non-instantiable");
}
}