Created
January 24, 2017 01:34
-
-
Save sjehutch/e511956739a181ad8142cb668d6d2d57 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.provagroup.provaserver.wamp.legit; | |
import static org.junit.Assert.assertEquals; | |
import static org.junit.Assert.assertNotNull; | |
import static org.junit.Assert.assertTrue; | |
import static org.junit.Assert.fail; | |
import java.io.File; | |
import java.io.FileOutputStream; | |
import java.net.URL; | |
import java.nio.channels.Channels; | |
import java.nio.channels.ReadableByteChannel; | |
import java.util.ArrayList; | |
import java.util.Arrays; | |
import java.util.HashMap; | |
import java.util.List; | |
import java.util.Map; | |
import java.util.Map.Entry; | |
import java.util.UUID; | |
import java.util.logging.Level; | |
import java.util.logging.Logger; | |
import org.junit.BeforeClass; | |
import org.junit.Test; | |
import com.provagroup.provaserver.wamp.WampApiFieldKeys; | |
import com.provagroup.wamp.client.LegitWampClient; | |
/** | |
* @author rbense | |
* | |
*/ | |
public class LegitTest { | |
private static Logger log = Logger.getLogger(LegitTest.class.getName()); | |
public static final long WAIT_TIME = 100000; // DEBUG - increase this value or the client code will finish in 100s | |
private static final boolean push = true; | |
private static int testEnv = 1; // 0 = local, 1 = P4 testing | |
private static String user; | |
private static String pw; | |
private static LegitWampClient client; | |
@BeforeClass | |
public static void setUserPw() { | |
user = System.getProperty("user"); | |
pw = System.getProperty("pw"); | |
if (user == null) { | |
throw new IllegalStateException("User is null, please set the argument user="); | |
} | |
if (pw == null) { | |
throw new IllegalStateException("Password (pw) is null, please set the argument pw="); | |
} | |
} | |
@Test | |
public void testLogin() { | |
try { | |
out("\n\tUser: " + user + "\n\tPush: " + push + " URI: " + URI); | |
LegitWampClient c = getClient(realmlegit); | |
assertNotNull(c); | |
c.login(); | |
} catch (Exception e) { | |
fail("Failed to login \n\tUser: " + user + "\n\tURI: " + URI); | |
} | |
} | |
@Test | |
public void testLoginUser() { | |
try { | |
LegitWampClient c = getClient(realmlegit); | |
Map<String, Object> luser = c.getLoginUser(); | |
assertNotNull(luser); | |
assertEquals(user, luser.get(WampApiFieldKeys.U_EMAIL)); | |
out(luser); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
fail("Failed to return login \n\tUser: " + user + "\n\tURI: " + URI); | |
} | |
} | |
@Test | |
public void testUserRegistration() { | |
final String email = UUID.randomUUID().toString().substring(0, 10) + "@provagroup.com"; | |
final String password = UUID.randomUUID().toString().substring(0, 15); | |
registerUser(email, password); | |
} | |
@SuppressWarnings("unchecked") | |
private Map<String, Object> registerUser(String email, String password) { | |
String uu = null; | |
try { | |
LegitWampClient c = getClient(realmlegit); | |
assertNotNull(c); | |
Map<String, Object> args = new HashMap<String, Object>(); | |
args.put(WampApiFieldKeys.U_EMAIL, email ); | |
args.put(WampApiFieldKeys.U_PWNEW, password); | |
args.put(WampApiFieldKeys.U_FIRST, "Joe" ); | |
args.put(WampApiFieldKeys.U_LAST, "Test" ); | |
args.put(WampApiFieldKeys.U_MIDDLE, "M" ); | |
args.put(WampApiFieldKeys.U_TITLE, "Mr"); | |
Map<String, Object> add = getAddress("Chicago", "IL", "US"); | |
List<Map<String, Object>> adds = new ArrayList<>(); | |
adds.add(add); | |
args.put(WampApiFieldKeys.U_ADDRESS_LIST, adds); | |
Map<String, Object> ret = c.registerUser(args); | |
assertNotNull(ret); | |
out (String.format("User Created:\nEmail: %s\nName:%s%s%s%s%s", | |
ret.get(WampApiFieldKeys.U_EMAIL), | |
getName(ret.get(WampApiFieldKeys.U_TITLE)), | |
getName(ret.get(WampApiFieldKeys.U_FIRST)), | |
getName(ret.get(WampApiFieldKeys.U_MIDDLE)), | |
getName(ret.get(WampApiFieldKeys.U_LAST)), | |
getName(ret.get(WampApiFieldKeys.U_SUFFIX)))); | |
assertEquals(ret.get(WampApiFieldKeys.U_EMAIL), ret.get(WampApiFieldKeys.U_EMAIL)); | |
assertEquals(ret.get(WampApiFieldKeys.U_FIRST), ret.get(WampApiFieldKeys.U_FIRST)); | |
assertEquals(ret.get(WampApiFieldKeys.U_LAST), ret.get(WampApiFieldKeys.U_LAST)); | |
assertEquals(ret.get(WampApiFieldKeys.U_MIDDLE), ret.get(WampApiFieldKeys.U_MIDDLE)); | |
assertEquals(ret.get(WampApiFieldKeys.U_TITLE), ret.get(WampApiFieldKeys.U_TITLE)); | |
assertEquals(ret.get(WampApiFieldKeys.U_SUFFIX), ret.get(WampApiFieldKeys.U_SUFFIX)); | |
List<Map<String, Object>> list = (List<Map<String, Object>>) ret.get(WampApiFieldKeys.U_ADDRESS_LIST); | |
assertNotNull(list); | |
checkMaps(add, list.get(0)); | |
uu = email; | |
c = getClient(realmlegit, uu, password); | |
assertNotNull(c); | |
Map<String, Object> u = c.getLoginUser(); | |
assertNotNull(u); | |
assertEquals(ret.get(WampApiFieldKeys.U_EMAIL), u.get(WampApiFieldKeys.U_EMAIL)); | |
assertEquals(ret.get(WampApiFieldKeys.U_FIRST), u.get(WampApiFieldKeys.U_FIRST)); | |
assertEquals(ret.get(WampApiFieldKeys.U_LAST), u.get(WampApiFieldKeys.U_LAST)); | |
assertEquals(ret.get(WampApiFieldKeys.U_MIDDLE), u.get(WampApiFieldKeys.U_MIDDLE)); | |
assertEquals(ret.get(WampApiFieldKeys.U_TITLE), u.get(WampApiFieldKeys.U_TITLE)); | |
assertEquals(ret.get(WampApiFieldKeys.U_SUFFIX), u.get(WampApiFieldKeys.U_SUFFIX)); | |
checkMaps(add, ((List<Map<String, Object>>) ret.get(WampApiFieldKeys.U_ADDRESS_LIST)).get(0)); | |
out (String.format("User Created:\nEmail: %s\nName:%s%s%s%s%s", | |
u.get(WampApiFieldKeys.U_EMAIL), | |
getName(u.get(WampApiFieldKeys.U_TITLE)), | |
getName(u.get(WampApiFieldKeys.U_FIRST)), | |
getName(u.get(WampApiFieldKeys.U_MIDDLE)), | |
getName(u.get(WampApiFieldKeys.U_LAST)), | |
getName(u.get(WampApiFieldKeys.U_SUFFIX)))); | |
return u; | |
} catch (Exception e) { | |
e.printStackTrace(); | |
fail("Failed to login \n\tUser: " + uu + "\n\tURI: " + URI); | |
} | |
return null; | |
} | |
private void checkMaps(Map<String, Object> orig, Map<String, Object> val) { | |
assertNotNull(val); | |
assertTrue(val.size() > 0); | |
for (Entry<String, Object> e : orig.entrySet()) { | |
Object o = val.get(e.getKey()); | |
assertNotNull(o); | |
assertEquals(e.getValue(), o); | |
} | |
} | |
/** | |
* Simple address generation for a user. | |
* @param city | |
* @param state | |
* @param country | |
* @return | |
*/ | |
private Map<String, Object> getAddress(String city, String state, String country) { | |
Map<String, Object> m = new HashMap<>(); | |
m.put(WampApiFieldKeys.UA_TYPE, "home"); | |
m.put(WampApiFieldKeys.UA_COUNTRY, country); | |
if (state != null) { | |
m.put(WampApiFieldKeys.UA_STATE, state); | |
} | |
if (state != null) { | |
m.put(WampApiFieldKeys.UA_CITY, city); | |
m.put(WampApiFieldKeys.UA_ZIP_CODE, "00001"); | |
m.put(WampApiFieldKeys.UA_STREET, "1 Ave K"); | |
} | |
return m; | |
} | |
private String getName(Object o) { | |
return (String) (o == null ? "" : " " + o); | |
} | |
@Test | |
public void testGetMyAssets() { | |
try { | |
LegitWampClient c = getClient(realmlegit); | |
assertNotNull(c); | |
List<Object> ret = c.getMyCollection(); | |
assertNotNull(ret); | |
assertTrue(ret.size() > 0); | |
assertNotNull(ret.get(0)); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
fail(e.getMessage()); | |
} | |
} | |
@Test | |
public void testUpdatePassword() { | |
try { | |
final String email = UUID.randomUUID().toString().substring(0, 10) + "@provagroup.com"; | |
final String password = UUID.randomUUID().toString().substring(0, 15); | |
Map<String, Object> u = registerUser(email, password); | |
LegitWampClient c = getClient(realmlegit, email, password); | |
c.login(); | |
String newpw = UUID.randomUUID().toString().substring(5, 15); | |
try { | |
c.updatePassword((String) u.get(WampApiFieldKeys.U_ID), pw, newpw, newpw); | |
fail("Failed to catch an exception with bad password"); | |
} catch (Exception ignored) { | |
log.log(Level.INFO, ignored.getLocalizedMessage(), ignored); | |
} | |
try { | |
c.updatePassword((String) u.get(WampApiFieldKeys.U_ID), password, newpw, pw); | |
fail("Failed to catch an exception with bad password"); | |
} catch (Exception ignored) { | |
log.log(Level.INFO, ignored.getLocalizedMessage(), ignored); | |
} | |
try { | |
c.updatePassword((String) u.get(WampApiFieldKeys.U_ID), password, "aaa", "aaa"); | |
fail("Failed to catch an exception with bad password"); | |
} catch (Exception ignored) { | |
log.log(Level.INFO, ignored.getLocalizedMessage(), ignored); | |
} | |
c.updatePassword((String) u.get(WampApiFieldKeys.U_ID), password, newpw, newpw); | |
client = null; | |
c = getClient(realmlegit, email, password); | |
try { | |
c.login(); | |
fail("Logged in with existing password"); | |
} catch (Exception expected) { | |
} | |
client = null; | |
c = getClient(realmlegit, email, newpw); | |
c.login(); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
fail(e.getMessage()); | |
} | |
} | |
@Test | |
public void testGetAssetBySerialNo() { | |
try { | |
LegitWampClient c = getClient(realmlegit); | |
assertNotNull(c); | |
String serno = "Z4vuNK2vMI"; // test serial no. | |
// String serno = "Z5011A16AY"; // NPE on localhost | |
Map<String, Object> ret = c.getAsset(serno); | |
assertNotNull(ret); | |
assertTrue("Failed to return asset", ret.size() > 0); | |
assertEquals(serno, ret.get(WampApiFieldKeys.ASSET_SERIAL_NO)); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
fail(e.getMessage()); | |
} | |
} | |
@Test | |
public void testGetAssetByTagid() { | |
try { | |
LegitWampClient c = getClient(realmlegit); | |
assertNotNull(c); | |
String tagid = "049F2262EB2B80"; // test tagid | |
Map<String, Object> ret = c.getAssetByTagid(tagid); | |
assertNotNull(ret); | |
assertTrue("Failed to return asset", ret.size() > 0); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
fail(e.getMessage()); | |
} | |
} | |
@Test | |
public void testRegisterItem() { | |
try { | |
LegitWampClient c = getClient(realmlegit); | |
assertNotNull(c); | |
String serno = "Z4vuNK2vMI"; // test serial no. | |
String certserno = "Z4vuNK2vdk"; // test certificate serial no | |
Map<String, Object> ret = c.registerAsset(serno, certserno); | |
assertNotNull(ret); | |
assertTrue("Failed to return asset", ret.size() > 0); | |
assertEquals(serno, ret.get(WampApiFieldKeys.ASSET_SERIAL_NO)); | |
assertEquals(certserno, ret.get(WampApiFieldKeys.CERT_SERIAL_NO)); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
fail(e.getMessage()); | |
} | |
} | |
@Test | |
public void testForgotPassword() { | |
try { | |
LegitWampClient c = getClient(realmlegit); | |
assertNotNull(c); | |
String ret = c.forgotPassword("[email protected]"); | |
assertNotNull(ret); | |
out("ForgotPassword response: " + ret); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
fail(e.getMessage()); | |
} | |
} | |
@SuppressWarnings("unchecked") | |
@Test | |
public void testUpdateUserInfo() { | |
try { | |
LegitWampClient c = getClient(realmlegit); | |
assertNotNull(c); | |
Map<String, Object> u = c.getLoginUser(); | |
String newname = updateField(u, WampApiFieldKeys.U_LAST, "Jones", "Smith"); | |
Map<String, Object> u2 = c.updateUser(u); | |
assertEquals(newname, u2.get(WampApiFieldKeys.U_LAST)); | |
String newfirst = updateField(u2, WampApiFieldKeys.U_FIRST, "Joe", "Albert"); | |
Map<String, Object> u5 = c.updateUser(u2); | |
assertEquals(newfirst, u5.get(WampApiFieldKeys.U_FIRST)); | |
// Address set/update | |
List<Map<String, Object>> adds = (List<Map<String, Object>>) u5.get(WampApiFieldKeys.U_ADDRESS_LIST); | |
List<Map<String, Object>> nadds = new ArrayList<>(); | |
if (adds == null || adds.size() == 0) { | |
nadds.add(getAddress("Chicago", "IL", "US")); | |
} else { | |
Map<String, Object> oldadd = adds.remove(0); | |
nadds.add(updateAddress(oldadd , "Chicago", "Sprinfield", "IL", "MO")); | |
} | |
u5.put(WampApiFieldKeys.U_ADDRESS_LIST, nadds); | |
Map<String, Object> u6 = c.updateUser(u5); | |
Map<String, Object> oa = nadds.get(0); | |
Map<String, Object> ua = ((List<Map<String, Object>>)u6.get(WampApiFieldKeys.U_ADDRESS_LIST)).get(0); | |
System.out.println(String.format("Updated Address: %s, %s", ua.get(WampApiFieldKeys.UA_CITY), ua.get(WampApiFieldKeys.UA_STATE))); | |
assertEquals(oa.get(WampApiFieldKeys.UA_TYPE), ua.get(WampApiFieldKeys.UA_TYPE)); | |
assertEquals(oa.get(WampApiFieldKeys.UA_COUNTRY), ua.get(WampApiFieldKeys.UA_COUNTRY)); | |
assertEquals(oa.get(WampApiFieldKeys.UA_CITY), ua.get(WampApiFieldKeys.UA_CITY)); | |
assertEquals(oa.get(WampApiFieldKeys.UA_STATE), ua.get(WampApiFieldKeys.UA_STATE)); | |
Map<String, Object> uu = c.getLoginUser(); | |
Map<String, Object> uua = ((List<Map<String, Object>>)uu.get(WampApiFieldKeys.U_ADDRESS_LIST)).get(0); | |
System.out.println(String.format("LoginUser Address: %s, %s", uua.get(WampApiFieldKeys.UA_CITY), uua.get(WampApiFieldKeys.UA_STATE))); | |
assertEquals(oa.get(WampApiFieldKeys.UA_TYPE), uua.get(WampApiFieldKeys.UA_TYPE)); | |
assertEquals(oa.get(WampApiFieldKeys.UA_COUNTRY), uua.get(WampApiFieldKeys.UA_COUNTRY)); | |
assertEquals(oa.get(WampApiFieldKeys.UA_CITY), uua.get(WampApiFieldKeys.UA_CITY)); | |
assertEquals(oa.get(WampApiFieldKeys.UA_STATE), uua.get(WampApiFieldKeys.UA_STATE)); | |
// TODO: the next 2 items are considered future features: | |
String newprofile = updateField(u6, WampApiFieldKeys.U_PROFILE_IMG, "/someimage", "/myotherimg"); | |
Map<String, Object> u3 = c.updateUser(u6); | |
assertEquals(newprofile, u3.get(WampApiFieldKeys.U_PROFILE_IMG)); | |
String newcover = updateField(u3, WampApiFieldKeys.U_COVER_IMG, "/someimage", "/myotherimg"); | |
Map<String, Object> u4 = c.updateUser(u3); | |
assertEquals(newcover, u4.get(WampApiFieldKeys.U_COVER_IMG)); | |
} catch (Exception e) { | |
e.printStackTrace(); | |
fail(e.getMessage()); | |
} | |
} | |
private Map<String, Object> updateAddress(Map<String, Object> source, String c1, String c2, String s1, String s2) { | |
if (source != null && source.get(WampApiFieldKeys.UA_STATE) != null) { | |
System.out.println(String.format("Old Address: %s, %s", source.get(WampApiFieldKeys.UA_CITY), source.get(WampApiFieldKeys.UA_STATE))); | |
} else { | |
System.out.println("Old Address is null"); | |
} | |
Map<String, Object> m = new HashMap<>(); | |
m.put(WampApiFieldKeys.UA_TYPE, "home"); | |
putAddress(WampApiFieldKeys.UA_COUNTRY, source, m); | |
putAddress(WampApiFieldKeys.UA_ZIP_CODE, source, m); | |
putAddress(WampApiFieldKeys.UA_STREET, source, m); | |
updateAddress(WampApiFieldKeys.UA_STATE, source, m, s1, s2); | |
updateAddress(WampApiFieldKeys.UA_CITY, source, m, c1, c2); | |
return m; | |
} | |
private void putAddress(String key, Map<String, Object> source, Map<String, Object> m) { | |
String val = (String) source.get(key); | |
String v; | |
if (val != null && (v = val.trim()).length() > 0) { | |
m.put(key, v); | |
} | |
} | |
private void updateAddress(String key, Map<String, Object> source, Map<String, Object> m, String val1, String val2) { | |
final String val = (String) source.get(key); | |
final String v; | |
final String v1 = val1.trim(); | |
if (val != null && (v = val.trim()).length() > 0) { | |
if (v.compareTo(v1) == 0) { | |
m.put(key, val2.trim()); | |
} else { | |
m.put(key, v1); | |
} | |
} else { | |
m.put(key, v1); | |
} | |
} | |
@Test //clears images stored for asset. | |
public void testAssetImageUpdate() { | |
try { | |
LegitWampClient c = getClient(realmlegit); | |
assertNotNull(c); | |
// Map<String, Object> args = new HashMap<>(); | |
// Object o = c.getError(args); | |
// assertNotNull(o); | |
// Map<String, Object> a = c.getAsset("Z5011A16JC"); // P4 | |
// Map<String, Object> a = c.getAsset("Z4vuNK2vKc"); // Local | |
Map<String, Object> a = c.getAsset("Z4vuNK2vRY"); // P4 & Local | |
// Map<String, Object> a = c.getAsset("NONE-Z4vuNK2vRY"); // Error | |
assertNotNull(a); | |
// List<String> newimgs = updateImages(a, WampApiFieldKeys.ASSET_IMAGES); | |
// Map<String, Object> a2 = c.updateAsset(a); | |
// if (newimgs.size() > 0) { | |
// assertEquals(newimgs, a2.get(WampApiFieldKeys.ASSET_IMAGES)); | |
// } | |
} catch (Exception e) { | |
e.printStackTrace(); | |
fail(e.getMessage()); | |
} | |
} | |
@SuppressWarnings("unchecked") | |
@Test | |
public void testPhotoUpload() { | |
try { | |
File testPic = new File("/Users/rbense/Pictures/IMG_0858.jpg"); | |
if (!testPic.exists()) { | |
fail("The test image does not exist " + testPic); | |
} | |
LegitWampClient c = getClient(realmlegit); | |
assertNotNull(c); | |
// List<Object> assets = c.getMyCollection(); | |
// | |
// Map<String, Object> a = null; | |
// String serialNo = null; | |
// for (Object o : assets) { | |
// Map<String, Object> m = (Map<String, Object>) o; | |
// serialNo = (String) m.get(WampApiFieldKeys.ASSET_SERIAL_NO); | |
// if (serialNo != null) { | |
// a = m; | |
// break; | |
// } | |
// } | |
// Get asset for test serial no | |
String serialNo = "Z4vuNK2vKc"; // Local | |
Map<String, Object> a = c.getAsset(serialNo); | |
if (a == null) { | |
fail("Did not find an asset for this user with a serial number to test against"); | |
} | |
final String assetId = (String) a.get(WampApiFieldKeys.ASSET_ID); | |
// upload image for asset | |
Map<String, Object> args = new HashMap<>(); | |
args.put(WampApiFieldKeys.MEDIA_TYPE, WampApiFieldKeys.MEDIA_PHOTO_JPG); | |
args.put(WampApiFieldKeys.MEDIA_TARGET_ASSET, assetId ); | |
Map<String, Object> ret = c.uploadMedia(args, testPic); | |
// test that the image was uploaded and the asset updated with the uploaded image. | |
assertNotNull(ret); | |
assertNotNull(ret.get(WampApiFieldKeys.MEDIA_URL)); | |
FileOutputStream fos = null; | |
try { | |
final String url = (String) ret.get(WampApiFieldKeys.MEDIA_URL); | |
URL website = new URL(url); | |
ReadableByteChannel rbc = Channels.newChannel(website.openStream()); | |
File ofile = new File((String) ret.get(WampApiFieldKeys.MEDIA_FILE_NAME)); | |
System.out.println(String.format("\nSaving %s to %s", website, ofile)); | |
fos = new FileOutputStream(ofile); | |
fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); | |
assertTrue(ofile.getAbsolutePath(), ofile.length() == testPic.length()); | |
System.out.println(String.format("\n\nSERNO: %s", serialNo)); | |
Map<String, Object> a2 = c.getAsset(serialNo); | |
final List<String> imgs = (List<String>) a2.get(WampApiFieldKeys.ASSET_IMAGES); | |
assertNotNull(imgs); | |
assertTrue(imgs.size() > 0); | |
for (String s : imgs) { | |
if (url.compareTo(s) == 0) { | |
return; | |
} | |
} | |
fail("Did not have image returned"); | |
} catch (Exception e) { | |
fail("Failed to confirm URL exists and file matches upload"); | |
} finally { | |
if (fos != null) { | |
fos.close(); | |
} | |
} | |
} catch (Exception e) { | |
e.printStackTrace(); | |
fail(e.getMessage()); | |
} | |
} | |
private String updateField(Map<String, Object> u, String key, String val1, String val2) { | |
String val = (String) u.get(key); | |
String nval = val != null && val.compareTo(val1) == 0 ? val2 : val1; | |
u.put(key, nval); | |
return nval; | |
} | |
private List<String> updateImages(Map<String, Object> u, String key, String ... val1) { | |
@SuppressWarnings("unchecked") | |
List<Object> val = (List<Object>) u.get(key); | |
List<String> imgs = new ArrayList<>(); | |
if (val == null || !Arrays.equals(val.toArray(), val1)) { | |
for (int i = 0; i < val1.length; i++) { | |
imgs.add(val1[i]); | |
} | |
} else { | |
final int max = val1.length - 1; | |
for (int i = 0; i < val1.length; i++) { | |
imgs.add(val1[max - i]); | |
} | |
} | |
u.put(key, imgs); | |
return imgs; | |
} | |
final LegitWampClient getClient(String realm) { | |
return getClient(realm, user, pw); | |
} | |
final LegitWampClient getClient(String realm, String user, String pw) { | |
if (client == null || realm.compareTo(client.getRealm()) != 0 || user.compareTo(client.getClientUser()) != 0) { | |
client = LegitWampClient.getClient(URI, realm, user, pw); | |
} | |
return client; | |
} | |
private static void out(Object e) { | |
// System.out.println(e); | |
// System.out.flush(); | |
log.info(e.toString()); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment