1
0
mirror of https://github.com/etesync/android synced 2024-12-23 07:08:16 +00:00

Version bump to 0.5.3-alpha

* add null checks (should fix #117)
* more detailed DAV exceptions for error handling
* better logging (limited to 10 kB per log entry) to avoid memory problems
* DavMultiget creates requests itself (instead of WebDavResource)
This commit is contained in:
rfc2822 2013-12-15 19:45:28 +01:00
parent ec4fedf04e
commit 6cfaad35b1
18 changed files with 246 additions and 143 deletions

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="at.bitfire.davdroid"
android:versionCode="20"
android:versionName="0.5.2-alpha" >
android:versionCode="21"
android:versionName="0.5.3-alpha" >
<uses-sdk
android:minSdkVersion="14"

View File

@ -0,0 +1,58 @@
package at.bitfire.davdroid;
import java.io.ByteArrayOutputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import android.util.Log;
public class LoggingInputStream extends FilterInputStream {
private static final int MAX_LENGTH = 10*1024; // don't log more than 10 kB of data
String tag;
ByteArrayOutputStream log = new ByteArrayOutputStream(MAX_LENGTH);
int logSize = 0;
public LoggingInputStream(String tag, InputStream proxy) {
super(proxy);
this.tag = tag;
}
@Override
public boolean markSupported() {
return false;
}
@Override
public int read() throws IOException {
int b = super.read();
if (logSize < MAX_LENGTH) {
log.write(b);
logSize++;
}
return b;
}
@Override
public int read(byte[] buffer, int byteOffset, int byteCount)
throws IOException {
int read = super.read(buffer, byteOffset, byteCount);
int bytesToLog = read;
if (bytesToLog + logSize > MAX_LENGTH)
bytesToLog = MAX_LENGTH - logSize;
if (bytesToLog > 0) {
log.write(buffer, byteOffset, bytesToLog);
logSize += bytesToLog;
}
return read;
}
@Override
public void close() throws IOException {
Log.d(tag, "Content: " + log.toString());
super.close();
}
}

View File

@ -9,7 +9,7 @@ package at.bitfire.davdroid.resource;
import java.net.URISyntaxException;
import at.bitfire.davdroid.webdav.WebDavResource.MultigetType;
import at.bitfire.davdroid.webdav.DavMultiget;
public class CalDavCalendar extends RemoteCollection<Event> {
//private final static String TAG = "davdroid.CalDavCalendar";
@ -20,8 +20,8 @@ public class CalDavCalendar extends RemoteCollection<Event> {
}
@Override
protected MultigetType multiGetType() {
return MultigetType.CALENDAR;
protected DavMultiget.Type multiGetType() {
return DavMultiget.Type.CALENDAR;
}
@Override

View File

@ -9,7 +9,7 @@ package at.bitfire.davdroid.resource;
import java.net.URISyntaxException;
import at.bitfire.davdroid.webdav.WebDavResource.MultigetType;
import at.bitfire.davdroid.webdav.DavMultiget;
public class CardDavAddressBook extends RemoteCollection<Contact> {
//private final static String TAG = "davdroid.CardDavAddressBook";
@ -20,8 +20,8 @@ public class CardDavAddressBook extends RemoteCollection<Contact> {
}
@Override
protected MultigetType multiGetType() {
return MultigetType.ADDRESS_BOOK;
protected DavMultiget.Type multiGetType() {
return DavMultiget.Type.ADDRESS_BOOK;
}
@Override

View File

@ -192,11 +192,11 @@ public class Event extends Resource {
if (exdate != null)
props.add(exdate);
if (summary != null)
if (summary != null && !summary.isEmpty())
props.add(new Summary(summary));
if (location != null)
if (location != null && !location.isEmpty())
props.add(new Location(location));
if (description != null)
if (description != null && !description.isEmpty())
props.add(new Description(description));
if (status != null)

View File

@ -10,7 +10,6 @@ package at.bitfire.davdroid.resource;
import java.util.ArrayList;
import java.util.LinkedList;
import net.fortuna.ical4j.model.ValidationException;
import android.accounts.Account;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
@ -72,8 +71,11 @@ public abstract class LocalCollection<T extends Resource> {
new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() },
where, null, null);
LinkedList<T> dirty = new LinkedList<T>();
while (cursor != null && cursor.moveToNext())
dirty.add(findById(cursor.getLong(0), true));
while (cursor != null && cursor.moveToNext()) {
T resource = findById(cursor.getLong(0), true);
if (resource != null)
dirty.add(resource);
}
return dirty.toArray(new Resource[0]);
}
@ -85,8 +87,11 @@ public abstract class LocalCollection<T extends Resource> {
new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() },
where, null, null);
LinkedList<T> deleted = new LinkedList<T>();
while (cursor != null && cursor.moveToNext())
deleted.add(findById(cursor.getLong(0), false));
while (cursor != null && cursor.moveToNext()) {
T resource = findById(cursor.getLong(0), false);
if (resource != null)
deleted.add(resource);
}
return deleted.toArray(new Resource[0]);
}
@ -100,16 +105,18 @@ public abstract class LocalCollection<T extends Resource> {
LinkedList<T> fresh = new LinkedList<T>();
while (cursor != null && cursor.moveToNext()) {
T resource = findById(cursor.getLong(0), true);
resource.initialize();
// new record: set generated UID + remote file name in database
pendingOperations.add(ContentProviderOperation
.newUpdate(ContentUris.withAppendedId(entriesURI(), resource.getLocalID()))
.withValue(entryColumnUID(), resource.getUid())
.withValue(entryColumnRemoteName(), resource.getName())
.build());
fresh.add(resource);
if (resource != null) {
resource.initialize();
// new record: set generated UID + remote file name in database
pendingOperations.add(ContentProviderOperation
.newUpdate(ContentUris.withAppendedId(entriesURI(), resource.getLocalID()))
.withValue(entryColumnUID(), resource.getUid())
.withValue(entryColumnRemoteName(), resource.getName())
.build());
fresh.add(resource);
}
}
return fresh.toArray(new Resource[0]);
}
@ -162,17 +169,18 @@ public abstract class LocalCollection<T extends Resource> {
addDataRows(resource, -1, idx);
}
public void updateByRemoteName(Resource remoteResource) throws RemoteException, ValidationException {
public void updateByRemoteName(Resource remoteResource) throws RemoteException {
T localResource = findByRemoteName(remoteResource.getName(), false);
pendingOperations.add(
buildEntry(ContentProviderOperation.newUpdate(ContentUris.withAppendedId(entriesURI(), localResource.getLocalID())), remoteResource)
.withValue(entryColumnETag(), remoteResource.getETag())
.withYieldAllowed(true)
.build());
removeDataRows(localResource);
addDataRows(remoteResource, localResource.getLocalID(), -1);
if (localResource != null) {
pendingOperations.add(
buildEntry(ContentProviderOperation.newUpdate(ContentUris.withAppendedId(entriesURI(), localResource.getLocalID())), remoteResource)
.withValue(entryColumnETag(), remoteResource.getETag())
.withYieldAllowed(true)
.build());
removeDataRows(localResource);
addDataRows(remoteResource, localResource.getLocalID(), -1);
}
}
public void delete(Resource resource) {

View File

@ -14,19 +14,21 @@ import java.net.URISyntaxException;
import java.util.LinkedList;
import java.util.List;
import lombok.Cleanup;
import lombok.Getter;
import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.ValidationException;
import org.apache.http.HttpException;
import ezvcard.VCardException;
import android.util.Log;
import at.bitfire.davdroid.LoggingInputStream;
import at.bitfire.davdroid.webdav.DAVException;
import at.bitfire.davdroid.webdav.DavMultiget;
import at.bitfire.davdroid.webdav.HttpPropfind;
import at.bitfire.davdroid.webdav.InvalidDavResponseException;
import at.bitfire.davdroid.webdav.WebDavResource;
import at.bitfire.davdroid.webdav.WebDavResource.MultigetType;
import at.bitfire.davdroid.webdav.WebDavResource.PutMode;
import ezvcard.VCardException;
public abstract class RemoteCollection<T extends Resource> {
private static final String TAG = "davdroid.RemoteCollection";
@ -34,7 +36,7 @@ public abstract class RemoteCollection<T extends Resource> {
@Getter WebDavResource collection;
abstract protected String memberContentType();
abstract protected MultigetType multiGetType();
abstract protected DavMultiget.Type multiGetType();
abstract protected T newResourceSkeleton(String name, String ETag);
public RemoteCollection(String baseURL, String user, String password, boolean preemptiveAuth) throws URISyntaxException {
@ -48,13 +50,13 @@ public abstract class RemoteCollection<T extends Resource> {
try {
if (collection.getCTag() == null && collection.getMembers() == null) // not already fetched
collection.propfind(HttpPropfind.Mode.COLLECTION_CTAG);
} catch (InvalidDavResponseException e) {
} catch (DAVException e) {
return null;
}
return collection.getCTag();
}
public Resource[] getMemberETags() throws IOException, InvalidDavResponseException, HttpException {
public Resource[] getMemberETags() throws IOException, DAVException, HttpException {
collection.propfind(HttpPropfind.Mode.MEMBERS_ETAG);
List<T> resources = new LinkedList<T>();
@ -67,7 +69,7 @@ public abstract class RemoteCollection<T extends Resource> {
}
@SuppressWarnings("unchecked")
public Resource[] multiGet(Resource[] resources) throws IOException, InvalidDavResponseException, HttpException {
public Resource[] multiGet(Resource[] resources) throws IOException, DAVException, HttpException {
try {
if (resources.length == 1) {
Resource resource = get(resources[0]);
@ -78,7 +80,7 @@ public abstract class RemoteCollection<T extends Resource> {
for (Resource resource : resources)
names.add(resource.getName());
collection.multiGet(names.toArray(new String[0]), multiGetType());
collection.multiGet(multiGetType(), names.toArray(new String[0]));
LinkedList<T> foundResources = new LinkedList<T>();
if (collection.getMembers() != null)
@ -116,7 +118,9 @@ public abstract class RemoteCollection<T extends Resource> {
public Resource get(Resource resources) throws IOException, HttpException, ParserException, VCardException {
WebDavResource member = new WebDavResource(collection, resources.getName());
member.get();
resources.parseEntity(member.getContent());
@Cleanup InputStream loggedContent = new LoggingInputStream(TAG, member.getContent());
resources.parseEntity(loggedContent);
return resources;
}

View File

@ -23,7 +23,7 @@ import android.provider.Settings;
import android.util.Log;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.RemoteCollection;
import at.bitfire.davdroid.webdav.InvalidDavResponseException;
import at.bitfire.davdroid.webdav.DAVException;
public abstract class DavSyncAdapter extends AbstractThreadedSyncAdapter {
private final static String TAG = "davdroid.DavSyncAdapter";
@ -72,7 +72,7 @@ public abstract class DavSyncAdapter extends AbstractThreadedSyncAdapter {
} catch (AuthenticationException ex) {
syncResult.stats.numAuthExceptions++;
Log.e(TAG, "HTTP authorization error", ex);
} catch (InvalidDavResponseException ex) {
} catch (DAVException ex) {
syncResult.stats.numParseExceptions++;
Log.e(TAG, "Invalid DAV response", ex);
} catch (HttpException ex) {

View File

@ -148,15 +148,11 @@ public class SyncManager {
local.commit();
}
Log.i(TAG, "Updating " + resourcesToUpdate.size() + " remote resource(s)");
Log.i(TAG, "Updating from " + resourcesToUpdate.size() + " remote resource(s)");
if (!resourcesToUpdate.isEmpty())
for (Resource res : dav.multiGet(resourcesToUpdate.toArray(new Resource[0]))) {
Log.i(TAG, "Updating " + res.getName());
try {
local.updateByRemoteName(res);
} catch (ValidationException ex) {
Log.e(TAG, "Ignoring invalid remote resource: " + res.getName(), ex);
}
local.updateByRemoteName(res);
if (++syncResult.stats.numUpdates % MAX_UPDATES_BEFORE_COMMIT == 0) // avoid TransactionTooLargeException
local.commit();

View File

@ -0,0 +1,18 @@
package at.bitfire.davdroid.webdav;
import org.apache.http.HttpException;
public class DAVException extends HttpException {
private static final long serialVersionUID = -2118919144443165706L;
final private static String prefix = "Invalid DAV response: ";
public DAVException(String message) {
super(prefix + message);
}
public DAVException(String message, Throwable ex) {
super(prefix + message, ex);
}
}

View File

@ -0,0 +1,11 @@
package at.bitfire.davdroid.webdav;
public class DAVNoContentException extends DAVException {
private static final long serialVersionUID = 6256645020350945477L;
private final static String message = "HTTP response entity (content) expected but not received";
public DAVNoContentException() {
super(message);
}
}

View File

@ -0,0 +1,11 @@
package at.bitfire.davdroid.webdav;
public class DAVNoMultiStatusException extends DAVException {
private static final long serialVersionUID = -3600405724694229828L;
private final static String message = "207 Multi-Status expected but not received";
public DAVNoMultiStatusException() {
super(message);
}
}

View File

@ -7,6 +7,7 @@
******************************************************************************/
package at.bitfire.davdroid.webdav;
import java.util.ArrayList;
import java.util.List;
import org.simpleframework.xml.Element;
@ -15,9 +16,33 @@ import org.simpleframework.xml.Order;
@Order(elements={"prop","href"})
public class DavMultiget {
public enum Type {
ADDRESS_BOOK,
CALENDAR
}
@Element
DavProp prop;
@ElementList(inline=true)
List<DavHref> hrefs;
public static DavMultiget newRequest(Type type, String names[]) {
DavMultiget multiget = (type == Type.ADDRESS_BOOK) ? new DavAddressbookMultiget() : new DavCalendarMultiget();
multiget.prop = new DavProp();
multiget.prop.getetag = new DavProp.DavPropGetETag();
if (type == Type.ADDRESS_BOOK)
multiget.prop.addressData = new DavProp.DavPropAddressData();
else if (type == Type.CALENDAR)
multiget.prop.calendarData = new DavProp.DavPropCalendarData();
multiget.hrefs = new ArrayList<DavHref>(names.length);
for (String name : names)
multiget.hrefs.add(new DavHref(name));
return multiget;
}
}

View File

@ -1,11 +0,0 @@
package at.bitfire.davdroid.webdav;
import org.apache.http.HttpException;
public class InvalidDavResponseException extends HttpException {
private static final long serialVersionUID = -2118919144443165706L;
public InvalidDavResponseException(String message) {
super("Invalid DAV response: " + message);
}
}

View File

@ -61,7 +61,7 @@ public class TlsSniSocketFactory implements LayeredSocketFactory {
// set up SNI before the handshake
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
Log.i(TAG, "Setting SNI hostname");
Log.d(TAG, "Setting SNI hostname");
sslSocketFactory.setHostname(ssl, host);
} else
Log.i(TAG, "No SNI support below Android 4.2!");

View File

@ -8,13 +8,11 @@
package at.bitfire.davdroid.webdav;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
@ -22,12 +20,13 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import lombok.Cleanup;
import lombok.Getter;
import lombok.ToString;
import org.apache.commons.io.input.TeeInputStream;
import org.apache.commons.lang.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpException;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
@ -46,6 +45,7 @@ import org.simpleframework.xml.Serializer;
import org.simpleframework.xml.core.Persister;
import android.util.Log;
import at.bitfire.davdroid.LoggingInputStream;
import at.bitfire.davdroid.URIUtils;
import at.bitfire.davdroid.resource.Event;
import at.bitfire.davdroid.webdav.DavProp.DavPropComp;
@ -64,10 +64,6 @@ public class WebDavResource {
CTAG, ETAG,
CONTENT_TYPE
}
public enum MultigetType {
ADDRESS_BOOK,
CALENDAR
}
public enum PutMode {
ADD_DONT_OVERWRITE,
UPDATE_DONT_OVERWRITE
@ -142,6 +138,8 @@ public class WebDavResource {
protected void checkResponse(StatusLine statusLine) throws HttpException {
int code = statusLine.getStatusCode();
Log.d(TAG, "Received " + statusLine.getProtocolVersion() + " " + code + " " + statusLine.getReasonPhrase());
if (code/100 == 1 || code/100 == 2) // everything OK
return;
@ -252,52 +250,38 @@ public class WebDavResource {
/* collection operations */
public void propfind(HttpPropfind.Mode mode) throws IOException, InvalidDavResponseException, HttpException {
public void propfind(HttpPropfind.Mode mode) throws IOException, DAVException, HttpException {
HttpPropfind propfind = new HttpPropfind(location, mode);
HttpResponse response = client.execute(propfind);
checkResponse(response);
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_MULTI_STATUS) {
InputStream content = response.getEntity().getContent();
if (content == null)
throw new InvalidDavResponseException("Multistatus response without content");
if (response.getStatusLine().getStatusCode() != HttpStatus.SC_MULTI_STATUS)
throw new DAVNoMultiStatusException();
// duplicate content for logging
ByteArrayOutputStream logStream = new ByteArrayOutputStream();
InputStream is = new TeeInputStream(content, logStream);
DavMultistatus multistatus;
try {
Serializer serializer = new Persister();
multistatus = serializer.read(DavMultistatus.class, is, false);
} catch (Exception ex) {
Log.w(TAG, "Invalid PROPFIND XML response", ex);
throw new InvalidDavResponseException("Invalid PROPFIND response");
} finally {
Log.d(TAG, "Received multistatus response:\n" + logStream.toString("UTF-8"));
is.close();
content.close();
}
processMultiStatus(multistatus);
} else
throw new InvalidDavResponseException("Multistatus response expected");
@Cleanup("consumeContent") HttpEntity entity = response.getEntity();
if (entity == null)
throw new DAVNoContentException();
InputStream content = entity.getContent();
if (content == null)
throw new DAVNoContentException();
@Cleanup LoggingInputStream loggedContent = new LoggingInputStream(TAG, content);
DavMultistatus multistatus;
try {
Serializer serializer = new Persister();
multistatus = serializer.read(DavMultistatus.class, loggedContent, false);
} catch (Exception ex) {
throw new DAVException("Couldn't parse Multi-Status response on PROPFIND", ex);
}
processMultiStatus(multistatus);
}
public void multiGet(String[] names, MultigetType type) throws IOException, InvalidDavResponseException, HttpException {
DavMultiget multiget = (type == MultigetType.ADDRESS_BOOK) ? new DavAddressbookMultiget() : new DavCalendarMultiget();
multiget.prop = new DavProp();
multiget.prop.getetag = new DavProp.DavPropGetETag();
if (type == MultigetType.ADDRESS_BOOK)
multiget.prop.addressData = new DavProp.DavPropAddressData();
else if (type == MultigetType.CALENDAR)
multiget.prop.calendarData = new DavProp.DavPropCalendarData();
multiget.hrefs = new ArrayList<DavHref>(names.length);
public void multiGet(DavMultiget.Type type, String[] names) throws IOException, DAVException, HttpException {
List<String> hrefs = new LinkedList<String>();
for (String name : names)
multiget.hrefs.add(new DavHref(location.resolve(name).getRawPath()));
hrefs.add(location.resolve(name).getRawPath());
DavMultiget multiget = DavMultiget.newRequest(type, hrefs.toArray(new String[0]));
Serializer serializer = new Persister();
StringWriter writer = new StringWriter();
@ -305,38 +289,32 @@ public class WebDavResource {
serializer.write(multiget, writer);
} catch (Exception ex) {
Log.e(TAG, "Couldn't create XML multi-get request", ex);
throw new InvalidDavResponseException("Couldn't create multi-get request");
throw new DAVException("Couldn't create multi-get request");
}
HttpReport report = new HttpReport(location, writer.toString());
HttpResponse response = client.execute(report);
checkResponse(response);
if (response.getStatusLine().getStatusCode() == HttpStatus.SC_MULTI_STATUS) {
InputStream content = response.getEntity().getContent();
if (content == null)
throw new InvalidDavResponseException("Multistatus response without content");
DavMultistatus multistatus;
// duplicate content for logging
ByteArrayOutputStream logStream = new ByteArrayOutputStream();
InputStream is = new TeeInputStream(content, logStream, true);
try {
multistatus = serializer.read(DavMultistatus.class, is, false);
} catch (Exception ex) {
Log.e(TAG, "Couldn't parse multi-get response", ex);
throw new InvalidDavResponseException("Invalid multi-get response");
} finally {
Log.d(TAG, "Received multistatus response:\n" + logStream.toString("UTF-8"));
is.close();
content.close();
}
processMultiStatus(multistatus);
} else
throw new InvalidDavResponseException("Multistatus response expected");
if (response.getStatusLine().getStatusCode() != HttpStatus.SC_MULTI_STATUS)
throw new DAVNoMultiStatusException();
@Cleanup("consumeContent") HttpEntity entity = response.getEntity();
if (entity == null)
throw new DAVNoContentException();
InputStream content = entity.getContent();
if (content == null)
throw new DAVNoContentException();
@Cleanup LoggingInputStream loggedContent = new LoggingInputStream(TAG, content);
DavMultistatus multiStatus;
try {
multiStatus = serializer.read(DavMultistatus.class, loggedContent, false);
} catch (Exception ex) {
throw new DAVException("Couldn't parse Multi-Status response on REPORT multi-get", ex);
}
processMultiStatus(multiStatus);
}
@ -473,4 +451,5 @@ public class WebDavResource {
this.members = members;
}
}

View File

@ -13,7 +13,11 @@ public class LombokTest extends TestCase {
}
public void testNonNull() {
assertNull(appendSlash(null));
//assertEquals("1/", appendSlash("1"));
try {
appendSlash(null);
fail();
} catch(NullPointerException e) {
}
assertEquals("1/", appendSlash("1"));
}
}

View File

@ -10,12 +10,12 @@ import org.apache.http.HttpException;
import android.content.res.AssetManager;
import android.test.InstrumentationTestCase;
import at.bitfire.davdroid.webdav.DAVException;
import at.bitfire.davdroid.webdav.DavMultiget;
import at.bitfire.davdroid.webdav.HttpPropfind;
import at.bitfire.davdroid.webdav.InvalidDavResponseException;
import at.bitfire.davdroid.webdav.NotFoundException;
import at.bitfire.davdroid.webdav.PreconditionFailedException;
import at.bitfire.davdroid.webdav.WebDavResource;
import at.bitfire.davdroid.webdav.WebDavResource.MultigetType;
import at.bitfire.davdroid.webdav.WebDavResource.PutMode;
// tests require running robohydra!
@ -88,7 +88,7 @@ public class WebDavResourceTest extends InstrumentationTestCase {
try {
simpleFile.propfind(HttpPropfind.Mode.CURRENT_USER_PRINCIPAL);
fail();
} catch(InvalidDavResponseException ex) {
} catch(DAVException ex) {
}
assertNull(simpleFile.getCurrentUserPrincipal());
}
@ -147,9 +147,9 @@ public class WebDavResourceTest extends InstrumentationTestCase {
));
}
public void testMultiGet() throws InvalidDavResponseException, IOException, HttpException {
public void testMultiGet() throws DAVException, IOException, HttpException {
WebDavResource davAddressBook = new WebDavResource(davCollection, "addressbooks/default.vcf", true);
davAddressBook.multiGet(new String[] { "1.vcf", "2.vcf" }, MultigetType.ADDRESS_BOOK);
davAddressBook.multiGet(DavMultiget.Type.ADDRESS_BOOK, new String[] { "1.vcf", "2.vcf" });
assertEquals(2, davAddressBook.getMembers().size());
for (WebDavResource member : davAddressBook.getMembers()) {
assertNotNull(member.getContent());