mirror of
https://github.com/tutao/tutanota.git
synced 2025-12-07 13:49:47 +00:00
We sync the spam training data encrypted through our server to make sure that all clients for a specific user behave the same when classifying mails. Additionally, this enables the spam classification in the webApp. We compress the training data vectors (see clientSpamTrainingDatum) before uploading to our server using SparseVectorCompressor.ts. When a user has the ClientSpamClassification enabled, the spam training data sync will happen for every mail received. ClientSpamTrainingDatum are not stored in the CacheStorage. No entityEvents are emitted for this type. However, we retrieve creations and updates for ClientSpamTrainingData through the modifiedClientSpamTrainingDataIndex. We calculate a threshold per classifier based on the dataset ham to spam ratio, we also subsample our training data to cap the ham to spam ratio within a certain limit. Co-authored-by: jomapp <17314077+jomapp@users.noreply.github.com> Co-authored-by: das <das@tutao.de> Co-authored-by: abp <abp@tutao.de> Co-authored-by: Kinan <104761667+kibibytium@users.noreply.github.com> Co-authored-by: sug <sug@tutao.de> Co-authored-by: nif <nif@tutao.de> Co-authored-by: map <mpfau@users.noreply.github.com>
841 lines
30 KiB
TypeScript
841 lines
30 KiB
TypeScript
import o from "@tutao/otest"
|
|
import {
|
|
createMailSetEntry,
|
|
Mail,
|
|
MailboxGroupRootTypeRef,
|
|
MailBoxTypeRef,
|
|
MailFolder,
|
|
MailFolderTypeRef,
|
|
MailSetEntry,
|
|
MailSetEntryTypeRef,
|
|
MailTypeRef,
|
|
} from "../../../../src/common/api/entities/tutanota/TypeRefs"
|
|
import { matchers, object, verify, when } from "testdouble"
|
|
import { ConversationPrefProvider } from "../../../../src/mail-app/mail/view/ConversationViewModel"
|
|
import { EntityClient } from "../../../../src/common/api/common/EntityClient"
|
|
import { MailModel } from "../../../../src/mail-app/mail/model/MailModel"
|
|
import { InboxRuleHandler } from "../../../../src/mail-app/mail/model/InboxRuleHandler"
|
|
import { ExposedCacheStorage } from "../../../../src/common/api/worker/rest/DefaultEntityRestCache"
|
|
import { MailSetKind, OperationType } from "../../../../src/common/api/common/TutanotaConstants"
|
|
import {
|
|
constructMailSetEntryId,
|
|
CUSTOM_MAX_ID,
|
|
CUSTOM_MIN_ID,
|
|
deconstructMailSetEntryId,
|
|
elementIdPart,
|
|
GENERATED_MAX_ID,
|
|
getElementId,
|
|
getListId,
|
|
isSameId,
|
|
listIdPart,
|
|
} from "../../../../src/common/api/common/utils/EntityUtils"
|
|
import { PageSize } from "../../../../src/common/gui/base/ListUtils"
|
|
import { createTestEntity } from "../../TestUtils"
|
|
import { EntityUpdateData, PrefetchStatus } from "../../../../src/common/api/common/utils/EntityUpdateUtils"
|
|
import { MailboxDetail } from "../../../../src/common/mailFunctionality/MailboxModel"
|
|
import { GroupInfoTypeRef, GroupTypeRef } from "../../../../src/common/api/entities/sys/TypeRefs"
|
|
import { ConnectionError } from "../../../../src/common/api/common/error/RestError"
|
|
import { assertNotNull, clamp, lastThrow, pad } from "@tutao/tutanota-utils"
|
|
import { LoadedMail } from "../../../../src/mail-app/mail/model/MailSetListModel"
|
|
import { ConversationListModel } from "../../../../src/mail-app/mail/model/ConversationListModel"
|
|
import { theme } from "../../../../src/common/gui/theme.js"
|
|
import { ListLoadingState } from "../../../../src/common/gui/base/List"
|
|
import { getMailFilterForType, MailFilterType } from "../../../../src/mail-app/mail/view/MailViewerUtils"
|
|
import { ProcessInboxHandler } from "../../../../src/mail-app/mail/model/ProcessInboxHandler"
|
|
import { FolderSystem } from "../../../../src/common/api/common/mail/FolderSystem"
|
|
|
|
o.spec("ConversationListModel", () => {
|
|
let model: ConversationListModel
|
|
|
|
const mailboxDetail: MailboxDetail = {
|
|
mailbox: createTestEntity(MailBoxTypeRef),
|
|
mailGroupInfo: createTestEntity(GroupInfoTypeRef),
|
|
mailGroup: createTestEntity(GroupTypeRef),
|
|
mailboxGroupRoot: createTestEntity(MailboxGroupRootTypeRef),
|
|
}
|
|
const noPatchesAndInstance: Pick<EntityUpdateData, "instance" | "patches"> = {
|
|
instance: null,
|
|
patches: null,
|
|
}
|
|
|
|
const mailSetEntriesListId = "entries"
|
|
const _ownerGroup = "me"
|
|
|
|
const labels: MailFolder[] = [
|
|
createTestEntity(MailFolderTypeRef, {
|
|
_id: ["mailFolderList", "tutaPrimary"],
|
|
color: theme.primary,
|
|
folderType: MailSetKind.LABEL,
|
|
name: "Tuta Primary Label",
|
|
parentFolder: null,
|
|
}),
|
|
createTestEntity(MailFolderTypeRef, {
|
|
_id: ["mailFolderList", "tutaSecondary"],
|
|
color: theme.secondary,
|
|
folderType: MailSetKind.LABEL,
|
|
name: "Tuta Secondary Label",
|
|
parentFolder: null,
|
|
}),
|
|
]
|
|
|
|
let mailSet: MailFolder
|
|
let conversationPrefProvider: ConversationPrefProvider
|
|
let entityClient: EntityClient
|
|
let mailModel: MailModel
|
|
let processInboxHandler: ProcessInboxHandler
|
|
let cacheStorage: ExposedCacheStorage
|
|
|
|
o.beforeEach(() => {
|
|
mailSet = createTestEntity(MailFolderTypeRef, {
|
|
_id: ["mailFolderList", "mailFolderId"],
|
|
folderType: MailSetKind.CUSTOM,
|
|
name: "My Folder",
|
|
entries: mailSetEntriesListId,
|
|
parentFolder: null,
|
|
})
|
|
|
|
conversationPrefProvider = object()
|
|
entityClient = object()
|
|
mailModel = object()
|
|
processInboxHandler = object()
|
|
cacheStorage = object()
|
|
model = new ConversationListModel(mailSet, conversationPrefProvider, entityClient, mailModel, processInboxHandler, cacheStorage)
|
|
when(mailModel.getMailboxDetailsForMailFolder(mailSet)).thenResolve(mailboxDetail)
|
|
const folderSystem: FolderSystem = object()
|
|
when(mailModel.getFolderSystemByGroupId(matchers.anything())).thenReturn(folderSystem)
|
|
})
|
|
|
|
// Care has to be ensured for generating mail set entry IDs as we depend on real mail set ID decoding, thus we have
|
|
// some helper methods for generating IDs for these tests.
|
|
function makeMailId(index: number): IdTuple {
|
|
const mailBag = index % 10
|
|
return [`${mailBag}`, pad(index, GENERATED_MAX_ID.length)]
|
|
}
|
|
|
|
function makeMailSetElementId(index: number): Id {
|
|
return constructMailSetEntryId(new Date(index * 100), elementIdPart(makeMailId(index)))
|
|
}
|
|
|
|
function mailSetElementIdToIndex(mailSetElementId: Id): number {
|
|
return Number(deconstructMailSetEntryId(mailSetElementId).mailId)
|
|
}
|
|
|
|
// Creates a totalMails number of mails, grouping into a number of conversations equal to totalMails/mailsPerConversation
|
|
async function setUpTestData(totalMails: number, initialLabels: MailFolder[], offline: boolean, mailsPerConversation: number): Promise<Mail[]> {
|
|
const mailSetEntries: MailSetEntry[] = []
|
|
const mails: Mail[][] = [[], [], [], [], [], [], [], [], [], []]
|
|
const allMails: Mail[] = []
|
|
|
|
for (let i = 0; i < totalMails; i++) {
|
|
const mailBag = i % 10
|
|
const mailId: IdTuple = makeMailId(i)
|
|
const conversationId = "" + Math.floor(i / mailsPerConversation)
|
|
|
|
const mail = createTestEntity(MailTypeRef, {
|
|
_id: mailId,
|
|
sets: [mailSet._id, ...initialLabels.map((l) => l._id)],
|
|
conversationEntry: [conversationId, elementIdPart(mailId)],
|
|
})
|
|
|
|
mails[mailBag].push(mail)
|
|
allMails.push(mail)
|
|
|
|
mailSetEntries.push(
|
|
createMailSetEntry({
|
|
_id: [mailSetEntriesListId, makeMailSetElementId(i)],
|
|
_ownerGroup,
|
|
_permissions: "1234",
|
|
mail: mailId,
|
|
}),
|
|
)
|
|
}
|
|
|
|
when(mailModel.getLabelsForMail(matchers.anything())).thenDo((mail: Mail) => {
|
|
const sets: MailFolder[] = []
|
|
for (const set of mail.sets) {
|
|
const setToAdd = labels.find((label) => isSameId(label._id, set))
|
|
if (setToAdd) {
|
|
sets.push(setToAdd)
|
|
}
|
|
}
|
|
return sets
|
|
})
|
|
|
|
// Ensures elements are loaded from the array in reverse order
|
|
async function getMailSetEntryMock(_mailSetEntry: any, _listId: Id, startingId: Id, count: number, _reverse: boolean): Promise<MailSetEntry[]> {
|
|
let endingIndex: number
|
|
if (startingId === CUSTOM_MAX_ID) {
|
|
endingIndex = mailSetEntries.length
|
|
} else {
|
|
endingIndex = mailSetElementIdToIndex(startingId)
|
|
}
|
|
endingIndex = clamp(endingIndex, 0, mailSetEntries.length)
|
|
|
|
const startingIndex = clamp(endingIndex - count, 0, endingIndex)
|
|
return mailSetEntries.slice(startingIndex, endingIndex).reverse()
|
|
}
|
|
|
|
async function getMailsMock(_mailTypeRef: any, mailBag: string, elements: Id[]): Promise<Mail[]> {
|
|
const mailsInMailBag = mails[Number(mailBag)] ?? []
|
|
return mailsInMailBag.filter((mail) => elements.includes(getElementId(mail)))
|
|
}
|
|
|
|
when(cacheStorage.provideFromRange(MailSetEntryTypeRef, mailSetEntriesListId, matchers.anything(), matchers.anything(), true)).thenDo(
|
|
getMailSetEntryMock,
|
|
)
|
|
when(cacheStorage.provideMultiple(MailTypeRef, matchers.anything(), matchers.anything())).thenDo(getMailsMock)
|
|
|
|
if (offline) {
|
|
when(entityClient.loadRange(matchers.anything(), matchers.anything(), matchers.anything(), matchers.anything(), matchers.anything())).thenReject(
|
|
new ConnectionError("sorry we are offline"),
|
|
)
|
|
when(entityClient.loadMultiple(matchers.anything(), matchers.anything(), matchers.anything(), matchers.anything())).thenReject(
|
|
new ConnectionError("sorry we are offline"),
|
|
)
|
|
} else {
|
|
when(entityClient.loadRange(MailSetEntryTypeRef, mailSetEntriesListId, matchers.anything(), matchers.anything(), true)).thenDo(getMailSetEntryMock)
|
|
when(entityClient.loadMultiple(MailTypeRef, matchers.anything(), matchers.anything())).thenDo(getMailsMock)
|
|
}
|
|
|
|
return allMails.reverse()
|
|
}
|
|
|
|
o.test("loads PageSize items and sets labels correctly", async () => {
|
|
await setUpTestData(PageSize, labels, false, 1)
|
|
await model.loadInitial()
|
|
o.check(model.mails.length).equals(PageSize)
|
|
for (const mail of model.mails) {
|
|
o.check(model.getLabelsForMail(mail)).deepEquals(labels)
|
|
}
|
|
verify(cacheStorage.provideFromRange(MailSetEntryTypeRef, mailSetEntriesListId, CUSTOM_MAX_ID, PageSize, true), {
|
|
times: 0,
|
|
})
|
|
verify(mailModel.getMailboxDetailsForMailFolder(matchers.anything()), {
|
|
times: 0,
|
|
})
|
|
verify(processInboxHandler.handleIncomingMail(matchers.anything(), matchers.anything(), mailboxDetail, matchers.anything()), {
|
|
times: 0,
|
|
})
|
|
})
|
|
|
|
o.test("loads PageSize items while offline and sets labels correctly", async () => {
|
|
await setUpTestData(PageSize, labels, true, 1)
|
|
await model.loadInitial()
|
|
o.check(model.mails.length).equals(PageSize)
|
|
for (const mail of model.mails) {
|
|
o.check(model.getLabelsForMail(mail)).deepEquals(labels)
|
|
}
|
|
verify(cacheStorage.provideFromRange(MailSetEntryTypeRef, mailSetEntriesListId, CUSTOM_MAX_ID, PageSize, true), {
|
|
times: 1,
|
|
})
|
|
verify(mailModel.getMailboxDetailsForMailFolder(matchers.anything()), {
|
|
times: 0,
|
|
})
|
|
verify(processInboxHandler.handleIncomingMail(matchers.anything(), matchers.anything(), mailboxDetail, matchers.anything()), {
|
|
times: 0,
|
|
})
|
|
})
|
|
|
|
o.test("loads fewer than PageSize items while offline, loadMore will fail", async () => {
|
|
const mails = await setUpTestData(PageSize - 1, labels, true, 1)
|
|
await model.loadInitial()
|
|
o.check(model.mails.length).equals(PageSize - 1)
|
|
o.check(model.mails).deepEquals(mails)
|
|
for (const mail of model.mails) {
|
|
o.check(model.getLabelsForMail(mail)).deepEquals(labels)
|
|
}
|
|
verify(cacheStorage.provideFromRange(MailSetEntryTypeRef, mailSetEntriesListId, CUSTOM_MAX_ID, PageSize, true), {
|
|
times: 1,
|
|
})
|
|
verify(mailModel.getMailboxDetailsForMailFolder(matchers.anything()), {
|
|
times: 0,
|
|
})
|
|
verify(processInboxHandler.handleIncomingMail(matchers.anything(), matchers.anything(), mailboxDetail, matchers.anything()), {
|
|
times: 0,
|
|
})
|
|
o.check(model.loadingStatus).equals(ListLoadingState.Idle)
|
|
|
|
await model.loadMore()
|
|
o.check(model.mails.length).equals(PageSize - 1)
|
|
o.check(model.mails).deepEquals(mails)
|
|
o.check(model.loadingStatus).equals(ListLoadingState.ConnectionLost)
|
|
})
|
|
|
|
o.test("applies inbox rules if inbox", async () => {
|
|
mailSet.folderType = MailSetKind.INBOX
|
|
|
|
// make one item have a rule
|
|
when(
|
|
processInboxHandler.handleIncomingMail(
|
|
matchers.argThat((mail: Mail) => isSameId(mail._id, makeMailId(25))),
|
|
matchers.anything(),
|
|
matchers.anything(),
|
|
matchers.anything(),
|
|
),
|
|
).thenResolve({ folderType: MailSetKind.SPAM })
|
|
|
|
when(
|
|
processInboxHandler.handleIncomingMail(
|
|
matchers.argThat((mail: Mail) => !isSameId(mail._id, makeMailId(25))),
|
|
matchers.anything(),
|
|
matchers.anything(),
|
|
matchers.anything(),
|
|
),
|
|
).thenResolve({ folderType: MailSetKind.INBOX })
|
|
|
|
await setUpTestData(PageSize, labels, false, 1)
|
|
|
|
await model.loadInitial()
|
|
|
|
o.check(model.mails.length).equals(PageSize - 1)
|
|
for (const mail of model.mails) {
|
|
o.check(model.getLabelsForMail(mail)).deepEquals(labels)
|
|
}
|
|
verify(cacheStorage.provideFromRange(MailSetEntryTypeRef, mailSetEntriesListId, CUSTOM_MAX_ID, PageSize, true), {
|
|
times: 0,
|
|
})
|
|
verify(mailModel.getMailboxDetailsForMailFolder(matchers.anything()), {
|
|
times: 1,
|
|
})
|
|
verify(processInboxHandler.handleIncomingMail(matchers.anything(), matchers.anything(), mailboxDetail, matchers.anything()), {
|
|
times: 100,
|
|
})
|
|
})
|
|
|
|
o.spec("loadMore eventually loads more mails", () => {
|
|
o.test("ungrouped", async () => {
|
|
const mailsPerConversation = 1
|
|
const pages = 5
|
|
await setUpTestData(PageSize * pages, labels, false, mailsPerConversation)
|
|
await model.loadInitial() // will have the first page loaded
|
|
const unloadedMail = elementIdPart(makeMailId(1)) // a mail that will be on the bottom of the list
|
|
|
|
// This mail is not loaded until we load a few more pages.
|
|
for (let loadedPageCount = 1; loadedPageCount < pages; loadedPageCount++) {
|
|
o.check(model.mails.length).equals(PageSize * loadedPageCount)
|
|
const mail = model.getMail(unloadedMail)
|
|
o.check(mail).equals(null)
|
|
await model.loadMore()
|
|
}
|
|
|
|
// Everything is loaded including that mail we wanted from before
|
|
o.check(model.mails.length).equals(PageSize * pages)
|
|
const mail = model.getMail(unloadedMail)
|
|
o.check(mail).notEquals(null)
|
|
})
|
|
|
|
o.test("grouped by conversations", async () => {
|
|
const mailsPerConversation = 2
|
|
const pages = 4
|
|
await setUpTestData(PageSize * pages * mailsPerConversation, labels, false, mailsPerConversation)
|
|
await model.loadInitial() // will have the first page loaded
|
|
const unloadedMail = elementIdPart(makeMailId(1)) // a mail that will be on the bottom of the list
|
|
|
|
// This mail is not loaded until we load a few more pages.
|
|
for (let loadedPageCount = 1; loadedPageCount < pages * mailsPerConversation; loadedPageCount++) {
|
|
const mail = model.getMail(unloadedMail)
|
|
o.check(mail).equals(null)
|
|
await model.loadMore()
|
|
}
|
|
|
|
// Everything is now loaded, including that mail we wanted from before
|
|
o.check(model.mails.length).equals(PageSize * pages * mailsPerConversation)
|
|
|
|
// But we have fewer pages shown because half of them are hidden behind conversations where there are newer mails
|
|
o.check(model.items.length).equals(PageSize * pages)
|
|
const mail = model.getMail(unloadedMail)
|
|
o.check(mail).notEquals(null)
|
|
})
|
|
|
|
o.test("everything is one conversation", async () => {
|
|
const pages = 4
|
|
const totalMails = PageSize * pages
|
|
await setUpTestData(totalMails, labels, false, totalMails)
|
|
await model.loadInitial() // will have the first page loaded
|
|
await model.loadAll()
|
|
|
|
o.check(model.mails.length).equals(totalMails)
|
|
o.check(model.items.length).equals(1)
|
|
})
|
|
})
|
|
|
|
o.test("loadAndSelect selects by mail id", async () => {
|
|
await setUpTestData(PageSize * 5, labels, false, 1)
|
|
await model.loadInitial() // will have the first page loaded
|
|
|
|
// This mail is not loaded yet.
|
|
const unloadedMail = elementIdPart(makeMailId(1)) // a mail that will be on the bottom of the list
|
|
const mail = model.getMail(unloadedMail)
|
|
o.check(mail).equals(null)
|
|
o.check(model.getSelectedAsArray()).deepEquals([])
|
|
|
|
// Should now be loaded
|
|
const loadedMail = assertNotNull(await model.loadAndSelect(unloadedMail, () => false))
|
|
o.check(loadedMail).equals(model.getMail(unloadedMail))
|
|
|
|
// ...and selected
|
|
o.check(model.getSelectedAsArray()).deepEquals([loadedMail])
|
|
})
|
|
|
|
o.test("handle create events while already loaded", async () => {
|
|
const loadedMails = await setUpTestData(PageSize, labels, false, 1)
|
|
const mail = lastThrow(loadedMails)
|
|
await model.loadInitial()
|
|
|
|
// the loaded mails are in reverse order, so we take the 0th element to get the last
|
|
const mailSetEntryId: IdTuple = [mailSetEntriesListId, makeMailSetElementId(0)]
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailSetEntryTypeRef,
|
|
instanceListId: listIdPart(mailSetEntryId) as NonEmptyString,
|
|
instanceId: elementIdPart(mailSetEntryId),
|
|
operation: OperationType.CREATE,
|
|
instance: null,
|
|
patches: null,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
|
|
when(entityClient.load(MailSetEntryTypeRef, mailSetEntryId)).thenResolve(
|
|
createTestEntity(MailSetEntryTypeRef, {
|
|
_id: mailSetEntryId,
|
|
mail: mail._id,
|
|
}),
|
|
)
|
|
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model._getConversationMap().get(listIdPart(mail.conversationEntry))?.getMainMail()!.mailSetEntryId).deepEquals(mailSetEntryId)
|
|
})
|
|
|
|
o.spec("handleEntityUpdate", () => {
|
|
o.test("mailset update updates labels", async () => {
|
|
await setUpTestData(PageSize, [labels[0]], false, 1)
|
|
await model.loadInitial()
|
|
|
|
// Overwrite one of the mails inside the list so it has both labels
|
|
const someIndex = 50 // a random number
|
|
const someMail: LoadedMail = {
|
|
...model._getLoadedMail(elementIdPart(makeMailId(someIndex)))!,
|
|
labels: [labels[0], labels[1]],
|
|
}
|
|
someMail.mail.sets.push(labels[1]._id)
|
|
model._insertOrUpdateLoadedMails([someMail])
|
|
o.check(model.getLabelsForMail(someMail.mail)[1]).deepEquals(labels[1])
|
|
|
|
// Change one of the labels (but not inside the mail we just updated)
|
|
labels[1] = {
|
|
...labels[1],
|
|
name: "Mint",
|
|
color: "#00FFAA",
|
|
}
|
|
|
|
o.check(model.getLabelsForMail(someMail.mail)[1]).notDeepEquals(labels[1])
|
|
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailFolderTypeRef,
|
|
instanceListId: getListId(labels[1]) as NonEmptyString,
|
|
instanceId: getElementId(labels[1]),
|
|
operation: OperationType.DELETE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
|
|
const oldMails = model.mails
|
|
|
|
entityUpdateData.operation = OperationType.UPDATE
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model.getLabelsForMail(someMail.mail)[1]).deepEquals(labels[1])
|
|
|
|
// verify getLabelsForMail call times (someMail was queried twice, but other mails only once)
|
|
verify(mailModel.getLabelsForMail(someMail.mail), { times: 2 })
|
|
verify(mailModel.getLabelsForMail(model.mails[someIndex + 1]), { times: 1 })
|
|
|
|
// does not break referential equality with mails (items is not guaranteed and is usually broken if *any*
|
|
// update is made to the list)
|
|
o.check(model.mails).equals(oldMails)
|
|
})
|
|
|
|
o.test("mailset delete does nothing", async () => {
|
|
await setUpTestData(PageSize, [labels[0]], false, 1)
|
|
await model.loadInitial()
|
|
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailFolderTypeRef,
|
|
instanceListId: getListId(labels[1]) as NonEmptyString,
|
|
instanceId: getElementId(labels[1]),
|
|
operation: OperationType.DELETE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
entityUpdateData.operation = OperationType.DELETE
|
|
|
|
const oldMails = model.mails
|
|
const oldItems = model.items
|
|
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model.mails.length).equals(PageSize)
|
|
|
|
// no change to referential equality
|
|
o.check(model.items).equals(oldItems)
|
|
o.check(model.mails).equals(oldMails)
|
|
})
|
|
|
|
o.test("deleting a mail set entry", async () => {
|
|
await setUpTestData(PageSize, labels, false, 1)
|
|
await model.loadInitial()
|
|
const someIndex = 22 // a random number
|
|
const someMail: LoadedMail = model._getLoadedMail(elementIdPart(makeMailId(someIndex)))!
|
|
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailSetEntryTypeRef,
|
|
instanceListId: listIdPart(someMail.mailSetEntryId) as NonEmptyString,
|
|
instanceId: elementIdPart(someMail.mailSetEntryId),
|
|
operation: OperationType.DELETE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
|
|
const oldItems = model.mails
|
|
const newItems = [...oldItems]
|
|
newItems.splice(PageSize - 1 - someIndex, 1)
|
|
|
|
o.check(model.mails).deepEquals(oldItems)
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model.mails).deepEquals(newItems)
|
|
o.check(model.getMail(getElementId(someMail.mail))).equals(null)
|
|
})
|
|
|
|
function createInsertedMail(
|
|
mailSetEntryId: IdTuple,
|
|
conversationId: Id,
|
|
): {
|
|
mail: Mail
|
|
mailSetEntry: MailSetEntry
|
|
entityUpdateData: EntityUpdateData
|
|
mailLabels: MailFolder[]
|
|
} {
|
|
const newMail = createTestEntity(MailTypeRef, {
|
|
_id: ["new mail!!!", deconstructMailSetEntryId(elementIdPart(mailSetEntryId)).mailId],
|
|
sets: [mailSet._id, labels[1]._id],
|
|
conversationEntry: [conversationId, "yay!"],
|
|
})
|
|
|
|
const newEntry = createMailSetEntry({
|
|
_id: mailSetEntryId,
|
|
mail: newMail._id,
|
|
})
|
|
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailSetEntryTypeRef,
|
|
instanceListId: getListId(newEntry) as NonEmptyString,
|
|
instanceId: getElementId(newEntry),
|
|
operation: OperationType.CREATE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
|
|
when(entityClient.load(MailSetEntryTypeRef, newEntry._id)).thenResolve(newEntry)
|
|
when(entityClient.loadMultiple(MailTypeRef, getListId(newMail), [getElementId(newMail)])).thenResolve([newMail])
|
|
|
|
return {
|
|
mail: newMail,
|
|
mailSetEntry: newEntry,
|
|
entityUpdateData,
|
|
mailLabels: [labels[1]],
|
|
}
|
|
}
|
|
|
|
o.test("creating a mail set entry of the same set adds the element", async () => {
|
|
await setUpTestData(3, labels, false, 1)
|
|
await model.loadInitial()
|
|
const { mail, entityUpdateData, mailLabels } = createInsertedMail([mailSet.entries, CUSTOM_MAX_ID], "1")
|
|
|
|
const oldItems = model.mails
|
|
// NOTE: mails is backed by a Map which maintains insertion order; MailSetListModel#mails is not required to
|
|
// be ordered
|
|
const newItems = [...oldItems, mail]
|
|
|
|
o.check(model.mails).equals(oldItems)
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model.mails).deepEquals(newItems)
|
|
o.check(model.getMail(getElementId(mail))).deepEquals(mail)
|
|
o.check(model.getLabelsForMail(mail)).deepEquals(mailLabels)
|
|
})
|
|
|
|
o.test("creating an older mail in an existing conversation", async () => {
|
|
await setUpTestData(1, labels, false, 1)
|
|
await model.loadInitial()
|
|
const { mail, entityUpdateData } = createInsertedMail([mailSetEntriesListId, CUSTOM_MIN_ID], "0")
|
|
|
|
const oldMails = model.mails
|
|
const newMails = [...oldMails, mail]
|
|
|
|
const oldItems = model.items
|
|
const newItems = [...oldItems]
|
|
|
|
o.check(model.mails).deepEquals(oldMails)
|
|
o.check(model.items).deepEquals(oldItems)
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model.mails).deepEquals(newMails)
|
|
o.check(model.items).deepEquals(newItems)
|
|
o.check(model.getMail(getElementId(mail))).equals(mail)
|
|
})
|
|
|
|
o.test("creating a newer mail in an existing conversation", async () => {
|
|
await setUpTestData(1, labels, false, 1)
|
|
await model.loadInitial()
|
|
const { mail, entityUpdateData } = createInsertedMail([mailSetEntriesListId, CUSTOM_MAX_ID], "0")
|
|
|
|
const oldMails = model.mails
|
|
const newMails = [...oldMails, mail]
|
|
|
|
const oldItems = model.items
|
|
const newItems = [mail]
|
|
|
|
o.check(model.mails).deepEquals(oldMails)
|
|
o.check(model.items).deepEquals(oldItems)
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model.mails).deepEquals(newMails)
|
|
o.check(model.items).deepEquals(newItems)
|
|
o.check(model.getMail(getElementId(mail))).equals(mail)
|
|
})
|
|
|
|
o.test("deleting an older mail in an existing conversation", async () => {
|
|
await setUpTestData(2, labels, false, 2)
|
|
await model.loadInitial()
|
|
|
|
const oldMails = model.mails
|
|
const newMails = [oldMails[0]]
|
|
|
|
const oldItems = model.items
|
|
const newItems = [...oldItems]
|
|
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailSetEntryTypeRef,
|
|
instanceListId: mailSetEntriesListId,
|
|
instanceId: makeMailSetElementId(0),
|
|
operation: OperationType.DELETE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
|
|
o.check(model.mails).deepEquals(oldMails)
|
|
o.check(model.items).deepEquals(oldItems)
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model.mails).deepEquals(newMails)
|
|
o.check(model.items).deepEquals(newItems)
|
|
o.check(model.getMail(entityUpdateData.instanceId)).equals(null)
|
|
})
|
|
|
|
o.test("deleting the newest mail in an existing conversation of 3 items selects the second newest", async () => {
|
|
await setUpTestData(3, labels, false, 3)
|
|
await model.loadInitial()
|
|
|
|
const oldMails = model.mails
|
|
const newMails = [oldMails[1], oldMails[2]]
|
|
|
|
const oldItems = model.items
|
|
const newItems = [oldMails[1]]
|
|
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailSetEntryTypeRef,
|
|
instanceListId: mailSetEntriesListId,
|
|
instanceId: makeMailSetElementId(2),
|
|
operation: OperationType.DELETE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
|
|
o.check(model.mails).deepEquals(oldMails)
|
|
o.check(model.items).deepEquals(oldItems)
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model.mails).deepEquals(newMails)
|
|
o.check(model.items).deepEquals(newItems)
|
|
o.check(model.getMail(entityUpdateData.instanceId)).equals(null)
|
|
})
|
|
|
|
o.test("deleting a newer mail in an existing conversation of 2 items", async () => {
|
|
await setUpTestData(2, labels, false, 2)
|
|
await model.loadInitial()
|
|
|
|
const oldMails = model.mails
|
|
const newMails = [oldMails[1]]
|
|
|
|
const oldItems = model.items
|
|
const newItems = [oldMails[1]]
|
|
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailSetEntryTypeRef,
|
|
instanceListId: mailSetEntriesListId,
|
|
instanceId: makeMailSetElementId(1),
|
|
operation: OperationType.DELETE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
|
|
o.check(model.mails).deepEquals(oldMails)
|
|
o.check(model.items).deepEquals(oldItems)
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model.mails).deepEquals(newMails)
|
|
o.check(model.items).deepEquals(newItems)
|
|
o.check(model.getMail(entityUpdateData.instanceId)).equals(null)
|
|
})
|
|
|
|
o.test("creating a mail set entry in a different set does nothing", async () => {
|
|
await setUpTestData(PageSize, labels, false, 1)
|
|
await model.loadInitial()
|
|
const { mail, entityUpdateData } = createInsertedMail(["something else", CUSTOM_MAX_ID], "whoo!")
|
|
|
|
const oldItems = model.mails
|
|
const newItems = [...oldItems]
|
|
|
|
o.check(model.mails).deepEquals(oldItems)
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model.mails).deepEquals(newItems)
|
|
o.check(model.getMail(getElementId(mail))).equals(null)
|
|
})
|
|
|
|
o.test("updating a mail updates the contents", async () => {
|
|
await setUpTestData(PageSize, labels, false, 1)
|
|
await model.loadInitial()
|
|
const mail = { ...model.mails[2] }
|
|
mail.subject = "hey it's a subject"
|
|
mail.sets = [mailSet._id] // remove all labels
|
|
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailTypeRef,
|
|
instanceListId: getListId(mail) as NonEmptyString,
|
|
instanceId: getElementId(mail),
|
|
operation: OperationType.UPDATE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
when(entityClient.load(MailTypeRef, mail._id)).thenResolve(mail)
|
|
|
|
entityUpdateData.operation = OperationType.UPDATE
|
|
|
|
const oldMails = model.mails
|
|
const oldItems = model.items
|
|
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
|
|
o.check(model.getMail(getElementId(mail))).deepEquals(mail)
|
|
o.check(model.getLabelsForMail(mail)).deepEquals([])
|
|
|
|
// Referential equality is broken
|
|
o.check(model.mails).notEquals(oldMails)
|
|
o.check(model.items).notEquals(oldItems)
|
|
})
|
|
|
|
o.test("mail delete does nothing", async () => {
|
|
await setUpTestData(PageSize, labels, false, 1)
|
|
await model.loadInitial()
|
|
const mail = { ...model.mails[2] }
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailTypeRef,
|
|
instanceListId: getListId(mail) as NonEmptyString,
|
|
instanceId: getElementId(mail),
|
|
operation: OperationType.UPDATE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
when(entityClient.load(MailTypeRef, mail._id)).thenResolve(mail)
|
|
entityUpdateData.operation = OperationType.DELETE
|
|
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
o.check(model.getMail(getElementId(mail))).deepEquals(mail)
|
|
})
|
|
})
|
|
|
|
o.spec("filter on ConversationList", () => {
|
|
o.test("When filtering for unread mails only relevant conversations are shown", async () => {
|
|
const allMails = await setUpTestData(2, [], false, 1)
|
|
await model.loadInitial()
|
|
|
|
o.check(model.items.length).equals(2)
|
|
|
|
const unreadMail = allMails[0]
|
|
unreadMail.unread = true
|
|
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailTypeRef,
|
|
instanceListId: getListId(unreadMail) as NonEmptyString,
|
|
instanceId: getElementId(unreadMail),
|
|
operation: OperationType.UPDATE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
when(entityClient.load(MailTypeRef, unreadMail._id)).thenResolve(unreadMail)
|
|
|
|
entityUpdateData.operation = OperationType.UPDATE
|
|
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
|
|
model.setFilter([getMailFilterForType(MailFilterType.Unread)])
|
|
|
|
const unreadMails = model.items
|
|
|
|
o.check(unreadMails.length).equals(1)
|
|
})
|
|
|
|
o.test("When the mail does not match filter after filter was set, it is still kept in the list", async () => {
|
|
const allMails = await setUpTestData(2, [], false, 1)
|
|
await model.loadInitial()
|
|
|
|
o.check(model.items.length).equals(2)
|
|
|
|
model.setFilter([getMailFilterForType(MailFilterType.Read)])
|
|
|
|
const unreadMail = allMails[0]
|
|
unreadMail.unread = true
|
|
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailTypeRef,
|
|
instanceListId: getListId(unreadMail) as NonEmptyString,
|
|
instanceId: getElementId(unreadMail),
|
|
operation: OperationType.UPDATE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
when(entityClient.load(MailTypeRef, unreadMail._id)).thenResolve(unreadMail)
|
|
|
|
entityUpdateData.operation = OperationType.UPDATE
|
|
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
|
|
const unreadMails = model.items
|
|
|
|
o.check(unreadMails.length).equals(2)
|
|
})
|
|
|
|
o.test("When filter is set, the relevant Email in the conversation is shown", async () => {
|
|
const allMails = await setUpTestData(5, [], false, 5)
|
|
await model.loadInitial()
|
|
|
|
let displayedItem = model.items
|
|
|
|
o.check(isSameId(displayedItem[0]._id, allMails[0]._id)).equals(true)
|
|
|
|
const unreadMail = allMails[2]
|
|
unreadMail.unread = true
|
|
|
|
const entityUpdateData: EntityUpdateData = {
|
|
typeRef: MailTypeRef,
|
|
instanceListId: getListId(unreadMail) as NonEmptyString,
|
|
instanceId: getElementId(unreadMail),
|
|
operation: OperationType.UPDATE,
|
|
...noPatchesAndInstance,
|
|
prefetchStatus: PrefetchStatus.NotPrefetched,
|
|
}
|
|
when(entityClient.load(MailTypeRef, unreadMail._id)).thenResolve(unreadMail)
|
|
|
|
entityUpdateData.operation = OperationType.UPDATE
|
|
|
|
await model.handleEntityUpdate(entityUpdateData)
|
|
|
|
model.setFilter([getMailFilterForType(MailFilterType.Unread)])
|
|
|
|
displayedItem = model.items
|
|
|
|
o.check(isSameId(displayedItem[0]._id, unreadMail._id)).equals(true)
|
|
})
|
|
})
|
|
})
|