1package eu.siacs.conversations.persistance;
2
3import android.content.ContentValues;
4import android.database.sqlite.SQLiteBlobTooBigException;
5import android.database.sqlite.SQLiteDatabase;
6import android.util.Log;
7import androidx.test.core.app.ApplicationProvider;
8import androidx.test.ext.junit.runners.AndroidJUnit4;
9
10import org.json.JSONException;
11import org.json.JSONObject;
12import org.junit.After;
13import org.junit.Assert;
14import org.junit.Before;
15import org.junit.BeforeClass;
16import org.junit.Test;
17import org.junit.runner.RunWith;
18
19import java.util.HashMap;
20import java.util.UUID;
21
22import eu.siacs.conversations.entities.Account;
23import eu.siacs.conversations.entities.Conversation;
24import eu.siacs.conversations.entities.MucOptions;
25import eu.siacs.conversations.xml.Namespace;
26import im.conversations.android.xmpp.model.disco.info.Feature;
27import im.conversations.android.xmpp.model.disco.info.InfoQuery;
28
29@RunWith(AndroidJUnit4.class)
30public class DatabaseBackendTest {
31 private record AccountFixture(String uuid, String username, String server) {
32 void write(DatabaseBackend db) {
33 final var cv = new ContentValues();
34 cv.put("uuid", uuid);
35 cv.put("username", username);
36 cv.put("server", server);
37 cv.put("password", "test");
38 cv.put("options", 0);
39 db.getWritableDatabase().insertWithOnConflict(
40 "accounts", null, cv, SQLiteDatabase.CONFLICT_REPLACE);
41 }
42 }
43
44 private record ConversationFixture(
45 String conversationUuid,
46 AccountFixture account,
47 String name,
48 String contactJid,
49 String attributes,
50 HashMap<MucOptions.User.OccupantId, MucOptions.User.CacheEntry> occupantCache
51 ) {
52 void writeConversation(DatabaseBackend db) {
53 final var cv = new ContentValues();
54 cv.put("uuid", conversationUuid);
55 cv.put("name", name);
56 cv.put("contactUuid", "");
57 cv.put("accountUuid", account.uuid());
58 cv.put("contactJid", contactJid);
59 cv.put("created", System.currentTimeMillis());
60 cv.put("status", Conversation.STATUS_AVAILABLE);
61 cv.put("mode", Conversation.MODE_MULTI);
62 cv.put("attributes", attributes);
63 db.getWritableDatabase().insert("conversations", null, cv);
64 }
65
66 void writeOccupants(DatabaseBackend db) {
67 for (final var entry : occupantCache.entrySet()) {
68 final var cv = new ContentValues();
69 cv.put(MucOptions.User.CacheEntry.OCCUPANT_ID, entry.getKey().inner());
70 cv.put(MucOptions.User.CacheEntry.CONVERSATION_UUID, conversationUuid);
71 cv.put(MucOptions.User.CacheEntry.AVATAR, entry.getValue().avatar());
72 cv.put(MucOptions.User.CacheEntry.NICK, entry.getValue().nick());
73 db.getWritableDatabase().insert(
74 MucOptions.User.CacheEntry.TABLENAME, null, cv);
75 }
76 }
77
78 void writeAll(DatabaseBackend db) {
79 writeConversation(db);
80 writeOccupants(db);
81 }
82
83 Conversation extractAndConfigure(DatabaseBackend db)
84 {
85 final var conversations = db.getConversations(Conversation.STATUS_AVAILABLE);
86 Assert.assertNotNull("getConversations should not return null", conversations);
87
88 Conversation match = null;
89 for (final var c : conversations) {
90 if (conversationUuid.equals(c.getUuid())) {
91 match = c;
92 break;
93 }
94 }
95 Assert.assertNotNull(
96 "Fixture conversation " + conversationUuid + " not found", match);
97
98 match.setAccount(db.getAccounts().get(0));
99 match.getMucOptions().updateConfiguration(INFO_QUERY_WITH_OCCUPANT_ID);
100 match.putAllInMucOccupantCache(db.getMucUsersForConversation(match));
101 return match;
102 }
103 }
104
105 private DatabaseBackend db;
106 private static final InfoQuery INFO_QUERY_WITH_OCCUPANT_ID = new InfoQuery();
107 private static final int MANY_USERS = 20_000;
108
109 private static AccountFixture ACCOUNT;
110 private static ConversationFixture ROW_TOO_BIG;
111 private static ConversationFixture CONFORMING;
112 private static ConversationFixture NO_CACHED_MUC_USERS;
113 private static ConversationFixture[] FIXTURES;
114
115 @BeforeClass
116 public static void setupClass() throws JSONException {
117 final var occupantIdFeature = new Feature();
118 occupantIdFeature.setVar(Namespace.OCCUPANT_ID);
119 INFO_QUERY_WITH_OCCUPANT_ID.addChild(occupantIdFeature);
120
121 ACCOUNT = new AccountFixture(
122 UUID.randomUUID().toString(), "test", "example.com");
123
124 final var rowTooBigAttrs = new JSONObject();
125 for (int i = 0; i < MANY_USERS; i++) {
126 final var occupantId = UUID.randomUUID().toString();
127 rowTooBigAttrs.put("occupantNick/" + occupantId, "User" + i);
128 rowTooBigAttrs.put("occupantAvatar/" + occupantId,
129 UUID.randomUUID().toString().repeat(5));
130 }
131 rowTooBigAttrs.put("mucNick", "testMucNick");
132
133 final var rowTooBigCache =
134 new HashMap<MucOptions.User.OccupantId, MucOptions.User.CacheEntry>();
135 rowTooBigCache.put(
136 new MucOptions.User.OccupantId(UUID.randomUUID().toString()),
137 new MucOptions.User.CacheEntry(UUID.randomUUID().toString(), "RowTooBigUser"));
138
139 ROW_TOO_BIG = new ConversationFixture(
140 UUID.randomUUID().toString(),
141 ACCOUNT,
142 "Big MUC",
143 "room@conference.example.com",
144 rowTooBigAttrs.toString(),
145 rowTooBigCache
146 );
147
148 final var conformingCache =
149 new HashMap<MucOptions.User.OccupantId, MucOptions.User.CacheEntry>();
150 conformingCache.put(
151 new MucOptions.User.OccupantId(UUID.randomUUID().toString()),
152 new MucOptions.User.CacheEntry(UUID.randomUUID().toString(), "ConformingUser"));
153
154 CONFORMING = new ConversationFixture(
155 UUID.randomUUID().toString(),
156 ACCOUNT,
157 "Normal MUC",
158 "normalroom@conference.example.com",
159 new JSONObject().put("mucNick", "testMucNick").toString(),
160 conformingCache
161 );
162
163 NO_CACHED_MUC_USERS = new ConversationFixture(
164 UUID.randomUUID().toString(),
165 ACCOUNT,
166 "Empty Cache MUC",
167 "emptycache@conference.example.com",
168 new JSONObject().put("mucNick", "testMucNick").toString(),
169 new HashMap<>()
170 );
171
172 FIXTURES = new ConversationFixture[] {
173 ROW_TOO_BIG, CONFORMING, NO_CACHED_MUC_USERS };
174 }
175
176 @Before
177 public void setUp() throws Exception {
178 db = DatabaseBackend.getInstance(
179 ApplicationProvider.getApplicationContext());
180 ACCOUNT.write(db);
181 for (final var fixture : FIXTURES) {
182 fixture.writeAll(db);
183 }
184 }
185
186 @After
187 public void tearDown() {
188 SQLiteDatabase sqDb = db.getWritableDatabase();
189 sqDb.delete(Conversation.TABLENAME, null, null);
190 sqDb.delete(Account.TABLENAME, null, null);
191 sqDb.delete(MucOptions.User.CacheEntry.TABLENAME, null, null);
192 }
193
194 @Test
195 public void getConversationsCorrectlyReadsMucUsers() throws Exception {
196 Assert.assertTrue(
197 "Occupant cache should be empty when no occupants are written",
198 NO_CACHED_MUC_USERS
199 .extractAndConfigure(db)
200 .getMucOccupantCache()
201 .isEmpty()
202 );
203
204 Assert.assertEquals(
205 "Cached entries should match fixture",
206 CONFORMING
207 .extractAndConfigure(db)
208 .getMucOccupantCache(),
209 CONFORMING.occupantCache()
210 );
211 }
212
213 @Test
214 public void getConversationsTruncatesTooBigRow() throws Exception {
215 final var conversation = ROW_TOO_BIG.extractAndConfigure(db);
216 java.lang.reflect.Field attributesField =
217 conversation.getClass().getDeclaredField("attributes");
218
219 attributesField.setAccessible(true);
220 org.json.JSONObject attributes =
221 (org.json.JSONObject) attributesField.get(conversation);
222
223 final var expected = new JSONObject();
224 expected.put("members_only", "false");
225 expected.put("moderated", "false");
226 expected.put("non_anonymous", "false");
227
228 Assert.assertEquals(
229 "Attributes should not contain occupant cache after truncation:\n" + attributes.toString(4),
230 expected.toString(),
231 attributes.toString()
232 );
233 }
234
235 @Test
236 public void updateConversationWritesMucOccupantsCache() throws Exception {
237 final var conversation = NO_CACHED_MUC_USERS.extractAndConfigure(db);
238 conversation.putAllInMucOccupantCache(CONFORMING.occupantCache());
239 db.updateConversation(conversation);
240
241 final var readBackCache = db.getMucUsersForConversation(conversation);
242 Assert.assertEquals(
243 "Cache should match after updateConversation",
244 CONFORMING.occupantCache(),
245 readBackCache
246 );
247 }
248}