diff --git a/Makefile b/Makefile index f8be343..56c0d09 100644 --- a/Makefile +++ b/Makefile @@ -17,36 +17,25 @@ deps: go get -u github.com/knadh/stuffbin/... cd frontend && yarn install -# Build the backend to ./listmonk. -.PHONY: build -build: - go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go - -# Run the backend. -.PHONY: run -run: build - ./${BIN} +# Run the JS frontend server in dev mode. +.PHONY: run-frontend +run-frontend: + export VUE_APP_VERSION="${VERSION}" && cd frontend && yarn serve # Build the JS frontend into frontend/dist. .PHONY: build-frontend build-frontend: export VUE_APP_VERSION="${VERSION}" && cd frontend && yarn build -# Run the JS frontend server in dev mode. -.PHONY: run-frontend -run-frontend: - export VUE_APP_VERSION="${VERSION}" && cd frontend && yarn serve +# Run the backend. +.PHONY: run +run: build + ./${BIN} -# Run Go tests. -.PHONY: test -test: - go test ./... - -# Bundle all static assets including the JS frontend into the ./listmonk binary -# using stuffbin (installed with make deps). -.PHONY: dist -dist: build build-frontend - stuffbin -a stuff -in ${BIN} -out ${BIN} ${STATIC} +# Build the backend to ./listmonk. +.PHONY: build +build: + go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go # pack-releases runns stuffbin packing on the given binary. This is used # in the .goreleaser post-build hook. @@ -54,6 +43,12 @@ dist: build build-frontend pack-bin: stuffbin -a stuff -in ${BIN} -out ${BIN} ${STATIC} +# Bundle all static assets including the JS frontend into the ./listmonk binary +# using stuffbin (installed with make deps). +.PHONY: dist +dist: build build-frontend + stuffbin -a stuff -in ${BIN} -out ${BIN} ${STATIC} + # Use goreleaser to do a dry run producing local builds. .PHONY: release-dry release-dry: @@ -63,3 +58,13 @@ release-dry: .PHONY: release release: goreleaser --parallelism 1 --rm-dist --skip-validate + +# Opens the cypress frontend tests UI. +.PHONY: open-frontend-tests +open-frontend-tests: + cd frontend && ./node_modules/cypress/bin/cypress open + +# Run Go tests. +.PHONY: test +test: + go test ./... diff --git a/cmd/handlers.go b/cmd/handlers.go index b1eaef0..0a0d9b7 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -37,9 +37,17 @@ var ( ) // registerHandlers registers HTTP handlers. -func registerHTTPHandlers(e *echo.Echo) { +func registerHTTPHandlers(e *echo.Echo, app *App) { // Group of private handlers with BasicAuth. - g := e.Group("", middleware.BasicAuth(basicAuth)) + var g *echo.Group + + if len(app.constants.AdminUsername) == 0 || + len(app.constants.AdminPassword) == 0 { + g = e.Group("") + } else { + g = e.Group("", middleware.BasicAuth(basicAuth)) + } + g.GET("/", handleIndexPage) g.GET("/api/health", handleHealthCheck) g.GET("/api/config", handleGetServerConfig) diff --git a/cmd/init.go b/cmd/init.go index 9170f9b..da8164b 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -487,7 +487,7 @@ func initHTTPServer(app *App) *echo.Echo { } // Register all HTTP handlers. - registerHTTPHandlers(srv) + registerHTTPHandlers(srv, app) // Start the server. go func() { diff --git a/frontend/README.md b/frontend/README.md index 85ae477..b703d7a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -12,6 +12,7 @@ In `main.js`, Buefy and vue-i18n are attached globally. In addition: Some constants are defined in `constants.js`. + ## APIs and states The project uses a global `vuex` state to centrally store the responses to pretty much all APIs (eg: fetch lists, campaigns etc.) except for a few exceptions. These are called `models` and have been defined in `constants.js`. The definitions are in `store/index.js`. diff --git a/frontend/cypress.json b/frontend/cypress.json new file mode 100644 index 0000000..f8db83a --- /dev/null +++ b/frontend/cypress.json @@ -0,0 +1,8 @@ +{ + "baseUrl": "http://localhost:9000", + "env": { + "server_init_command": "pkill -9 listmonk | cd ../ && ./listmonk --install --yes && ./listmonk > /dev/null 2>/dev/null &", + "username": "listmonk", + "password": "listmonk" + } +} diff --git a/frontend/cypress/downloads/data.json b/frontend/cypress/downloads/data.json new file mode 100644 index 0000000..81f86d4 --- /dev/null +++ b/frontend/cypress/downloads/data.json @@ -0,0 +1,28 @@ +{ + "profile": [ + { + "id": 2, + "uuid": "0954ba2e-50e4-4847-86f4-c2b8b72dace8", + "email": "anon@example.com", + "name": "Anon Doe", + "attribs": { + "city": "Bengaluru", + "good": true, + "type": "unknown" + }, + "status": "enabled", + "created_at": "2021-02-20T15:52:16.251648+05:30", + "updated_at": "2021-02-20T15:52:16.251648+05:30" + } + ], + "subscriptions": [ + { + "subscription_status": "unconfirmed", + "name": "Opt-in list", + "type": "public", + "created_at": "2021-02-20T15:52:16.251648+05:30" + } + ], + "campaign_views": [], + "link_clicks": [] +} \ No newline at end of file diff --git a/frontend/cypress/fixtures/subs.csv b/frontend/cypress/fixtures/subs.csv new file mode 100644 index 0000000..d792aaf --- /dev/null +++ b/frontend/cypress/fixtures/subs.csv @@ -0,0 +1,101 @@ +email,name,attributes +user0@mail.com,First0 Last0,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}" +user1@mail.com,First1 Last1,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}" +user2@mail.com,First2 Last2,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX70""}" +user3@mail.com,First3 Last3,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX32""}" +user4@mail.com,First4 Last4,"{""age"": 63, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}" +user5@mail.com,First5 Last5,"{""age"": 69, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}" +user6@mail.com,First6 Last6,"{""age"": 68, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}" +user7@mail.com,First7 Last7,"{""age"": 56, ""city"": ""Bangalore"", ""clientId"": ""DAXX54""}" +user8@mail.com,First8 Last8,"{""age"": 58, ""city"": ""Bangalore"", ""clientId"": ""DAXX65""}" +user9@mail.com,First9 Last9,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX66""}" +user10@mail.com,First10 Last10,"{""age"": 53, ""city"": ""Bangalore"", ""clientId"": ""DAXX31""}" +user11@mail.com,First11 Last11,"{""age"": 46, ""city"": ""Bangalore"", ""clientId"": ""DAXX59""}" +user12@mail.com,First12 Last12,"{""age"": 41, ""city"": ""Bangalore"", ""clientId"": ""DAXX80""}" +user13@mail.com,First13 Last13,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX96""}" +user14@mail.com,First14 Last14,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}" +user15@mail.com,First15 Last15,"{""age"": 31, ""city"": ""Bangalore"", ""clientId"": ""DAXX97""}" +user16@mail.com,First16 Last16,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}" +user17@mail.com,First17 Last17,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX93""}" +user18@mail.com,First18 Last18,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX35""}" +user19@mail.com,First19 Last19,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX21""}" +user20@mail.com,First20 Last20,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX56""}" +user21@mail.com,First21 Last21,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}" +user22@mail.com,First22 Last22,"{""age"": 44, ""city"": ""Bangalore"", ""clientId"": ""DAXX98""}" +user23@mail.com,First23 Last23,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}" +user24@mail.com,First24 Last24,"{""age"": 48, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}" +user25@mail.com,First25 Last25,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX80""}" +user26@mail.com,First26 Last26,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}" +user27@mail.com,First27 Last27,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}" +user28@mail.com,First28 Last28,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX45""}" +user29@mail.com,First29 Last29,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}" +user30@mail.com,First30 Last30,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX27""}" +user31@mail.com,First31 Last31,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX37""}" +user32@mail.com,First32 Last32,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX50""}" +user33@mail.com,First33 Last33,"{""age"": 70, ""city"": ""Bangalore"", ""clientId"": ""DAXX29""}" +user34@mail.com,First34 Last34,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX95""}" +user35@mail.com,First35 Last35,"{""age"": 36, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}" +user36@mail.com,First36 Last36,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}" +user37@mail.com,First37 Last37,"{""age"": 36, ""city"": ""Bangalore"", ""clientId"": ""DAXX92""}" +user38@mail.com,First38 Last38,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX48""}" +user39@mail.com,First39 Last39,"{""age"": 23, ""city"": ""Bangalore"", ""clientId"": ""DAXX12""}" +user40@mail.com,First40 Last40,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX40""}" +user41@mail.com,First41 Last41,"{""age"": 41, ""city"": ""Bangalore"", ""clientId"": ""DAXX51""}" +user42@mail.com,First42 Last42,"{""age"": 22, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}" +user43@mail.com,First43 Last43,"{""age"": 68, ""city"": ""Bangalore"", ""clientId"": ""DAXX58""}" +user44@mail.com,First44 Last44,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX15""}" +user45@mail.com,First45 Last45,"{""age"": 44, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}" +user46@mail.com,First46 Last46,"{""age"": 42, ""city"": ""Bangalore"", ""clientId"": ""DAXX99""}" +user47@mail.com,First47 Last47,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX39""}" +user48@mail.com,First48 Last48,"{""age"": 57, ""city"": ""Bangalore"", ""clientId"": ""DAXX13""}" +user49@mail.com,First49 Last49,"{""age"": 28, ""city"": ""Bangalore"", ""clientId"": ""DAXX97""}" +user50@mail.com,First50 Last50,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}" +user51@mail.com,First51 Last51,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}" +user52@mail.com,First52 Last52,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX35""}" +user53@mail.com,First53 Last53,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX67""}" +user54@mail.com,First54 Last54,"{""age"": 25, ""city"": ""Bangalore"", ""clientId"": ""DAXX36""}" +user55@mail.com,First55 Last55,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}" +user56@mail.com,First56 Last56,"{""age"": 53, ""city"": ""Bangalore"", ""clientId"": ""DAXX28""}" +user57@mail.com,First57 Last57,"{""age"": 32, ""city"": ""Bangalore"", ""clientId"": ""DAXX36""}" +user58@mail.com,First58 Last58,"{""age"": 64, ""city"": ""Bangalore"", ""clientId"": ""DAXX44""}" +user59@mail.com,First59 Last59,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX65""}" +user60@mail.com,First60 Last60,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX11""}" +user61@mail.com,First61 Last61,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}" +user62@mail.com,First62 Last62,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}" +user63@mail.com,First63 Last63,"{""age"": 52, ""city"": ""Bangalore"", ""clientId"": ""DAXX83""}" +user64@mail.com,First64 Last64,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX16""}" +user65@mail.com,First65 Last65,"{""age"": 48, ""city"": ""Bangalore"", ""clientId"": ""DAXX54""}" +user66@mail.com,First66 Last66,"{""age"": 35, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}" +user67@mail.com,First67 Last67,"{""age"": 70, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}" +user68@mail.com,First68 Last68,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX98""}" +user69@mail.com,First69 Last69,"{""age"": 46, ""city"": ""Bangalore"", ""clientId"": ""DAXX24""}" +user70@mail.com,First70 Last70,"{""age"": 58, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}" +user71@mail.com,First71 Last71,"{""age"": 50, ""city"": ""Bangalore"", ""clientId"": ""DAXX57""}" +user72@mail.com,First72 Last72,"{""age"": 63, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}" +user73@mail.com,First73 Last73,"{""age"": 54, ""city"": ""Bangalore"", ""clientId"": ""DAXX77""}" +user74@mail.com,First74 Last74,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX91""}" +user75@mail.com,First75 Last75,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}" +user76@mail.com,First76 Last76,"{""age"": 50, ""city"": ""Bangalore"", ""clientId"": ""DAXX28""}" +user77@mail.com,First77 Last77,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}" +user78@mail.com,First78 Last78,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX18""}" +user79@mail.com,First79 Last79,"{""age"": 40, ""city"": ""Bangalore"", ""clientId"": ""DAXX89""}" +user80@mail.com,First80 Last80,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX72""}" +user81@mail.com,First81 Last81,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX31""}" +user82@mail.com,First82 Last82,"{""age"": 33, ""city"": ""Bangalore"", ""clientId"": ""DAXX89""}" +user83@mail.com,First83 Last83,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX88""}" +user84@mail.com,First84 Last84,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX77""}" +user85@mail.com,First85 Last85,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX40""}" +user86@mail.com,First86 Last86,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX46""}" +user87@mail.com,First87 Last87,"{""age"": 20, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}" +user88@mail.com,First88 Last88,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}" +user89@mail.com,First89 Last89,"{""age"": 31, ""city"": ""Bangalore"", ""clientId"": ""DAXX11""}" +user90@mail.com,First90 Last90,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}" +user91@mail.com,First91 Last91,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX20""}" +user92@mail.com,First92 Last92,"{""age"": 26, ""city"": ""Bangalore"", ""clientId"": ""DAXX20""}" +user93@mail.com,First93 Last93,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}" +user94@mail.com,First94 Last94,"{""age"": 60, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}" +user95@mail.com,First95 Last95,"{""age"": 64, ""city"": ""Bangalore"", ""clientId"": ""DAXX91""}" +user96@mail.com,First96 Last96,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}" +user97@mail.com,First97 Last97,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX46""}" +user98@mail.com,First98 Last98,"{""age"": 26, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}" +user99@mail.com,First99 Last99,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}" diff --git a/frontend/cypress/integration/campaigns.js b/frontend/cypress/integration/campaigns.js new file mode 100644 index 0000000..a80567e --- /dev/null +++ b/frontend/cypress/integration/campaigns.js @@ -0,0 +1,211 @@ +describe('Subscribers', () => { + it('Opens campaigns page', () => { + cy.resetDB(); + cy.loginAndVisit('/campaigns'); + }); + + + it('Counts campaigns', () => { + cy.get('tbody td[data-label=Status]').should('have.length', 1); + }); + + it('Edits campaign', () => { + cy.get('td[data-label=Status] a').click(); + + // Fill fields. + cy.get('input[name=name]').clear().type('new-name'); + cy.get('input[name=subject]').clear().type('new-subject'); + cy.get('input[name=from_email]').clear().type('new '); + + // Change the list. + cy.get('.list-selector a.delete').click(); + cy.get('.list-selector input').click(); + cy.get('.list-selector .autocomplete a').eq(1).click(); + + // Clear and redo tags. + cy.get('input[name=tags]').type('{backspace}new-tag{enter}'); + + // Enable schedule. + cy.get('[data-cy=btn-send-later] .check').click(); + cy.get('.datepicker input').click(); + cy.get('.datepicker-header .control:nth-child(2) select').select((new Date().getFullYear() + 1).toString()); + cy.get('.datepicker-body a.is-selectable:first').click(); + cy.get('body').click(1, 1); + + // Switch to content tab. + cy.get('.b-tabs nav a').eq(1).click(); + + // Switch format to plain text. + cy.get('label[data-cy=check-plain]').click(); + cy.get('.modal button.is-primary').click(); + + // Enter body value. + cy.get('textarea[name=content]').clear().type('new-content'); + cy.get('button[data-cy=btn-save]').click(); + + // Schedule. + cy.get('button[data-cy=btn-schedule]').click(); + cy.get('.modal button.is-primary').click(); + + cy.wait(250); + + // Verify the changes. + cy.request('/api/campaigns/1').should((response) => { + const { data } = response.body; + expect(data.status).to.equal('scheduled'); + expect(data.name).to.equal('new-name'); + expect(data.subject).to.equal('new-subject'); + expect(data.content_type).to.equal('plain'); + expect(data.altbody).to.equal(null); + expect(data.send_at).to.not.equal(null); + expect(data.body).to.equal('new-content'); + + expect(data.lists.length).to.equal(1); + expect(data.lists[0].id).to.equal(2); + expect(data.tags.length).to.equal(1); + expect(data.tags[0]).to.equal('new-tag'); + }); + + cy.get('tbody td[data-label=Status] .tag.scheduled'); + }); + + it('Clones campaign', () => { + for (let n = 0; n < 3; n++) { + // Clone the campaign. + cy.get('[data-cy=btn-clone]').first().click(); + cy.get('.modal input').clear().type(`clone${n}`).click(); + cy.get('.modal button.is-primary').click(); + cy.wait(250); + cy.clickMenu('all-campaigns'); + cy.wait(100); + + // Verify the newly created row. + cy.get('tbody td[data-label="Name"]').first().contains(`clone${n}`); + } + }); + + + it('Searches campaigns', () => { + cy.get('input[name=query]').clear().type('clone2{enter}'); + cy.get('tbody tr').its('length').should('eq', 1); + cy.get('tbody td[data-label="Name"]').first().contains('clone2'); + cy.get('input[name=query]').clear().type('{enter}'); + }); + + + it('Deletes campaign', () => { + // Delete all visible lists. + cy.get('tbody tr').each(() => { + cy.get('tbody a[data-cy=btn-delete]').first().click(); + cy.get('.modal button.is-primary').click(); + }); + + // Confirm deletion. + cy.get('table tr.is-empty'); + }); + + + it('Adds new campaigns', () => { + const lists = [[1], [1, 2]]; + const cTypes = ['richtext', 'html', 'plain']; + + let n = 0; + cTypes.forEach((c) => { + lists.forEach((l) => { + // Click the 'new button' + cy.get('[data-cy=btn-new]').click(); + cy.wait(100); + + // Fill fields. + cy.get('input[name=name]').clear().type(`name${n}`); + cy.get('input[name=subject]').clear().type(`subject${n}`); + + l.forEach(() => { + cy.get('.list-selector input').click(); + cy.get('.list-selector .autocomplete a').first().click(); + }); + + // Add tags. + for (let i = 0; i < 3; i++) { + cy.get('input[name=tags]').type(`tag${i}{enter}`); + } + + // Hit 'Continue'. + cy.get('button[data-cy=btn-continue]').click(); + cy.wait(250); + + // Insert content. + cy.get('.ql-editor').type(`hello${n} \{\{ .Subscriber.Name \}\}`, { parseSpecialCharSequences: false }); + cy.get('.ql-editor').type('{enter}'); + cy.get('.ql-editor').type('\{\{ .Subscriber.Attribs.city \}\}', { parseSpecialCharSequences: false }); + + // Select content type. + cy.get(`label[data-cy=check-${c}]`).click(); + + // If it's not richtext, there's a "you'll lose formatting" prompt. + if (c !== 'richtext') { + cy.get('.modal button.is-primary').click(); + } + + // Save. + cy.get('button[data-cy=btn-save]').click(); + + cy.clickMenu('all-campaigns'); + cy.wait(250); + + // Verify the newly created campaign in the table. + cy.get('tbody td[data-label="Name"]').first().contains(`name${n}`); + cy.get('tbody td[data-label="Name"]').first().contains(`subject${n}`); + cy.get('tbody td[data-label="Lists"]').first().then(($el) => { + cy.wrap($el).find('li').should('have.length', l.length); + }); + + n++; + }); + }); + + // Fetch the campaigns API and verfiy the values that couldn't be verified on the table UI. + cy.request('/api/campaigns?order=asc&order_by=created_at').should((response) => { + const { data } = response.body; + expect(data.total).to.equal(lists.length * cTypes.length); + + let n = 0; + cTypes.forEach((c) => { + lists.forEach((l) => { + expect(data.results[n].content_type).to.equal(c); + expect(data.results[n].lists.map((ls) => ls.id)).to.deep.equal(l); + n++; + }); + }); + }); + }); + + it('Starts and cancels campaigns', () => { + for (let n = 1; n <= 2; n++) { + cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-start]`).click(); + cy.get('.modal button.is-primary').click(); + cy.wait(250); + cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.running`); + + if (n > 1) { + cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-cancel]`).click(); + cy.get('.modal button.is-primary').click(); + cy.wait(250); + cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.cancelled`); + } + } + }); + + it('Sorts campaigns', () => { + const asc = [5, 6, 7, 8, 9, 10]; + const desc = [10, 9, 8, 7, 6, 5]; + const cases = ['cy-name', 'cy-timestamp']; + + cases.forEach((c) => { + cy.sortTable(`thead th.${c}`, asc); + cy.wait(250); + cy.sortTable(`thead th.${c}`, desc); + cy.wait(250); + }); + }); +}); diff --git a/frontend/cypress/integration/dashboard.js b/frontend/cypress/integration/dashboard.js new file mode 100644 index 0000000..a19310d --- /dev/null +++ b/frontend/cypress/integration/dashboard.js @@ -0,0 +1,28 @@ +describe('Dashboard', () => { + it('Opens dashboard', () => { + cy.loginAndVisit('/'); + + // List counts. + cy.get('[data-cy=lists]') + .should('contain', '2 Lists') + .and('contain', '1 Public') + .and('contain', '1 Private') + .and('contain', '1 Single opt-in') + .and('contain', '1 Double opt-in'); + + // Campaign counts. + cy.get('[data-cy=campaigns]') + .should('contain', '1 Campaign') + .and('contain', '1 draft'); + + // Subscriber counts. + cy.get('[data-cy=subscribers]') + .should('contain', '2 Subscribers') + .and('contain', '0 Blocklisted') + .and('contain', '0 Orphans'); + + // Message count. + cy.get('[data-cy=messages]') + .should('contain', '0 Messages sent'); + }); +}); diff --git a/frontend/cypress/integration/forms.js b/frontend/cypress/integration/forms.js new file mode 100644 index 0000000..d401d51 --- /dev/null +++ b/frontend/cypress/integration/forms.js @@ -0,0 +1,36 @@ +describe('Forms', () => { + it('Opens forms page', () => { + cy.resetDB(); + cy.loginAndVisit('/lists/forms'); + }); + + it('Checks form URL', () => { + cy.get('a[data-cy=url]').contains('http://localhost:9000'); + }); + + it('Checks public lists', () => { + cy.get('ul[data-cy=lists] li') + .should('contain', 'Opt-in list') + .its('length') + .should('eq', 1); + + cy.get('[data-cy=form] pre').should('not.exist'); + }); + + it('Selects public list', () => { + // Click the list checkbox. + cy.get('ul[data-cy=lists] .checkbox').click(); + + // Make sure the
 form HTML has appeared.
+    cy.get('[data-cy=form] pre').then(($pre) => {
+      // Check that the ID of the list in the checkbox appears in the HTML.
+      cy.get('ul[data-cy=lists] input').then(($inp) => {
+        cy.wrap($pre).contains($inp.val());
+      });
+    });
+
+    // Click the list checkbox.
+    cy.get('ul[data-cy=lists] .checkbox').click();
+    cy.get('[data-cy=form] pre').should('not.exist');
+  });
+});
diff --git a/frontend/cypress/integration/import.js b/frontend/cypress/integration/import.js
new file mode 100644
index 0000000..b8b8d55
--- /dev/null
+++ b/frontend/cypress/integration/import.js
@@ -0,0 +1,50 @@
+
+describe('Import', () => {
+  it('Opens import page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/subscribers/import');
+  });
+
+  it('Imports subscribers', () => {
+    const cases = [
+      { mode: 'check-subscribe', status: 'enabled', count: 102 },
+      { mode: 'check-blocklist', status: 'blocklisted', count: 102 },
+    ];
+
+    cases.forEach((c) => {
+      cy.get(`[data-cy=${c.mode}] .check`).click();
+
+      if (c.status === 'enabled') {
+        cy.get('.list-selector input').click();
+        cy.get('.list-selector .autocomplete a').first().click();
+      }
+
+      cy.fixture('subs.csv').then((data) => {
+        cy.get('input[type="file"]').attachFile({
+          fileContent: data.toString(),
+          fileName: 'subs.csv',
+          mimeType: 'text/csv',
+        });
+      });
+
+      cy.get('button.is-primary').click();
+      cy.get('section.wrap .has-text-success');
+      cy.get('button.is-primary').click();
+      cy.wait(100);
+
+      // Verify that 100 (+2 default) subs are imported.
+      cy.loginAndVisit('/subscribers');
+      cy.wait(100);
+      cy.get('[data-cy=count]').then(($el) => {
+        cy.expect(parseInt($el.text().trim())).to.equal(c.count);
+      });
+
+      cy.get('tbody td[data-label=Status]').each(($el) => {
+        cy.wrap($el).find(`.tag.${c.status}`);
+      });
+
+      cy.loginAndVisit('/subscribers/import');
+      cy.wait(100);
+    });
+  });
+});
diff --git a/frontend/cypress/integration/lists.js b/frontend/cypress/integration/lists.js
new file mode 100644
index 0000000..a8210fb
--- /dev/null
+++ b/frontend/cypress/integration/lists.js
@@ -0,0 +1,130 @@
+describe('Lists', () => {
+  it('Opens lists page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/lists');
+  });
+
+
+  it('Counts subscribers in default lists', () => {
+    cy.get('tbody td[data-label=Subscribers]').contains('1');
+  });
+
+
+  it('Creates campaign for list', () => {
+    cy.get('tbody a[data-cy=btn-campaign]').first().click();
+    cy.location('pathname').should('contain', '/campaigns/new');
+    cy.get('.list-tags .tag').contains('Default list');
+
+    cy.clickMenu('lists', 'all-lists');
+  });
+
+
+  it('Creates opt-in campaign for list', () => {
+    cy.get('tbody a[data-cy=btn-send-optin-campaign]').click();
+    cy.get('.modal button.is-primary').click();
+    cy.location('pathname').should('contain', '/campaigns/2');
+
+    cy.clickMenu('lists', 'all-lists');
+  });
+
+
+  it('Checks individual subscribers in lists', () => {
+    const subs = [{ listID: 1, email: 'john@example.com' },
+      { listID: 2, email: 'anon@example.com' }];
+
+    // Click on each list on the lists page, go the the subscribers page
+    // for that list, and check the subscriber details.
+    subs.forEach((s, n) => {
+      cy.get('tbody td[data-label=Subscribers] a').eq(n).click();
+      cy.location('pathname').should('contain', `/subscribers/lists/${s.listID}`);
+      cy.get('tbody tr').its('length').should('eq', 1);
+      cy.get('tbody td[data-label="E-mail"]').contains(s.email);
+      cy.clickMenu('lists', 'all-lists');
+    });
+  });
+
+  it('Edits lists', () => {
+    // Open the edit popup and edit the default lists.
+    cy.get('[data-cy=btn-edit]').each(($el, n) => {
+      cy.wrap($el).click();
+      cy.get('input[name=name]').clear().type(`list-${n}`);
+      cy.get('select[name=type]').select('public');
+      cy.get('select[name=optin]').select('double');
+      cy.get('input[name=tags]').clear().type(`tag${n}`);
+      cy.get('button[type=submit]').click();
+    });
+    cy.wait(250);
+
+    // Confirm the edits.
+    cy.get('tbody tr').each(($el, n) => {
+      cy.wrap($el).find('td[data-label=Name]').contains(`list-${n}`);
+      cy.wrap($el).find('.tags')
+        .should('contain', 'test')
+        .and('contain', `tag${n}`);
+    });
+  });
+
+
+  it('Deletes lists', () => {
+    // Delete all visible lists.
+    cy.get('tbody tr').each(() => {
+      cy.get('tbody a[data-cy=btn-delete]').first().click();
+      cy.get('.modal button.is-primary').click();
+    });
+
+    // Confirm deletion.
+    cy.get('table tr.is-empty');
+  });
+
+
+  // Add new lists.
+  it('Adds new lists', () => {
+    // Open the list form and create lists of multiple type/optin combinations.
+    const types = ['private', 'public'];
+    const optin = ['single', 'double'];
+
+    let n = 0;
+    types.forEach((t) => {
+      optin.forEach((o) => {
+        const name = `list-${t}-${o}-${n}`;
+
+        cy.get('[data-cy=btn-new]').click();
+        cy.get('input[name=name]').type(name);
+        cy.get('select[name=type]').select(t);
+        cy.get('select[name=optin]').select(o);
+        cy.get('input[name=tags]').type(`tag${n}{enter}${t}{enter}${o}{enter}`);
+        cy.get('button[type=submit]').click();
+
+        // Confirm the addition by inspecting the newly created list row.
+        const tr = `tbody tr:nth-child(${n + 1})`;
+        cy.get(`${tr} td[data-label=Name]`).contains(name);
+        cy.get(`${tr} td[data-label=Type] [data-cy=type-${t}]`);
+        cy.get(`${tr} td[data-label=Type] [data-cy=optin-${o}]`);
+        cy.get(`${tr} .tags`)
+          .should('contain', `tag${n}`)
+          .and('contain', t)
+          .and('contain', o);
+
+        n++;
+      });
+    });
+  });
+
+
+  // Sort lists by clicking on various headers. At this point, there should be four
+  // lists with IDs = [3, 4, 5, 6]. Sort the items be columns and match them with
+  // the expected order of IDs.
+  it('Sorts lists', () => {
+    cy.sortTable('thead th.cy-name', [4, 3, 6, 5]);
+    cy.sortTable('thead th.cy-name', [5, 6, 3, 4]);
+
+    cy.sortTable('thead th.cy-type', [5, 6, 4, 3]);
+    cy.sortTable('thead th.cy-type', [4, 3, 5, 6]);
+
+    cy.sortTable('thead th.cy-created_at', [3, 4, 5, 6]);
+    cy.sortTable('thead th.cy-created_at', [6, 5, 4, 3]);
+
+    cy.sortTable('thead th.cy-updated_at', [3, 4, 5, 6]);
+    cy.sortTable('thead th.cy-updated_at', [6, 5, 4, 3]);
+  });
+});
diff --git a/frontend/cypress/integration/settings.js b/frontend/cypress/integration/settings.js
new file mode 100644
index 0000000..28de7bf
--- /dev/null
+++ b/frontend/cypress/integration/settings.js
@@ -0,0 +1,40 @@
+describe('Templates', () => {
+  it('Opens settings page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/settings');
+  });
+
+  it('Changes some settings', () => {
+    const rootURL = 'http://127.0.0.1:9000';
+    const faveURL = 'http://127.0.0.1:9000/public/static/logo.png';
+
+    cy.get('input[name="app.root_url"]').clear().type(rootURL);
+    cy.get('input[name="app.favicon_url"]').type(faveURL);
+    cy.get('.b-tabs nav a').eq(1).click();
+    cy.get('.tab-item:visible').find('.field').first()
+      .find('button')
+      .first()
+      .click();
+
+    // Enable / disable SMTP and delete one.
+    cy.get('.b-tabs nav a').eq(4).click();
+    cy.get('.tab-item:visible [data-cy=btn-enable-smtp]').eq(1).click();
+    cy.get('.tab-item:visible [data-cy=btn-delete-smtp]').first().click();
+    cy.get('.modal button.is-primary').click();
+
+    cy.get('[data-cy=btn-save]').click();
+
+    cy.wait(250);
+
+    // Verify the changes.
+    cy.request('/api/settings').should((response) => {
+      const { data } = response.body;
+      expect(data['app.root_url']).to.equal(rootURL);
+      expect(data['app.favicon_url']).to.equal(faveURL);
+      expect(data['app.concurrency']).to.equal(9);
+
+      expect(data.smtp.length).to.equal(1);
+      expect(data.smtp[0].enabled).to.equal(true);
+    });
+  });
+});
diff --git a/frontend/cypress/integration/subscribers.js b/frontend/cypress/integration/subscribers.js
new file mode 100644
index 0000000..aa74094
--- /dev/null
+++ b/frontend/cypress/integration/subscribers.js
@@ -0,0 +1,219 @@
+describe('Subscribers', () => {
+  it('Opens subscribers page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/subscribers');
+  });
+
+
+  it('Counts subscribers', () => {
+    cy.get('tbody td[data-label=Status]').its('length').should('eq', 2);
+  });
+
+
+  it('Searches subscribers', () => {
+    const cases = [
+      { value: 'john{enter}', count: 1, contains: 'john@example.com' },
+      { value: 'anon{enter}', count: 1, contains: 'anon@example.com' },
+      { value: '{enter}', count: 2, contains: null },
+    ];
+
+    cases.forEach((c) => {
+      cy.get('[data-cy=search]').clear().type(c.value);
+      cy.get('tbody td[data-label=Status]').its('length').should('eq', c.count);
+      if (c.contains) {
+        cy.get('tbody td[data-label=E-mail]').contains(c.contains);
+      }
+    });
+  });
+
+
+  it('Advanced searches subscribers', () => {
+    cy.get('[data-cy=btn-advanced-search]').click();
+
+    const cases = [
+      { value: 'subscribers.attribs->>\'city\'=\'Bengaluru\'', count: 2 },
+      { value: 'subscribers.attribs->>\'city\'=\'Bengaluru\' AND id=1', count: 1 },
+      { value: '(subscribers.attribs->>\'good\')::BOOLEAN = true AND name like \'Anon%\'', count: 1 },
+    ];
+
+    cases.forEach((c) => {
+      cy.get('[data-cy=query]').clear().type(c.value);
+      cy.get('[data-cy=btn-query]').click();
+      cy.get('tbody td[data-label=Status]').its('length').should('eq', c.count);
+    });
+
+    cy.get('[data-cy=btn-query-reset]').click();
+    cy.get('tbody td[data-label=Status]').its('length').should('eq', 2);
+  });
+
+
+  it('Does bulk subscriber list add and remove', () => {
+    const cases = [
+      // radio: action to perform, rows: table rows to select and perform on: [expected statuses of those rows after thea action]
+      { radio: 'check-list-add', lists: [0, 1], rows: { 0: ['unconfirmed', 'unconfirmed'] } },
+      { radio: 'check-list-unsubscribe', lists: [0, 1], rows: { 0: ['unsubscribed', 'unsubscribed'], 1: ['unsubscribed'] } },
+      { radio: 'check-list-remove', lists: [0, 1], rows: { 1: [] } },
+      { radio: 'check-list-add', lists: [0, 1], rows: { 0: ['unsubscribed', 'unsubscribed'], 1: ['unconfirmed', 'unconfirmed'] } },
+      { radio: 'check-list-remove', lists: [0], rows: { 0: ['unsubscribed'] } },
+      { radio: 'check-list-add', lists: [0], rows: { 0: ['unconfirmed', 'unsubscribed'] } },
+    ];
+
+
+    cases.forEach((c, n) => {
+      // Select one of the 2 subscriber in the table.
+      Object.keys(c.rows).forEach((r) => {
+        cy.get('tbody td.checkbox-cell .checkbox').eq(r).click();
+      });
+
+      // Open the 'manage lists' modal.
+      cy.get('[data-cy=btn-manage-lists]').click();
+
+      // Check both lists in the modal.
+      c.lists.forEach((l) => {
+        cy.get('.list-selector input').click();
+        cy.get('.list-selector .autocomplete a').first().click();
+      });
+
+      // Select the radio option in the modal.
+      cy.get(`[data-cy=${c.radio}] .check`).click();
+
+      // Save.
+      cy.get('.modal button.is-primary').click();
+
+      // Check the status of the lists on the subscriber.
+      Object.keys(c.rows).forEach((r) => {
+        cy.get('tbody td[data-label=E-mail]').eq(r).find('.tags').then(($el) => {
+          cy.wrap($el).find('.tag').should('have.length', c.rows[r].length);
+          c.rows[r].forEach((status, n) => {
+            // eg: .tag(n).unconfirmed
+            cy.wrap($el).find(`.tag:nth-child(${n + 1}).${status}`);
+          });
+        });
+      });
+    });
+  });
+
+  it('Resets subscribers page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/subscribers');
+  });
+
+
+  it('Edits subscribers', () => {
+    const status = ['enabled', 'blocklisted'];
+    const json = '{"string": "hello", "ints": [1,2,3], "null": null, "sub": {"bool": true}}';
+
+    // Collect values being edited on each sub to confirm the changes in the next step
+    // index by their ID shown in the modal.
+    const rows = {};
+
+    // Open the edit popup and edit the default lists.
+    cy.get('[data-cy=btn-edit]').each(($el, n) => {
+      const email = `email-${n}@email.com`;
+      const name = `name-${n}`;
+
+      // Open the edit modal.
+      cy.wrap($el).click();
+
+      // Get the ID from the header and proceed to fill the form.
+      let id = 0;
+      cy.get('[data-cy=id]').then(($el) => {
+        id = $el.text();
+
+        cy.get('input[name=email]').clear().type(email);
+        cy.get('input[name=name]').clear().type(name);
+        cy.get('select[name=status]').select(status[n]);
+        cy.get('.list-selector input').click();
+        cy.get('.list-selector .autocomplete a').first().click();
+        cy.get('textarea[name=attribs]').clear().type(json, { parseSpecialCharSequences: false, delay: 0 });
+        cy.get('.modal-card-foot button[type=submit]').click();
+
+        rows[id] = { email, name, status: status[n] };
+      });
+    });
+
+    // Confirm the edits on the table.
+    cy.get('tbody tr').each(($el) => {
+      cy.wrap($el).find('td[data-id]').invoke('attr', 'data-id').then((id) => {
+        cy.wrap($el).find('td[data-label=E-mail]').contains(rows[id].email);
+        cy.wrap($el).find('td[data-label=Name]').contains(rows[id].name);
+        cy.wrap($el).find('td[data-label=Status]').contains(rows[id].status, { matchCase: false });
+
+        // Both lists on the enabled sub should be 'unconfirmed' and the blocklisted one, 'unsubscribed.'
+        cy.wait(250);
+        cy.wrap($el).find(`.tags .${rows[id].status === 'enabled' ? 'unconfirmed' : 'unsubscribed'}`)
+          .its('length').should('eq', 2);
+        cy.wrap($el).find('td[data-label=Lists]').then((l) => {
+          cy.expect(parseInt(l.text().trim())).to.equal(rows[id].status === 'blocklisted' ? 0 : 2);
+        });
+      });
+    });
+  });
+
+  it('Deletes subscribers', () => {
+    // Delete all visible lists.
+    cy.get('tbody tr').each(() => {
+      cy.get('tbody a[data-cy=btn-delete]').first().click();
+      cy.get('.modal button.is-primary').click();
+    });
+
+    // Confirm deletion.
+    cy.get('table tr.is-empty');
+  });
+
+
+  it('Creates new subscribers', () => {
+    const statuses = ['enabled', 'blocklisted'];
+    const lists = [[1], [2], [1, 2]];
+    const json = '{"string": "hello", "ints": [1,2,3], "null": null, "sub": {"bool": true}}';
+
+
+    // Cycle through each status and each list ID combination and create subscribers.
+    const n = 0;
+    for (let n = 0; n < 6; n++) {
+      const email = `email-${n}@email.com`;
+      const name = `name-${n}`;
+      const status = statuses[(n + 1) % statuses.length];
+      const list = lists[(n + 1) % lists.length];
+
+      cy.get('[data-cy=btn-new]').click();
+      cy.get('input[name=email]').type(email);
+      cy.get('input[name=name]').type(name);
+      cy.get('select[name=status]').select(status);
+
+      list.forEach((l) => {
+        cy.get('.list-selector input').click();
+        cy.get('.list-selector .autocomplete a').first().click();
+      });
+      cy.get('textarea[name=attribs]').clear().type(json, { parseSpecialCharSequences: false, delay: 0 });
+      cy.get('.modal-card-foot button[type=submit]').click();
+
+      // Confirm the addition by inspecting the newly created list row,
+      // which is always the first row in the table.
+      cy.wait(250);
+      const tr = cy.get('tbody tr:nth-child(1)').then(($el) => {
+        cy.wrap($el).find('td[data-label=E-mail]').contains(email);
+        cy.wrap($el).find('td[data-label=Name]').contains(name);
+        cy.wrap($el).find('td[data-label=Status]').contains(status, { matchCase: false });
+        cy.wrap($el).find(`.tags .${status === 'enabled' ? 'unconfirmed' : 'unsubscribed'}`)
+          .its('length').should('eq', list.length);
+        cy.wrap($el).find('td[data-label=Lists]').then((l) => {
+          cy.expect(parseInt(l.text().trim())).to.equal(status === 'blocklisted' ? 0 : list.length);
+        });
+      });
+    }
+  });
+
+  it('Sorts subscribers', () => {
+    const asc = [3, 4, 5, 6, 7, 8];
+    const desc = [8, 7, 6, 5, 4, 3];
+    const cases = ['cy-status', 'cy-email', 'cy-name', 'cy-created_at', 'cy-updated_at'];
+
+    cases.forEach((c) => {
+      cy.sortTable(`thead th.${c}`, asc);
+      cy.wait(100);
+      cy.sortTable(`thead th.${c}`, desc);
+      cy.wait(100);
+    });
+  });
+});
diff --git a/frontend/cypress/integration/templates.js b/frontend/cypress/integration/templates.js
new file mode 100644
index 0000000..48cc5af
--- /dev/null
+++ b/frontend/cypress/integration/templates.js
@@ -0,0 +1,77 @@
+describe('Templates', () => {
+  it('Opens templates page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/campaigns/templates');
+  });
+
+
+  it('Counts default templates', () => {
+    cy.get('tbody td[data-label=Name]').should('have.length', 1);
+  });
+
+  it('Clones template', () => {
+    // Clone the campaign.
+    cy.get('[data-cy=btn-clone]').first().click();
+    cy.get('.modal input').clear().type('cloned').click();
+    cy.get('.modal button.is-primary').click();
+    cy.wait(250);
+
+    // Verify the newly created row.
+    cy.get('tbody td[data-label="Name"]').eq(1).contains('cloned');
+  });
+
+  it('Edits template', () => {
+    cy.get('tbody td.actions [data-cy=btn-edit]').first().click();
+    cy.wait(250);
+    cy.get('input[name=name]').clear().type('edited');
+    cy.get('textarea[name=body]').clear().type('test {{ template "content" . }}',
+      { parseSpecialCharSequences: false, delay: 0 });
+    cy.get('footer.modal-card-foot button.is-primary').click();
+    cy.wait(250);
+    cy.get('tbody td[data-label="Name"] a').contains('edited');
+  });
+
+
+  it('Previews templates', () => {
+    // Edited one sould have a bare body.
+    cy.get('tbody [data-cy=btn-preview').eq(0).click();
+    cy.wait(500);
+    cy.get('.modal-card-body iframe').iframe(() => {
+      cy.get('span').first().contains('test');
+      cy.get('p').first().contains('Hi there');
+    });
+    cy.get('footer.modal-card-foot button').click();
+
+    // Cloned one should have the full template.
+    cy.get('tbody [data-cy=btn-preview').eq(1).click();
+    cy.wait(500);
+    cy.get('.modal-card-body iframe').iframe(() => {
+      cy.get('.wrap p').first().contains('Hi there');
+      cy.get('.footer a').first().contains('Unsubscribe');
+    });
+    cy.get('footer.modal-card-foot button').click();
+  });
+
+  it('Sets default', () => {
+    cy.get('tbody td.actions').eq(1).find('[data-cy=btn-set-default]').click();
+    cy.get('.modal button.is-primary').click();
+
+    // The original default shouldn't have default and the new one should have.
+    cy.get('tbody td.actions').eq(0).then((el) => {
+      cy.wrap(el).find('[data-cy=btn-delete]').should('exist');
+      cy.wrap(el).find('[data-cy=btn-set-default]').should('exist');
+    });
+    cy.get('tbody td.actions').eq(1).then((el) => {
+      cy.wrap(el).find('[data-cy=btn-delete]').should('not.exist');
+      cy.wrap(el).find('[data-cy=btn-set-default]').should('not.exist');
+    });
+  });
+
+
+  it('Deletes template', () => {
+    cy.get('tbody td.actions [data-cy=btn-delete]').first().click();
+    cy.get('.modal button.is-primary').click();
+    cy.wait(250);
+    cy.get('tbody td.actions').should('have.length', 1);
+  });
+});
diff --git a/frontend/cypress/plugins/index.js b/frontend/cypress/plugins/index.js
new file mode 100644
index 0000000..aa9918d
--- /dev/null
+++ b/frontend/cypress/plugins/index.js
@@ -0,0 +1,21 @@
+/// 
+// ***********************************************************
+// This example plugins/index.js can be used to load plugins
+//
+// You can change the location of this file or turn off loading
+// the plugins file with the 'pluginsFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/plugins-guide
+// ***********************************************************
+
+// This function is called when a project is opened or re-opened (e.g. due to
+// the project's config changing)
+
+/**
+ * @type {Cypress.PluginConfig}
+ */
+module.exports = (on, config) => {
+  // `on` is used to hook into various events Cypress emits
+  // `config` is the resolved Cypress config
+}
diff --git a/frontend/cypress/support/commands.js b/frontend/cypress/support/commands.js
new file mode 100644
index 0000000..e8b3fbf
--- /dev/null
+++ b/frontend/cypress/support/commands.js
@@ -0,0 +1,42 @@
+import 'cypress-file-upload';
+
+Cypress.Commands.add('resetDB', () => {
+  // Although cypress clearly states that a webserver should not be run
+  // from within it, listmonk is killed, the DB reset, and run again
+  // in the background. If the DB is reset without restartin listmonk,
+  // the live Postgres connections in the app throw errors because the
+  // schema changes midway.
+  cy.exec(Cypress.env('server_init_command'));
+});
+
+// Takes a th class selector of a Buefy table, clicks it sorting the table,
+// then compares the values of [td.data-id] attri of all the rows in the
+// table against the given IDs, asserting the expected order of sort.
+Cypress.Commands.add('sortTable', (theadSelector, ordIDs) => {
+  cy.get(theadSelector).click();
+  cy.get('tbody td[data-id]').each(($el, index) => {
+    expect(ordIDs[index]).to.equal(parseInt($el.attr('data-id')));
+  });
+});
+
+Cypress.Commands.add('loginAndVisit', (url) => {
+  cy.visit(url, {
+    auth: {
+      username: Cypress.env('username'),
+      password: Cypress.env('password'),
+    },
+  });
+});
+
+Cypress.Commands.add('clickMenu', (...selectors) => {
+  selectors.forEach((s) => {
+    cy.get(`.menu a[data-cy="${s}"]`).click();
+  });
+});
+
+// https://www.nicknish.co/blog/cypress-targeting-elements-inside-iframes
+Cypress.Commands.add('iframe', { prevSubject: 'element' }, ($iframe, callback = () => {}) => cy
+    .wrap($iframe)
+    .should((iframe) => expect(iframe.contents().find('body')).to.exist)
+    .then((iframe) => cy.wrap(iframe.contents().find('body')))
+    .within({}, callback));
diff --git a/frontend/cypress/support/index.js b/frontend/cypress/support/index.js
new file mode 100644
index 0000000..02d3a1d
--- /dev/null
+++ b/frontend/cypress/support/index.js
@@ -0,0 +1,16 @@
+import './commands';
+
+beforeEach(() => {
+  cy.server({
+    ignore: (xhr) => {
+      // Ignore the webpack dev server calls that interfere in the tests
+      // when testing with `yarn serve`.
+      if (xhr.url.indexOf('sockjs-node/') > -1) {
+        return true;
+      }
+
+      // Return the default cypress whitelist filer.
+      return xhr.method === 'GET' && /\.(jsx?|html|css)(\?.*)?$/.test(xhr.url);
+    },
+  });
+});
diff --git a/frontend/cypress/support/reset.sh b/frontend/cypress/support/reset.sh
new file mode 100755
index 0000000..6bbf339
--- /dev/null
+++ b/frontend/cypress/support/reset.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+pkill -9 listmonk
+ cd ../
+./listmonk --install --yes
+./listmonk > /dev/null 2>/dev/null &
diff --git a/frontend/package.json b/frontend/package.json
index 7b9efd8..6b6f3a6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -40,6 +40,8 @@
     "@vue/cli-service": "~4.4.0",
     "@vue/eslint-config-airbnb": "^5.0.2",
     "babel-eslint": "^10.1.0",
+    "cypress": "^6.4.0",
+    "cypress-file-upload": "^5.0.2",
     "eslint": "^6.7.2",
     "eslint-plugin-import": "^2.20.2",
     "eslint-plugin-vue": "^6.2.2",
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index a7d58a7..3297aa5 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -32,63 +32,63 @@
                 
 
                 
                   
 
                   
                 
 
                 
                   
 
                   
                 
 
                 
                   
 
                   
 
                   
 
                   
                 
 
                 
 
                   
 
                   
                 
               
diff --git a/frontend/src/components/Editor.vue b/frontend/src/components/Editor.vue
index 6ec4646..347ac0c 100644
--- a/frontend/src/components/Editor.vue
+++ b/frontend/src/components/Editor.vue
@@ -7,13 +7,16 @@
           
{{ $t('campaigns.richText') }} + native-value="richtext" + data-cy="check-richtext">{{ $t('campaigns.richText') }} {{ $t('campaigns.rawHTML') }} + native-value="html" + data-cy="check-html">{{ $t('campaigns.rawHTML') }} {{ $t('campaigns.plainText') }} + native-value="plain" + data-cy="check-plain">{{ $t('campaigns.plainText') }}
@@ -42,7 +45,7 @@ + type="textarea" name="content" ref="plainEditor" class="plain-editor" /> -
+
+ type="is-primary" icon-left="content-save-outline" data-cy="btn-save"> {{ $t('globals.buttons.saveChanges') }} + type="is-primary" icon-left="rocket-launch-outline" data-cy="btn-start"> {{ $t('campaigns.start') }} + type="is-primary" icon-left="clock-start" data-cy="btn-schedule"> {{ $t('campaigns.schedule') }}
@@ -42,17 +42,20 @@
- - - @@ -67,34 +70,34 @@ + name="template" :disabled="!canEdit" required> + name="messenger" :disabled="!canEdit" required> -
- +

- {{ $t('campaigns.continue') }} + :loading="loading.campaigns" data-cy="btn-continue"> + {{ $t('campaigns.continue') }} +
diff --git a/frontend/src/views/Campaigns.vue b/frontend/src/views/Campaigns.vue index 05d52d1..3b7db76 100644 --- a/frontend/src/views/Campaigns.vue +++ b/frontend/src/views/Campaigns.vue @@ -8,13 +8,15 @@
{{ $t('globals.buttons.new') }} + type="is-primary" icon-left="plus" data-cy="btn-new"> + {{ $t('globals.buttons.new') }} +
- @@ -29,7 +31,8 @@ hoverable backend-sorting @sort="onSort">
-
+

{{ $t('forms.formHTML') }}

{{ $t('forms.formHTMLHelp') }} diff --git a/frontend/src/views/Import.vue b/frontend/src/views/Import.vue index a213d94..1c5bebb 100644 --- a/frontend/src/views/Import.vue +++ b/frontend/src/views/Import.vue @@ -11,9 +11,11 @@

{{ $t('import.subscribe') }} + native-value="subscribe" + data-cy="check-subscribe">{{ $t('import.subscribe') }} {{ $t('import.blocklist') }} + native-value="blocklist" + data-cy="check-blocklist">{{ $t('import.blocklist') }}
diff --git a/frontend/src/views/ListForm.vue b/frontend/src/views/ListForm.vue index 1d20234..8799957 100644 --- a/frontend/src/views/ListForm.vue +++ b/frontend/src/views/ListForm.vue @@ -12,13 +12,13 @@ diff --git a/frontend/src/views/Lists.vue b/frontend/src/views/Lists.vue index a76f760..740cd40 100644 --- a/frontend/src/views/Lists.vue +++ b/frontend/src/views/Lists.vue @@ -8,7 +8,7 @@
- + {{ $t('globals.buttons.new') }}
@@ -23,9 +23,9 @@ backend-sorting @sort="onSort" >