Compare commits

...

74 Commits

Author SHA1 Message Date
f519b94392 Use realIP
All checks were successful
Build and Push Docker Image on Tag / Build and Push Docker Image (push) Successful in 15m31s
Code was copied from https://github.com/go-chi/chi/blob/master/middleware/realip.go
2025-02-01 17:07:24 +01:00
5a0da6560d Fix env var in image-build.yaml
All checks were successful
Build and Push Docker Image on Tag / Build and Push Docker Image (push) Successful in 15m25s
2025-01-30 09:09:21 +01:00
6aed7cb7fa Add Gitea workflow
Some checks failed
Build and Push Docker Image on Tag / Build and Push Docker Image (push) Failing after 10m54s
2025-01-30 08:56:30 +01:00
5ed16bdab3 Remove allow_different_binary_count 2025-01-23 14:15:34 +01:00
7939a6fde1 Fix deprecated property 2025-01-23 14:12:59 +01:00
e78d0a7276 Add version in archive name 2025-01-23 14:12:16 +01:00
e544ea8426 Fix Makefile 2025-01-23 14:09:18 +01:00
56adeaf1b8 Configure for goreleaser 2025-01-23 14:03:58 +01:00
a72eead432 Fix TODOs in openapi.yml 2025-01-22 20:12:52 +01:00
6e0375c666 Use alpine as base image in Dockerfile. Image size went from 372MB to 17.9MB 2025-01-22 08:13:10 +01:00
ff2a90ed3d Add links for election results after creating 2025-01-22 08:08:28 +01:00
6d92358f79 Fix Makefile and add VOLUME in Dockerfile 2025-01-21 22:24:01 +01:00
e7387be995 Make Chart.js work as local file 2025-01-21 11:38:21 +01:00
a1af7a48b2 Fix maxVoters check and add unit tests 2025-01-21 09:24:51 +01:00
5570dca6c9 Use cgo-free SQL driver and add limit of 100 to maxVoters 2025-01-21 09:17:40 +01:00
1b6fc173d3 Add page for results 2025-01-20 18:08:19 +01:00
37b06cfb9e Use makefile instead 2025-01-20 11:53:48 +01:00
8792b05cca Fix footer link and tailwind source 2025-01-20 10:39:15 +01:00
9a03a9cebd Add script to build 2025-01-20 10:39:00 +01:00
bddae031cc Add index page and link to share more easily 2025-01-20 10:13:59 +01:00
5668b1cd6a Insert election with uuid instead of auto-generated id 2025-01-20 10:04:15 +01:00
729fbecae6 Fix content-type header 2025-01-17 19:34:49 +01:00
fde07b74fa Display error message from JSON to end user 2025-01-17 17:26:24 +01:00
f94e08fc7f Add integration tests for createVotes 2025-01-17 17:11:16 +01:00
5e4a089b89 Fix integration test URI 2025-01-17 16:50:16 +01:00
27d166dad6 Implement page to vote and fix trigger 2025-01-17 16:48:38 +01:00
62562272f1 Add tests for GetElection 2025-01-17 14:55:54 +01:00
410e8f39d3 Add some more tests and implement getElection 2025-01-17 14:49:51 +01:00
60d1bb382c Write TestGetElectionResults_NotFound 2025-01-17 13:59:37 +01:00
afac0f8f91 Change copyAllCodes to async function 2025-01-16 16:36:25 +01:00
70a0cd7d3b Add voterIdentities to copy again and fix issue if there was no body in the response (in the case of unknown voters election) 2025-01-14 20:07:02 +01:00
f784eb474f Fix maxVoters minimum value and value of boolean areVotersKnown 2025-01-14 19:49:12 +01:00
96133f00de Add variable for baseUri 2025-01-14 17:32:44 +01:00
1428534dc3 Write test for getElectionResults 2025-01-14 17:28:45 +01:00
66cdd0086c Fix tests 2025-01-14 17:00:29 +01:00
c095e9ae0b Implement getElectionResults 2025-01-14 16:52:20 +01:00
13c2693bdb Add caching middlware for static files 2025-01-14 13:43:16 +01:00
82aa5ef57d Remove unused CSS files 2025-01-14 13:31:55 +01:00
07ceec4db2 Use TailwindCSS 2025-01-14 13:31:33 +01:00
e102d01bff Remove comments 2025-01-14 12:51:53 +01:00
c878b33649 Add error returned from the request 2025-01-14 12:51:29 +01:00
2847c53bca Add doc for HTTP 400 on vote creation 2025-01-14 12:44:06 +01:00
3c2d45083f Put API behind /api 2025-01-14 12:40:30 +01:00
88790bdec2 Convert to Alpine.js frontend 2025-01-14 12:35:32 +01:00
f22710464b Add TODO 2025-01-13 22:10:12 +01:00
22a6593e3a Add busy_timeout 2025-01-13 22:00:00 +01:00
26ae032fd6 Remove concurrency 2025-01-13 21:59:48 +01:00
b9d6d25245 Add return 2025-01-13 21:37:28 +01:00
e8ced857e0 Remove comments 2025-01-13 20:41:41 +01:00
2f13d7a76a Display election ID in the response and insert voters in a goroutine 2025-01-13 20:41:33 +01:00
7ab27a947e Remove todo because fixed 2025-01-13 20:07:18 +01:00
d1225d3258 Fix slow-ass code 2025-01-13 20:06:55 +01:00
ee847020f7 Add page to create an election 2025-01-13 19:58:42 +01:00
c7cccf2ec1 Use generated interface 2025-01-13 11:26:18 +01:00
c3563878b7 Add operationId for election results 2025-01-13 10:59:05 +01:00
4f50aca3b6 Add create-election.html 2025-01-13 10:56:36 +01:00
9cc85b9a47 Fix test that wasn't testing the correct thing 2025-01-11 18:39:46 +01:00
98421c8c06 Remove useless variable 2025-01-11 18:38:07 +01:00
cad5cfe636 Move voting to a new endpoint
/election/{id}/votes
2025-01-11 18:29:01 +01:00
86d7d0e881 Add some TODO and add 500 error responses in openapi.yml. Also write the endpoint for election results 2025-01-11 17:57:15 +01:00
5927e9c855 Refactor createVotes in two different functions (only for the part that was nested in ifs) 2025-01-10 10:31:59 +01:00
0b0ecaba9b Write test for expired election 2025-01-09 18:26:22 +01:00
b25e090a7b Write test for max voters reached (only in unknown voters election 2025-01-09 18:22:58 +01:00
4fbf72d84d Add tests for already voted cases 2025-01-09 18:12:18 +01:00
d77bef4bda Write some tests for createVotes before refactoring 2025-01-09 15:35:16 +01:00
bf3368b736 Implement mocking and update tests for createElection 2025-01-08 17:18:53 +01:00
49a1df06d2 Dirty implementation of createVotes 2025-01-08 13:57:49 +01:00
ca824726b4 Return err 2025-01-02 19:27:56 +01:00
a9278366dd Remove useless intermediary variable in elections.go 2025-01-02 19:27:31 +01:00
0229f78976 Incomplete implementation of createVotes 2025-01-02 19:24:32 +01:00
9efe9a3537 Write validation for createVotesRequest 2025-01-02 18:12:48 +01:00
d251909ee7 Move isValid 2025-01-02 18:06:02 +01:00
8d3ce36dd9 Add endpoint for POST /votes in openapi.yml 2025-01-02 18:05:27 +01:00
7f82a402de Document Location header in POST /election 2025-01-02 17:53:30 +01:00
32 changed files with 2668 additions and 473 deletions

View File

@ -0,0 +1,35 @@
name: Build and Push Docker Image on Tag
on:
push:
tags:
- "v*"
jobs:
build:
name: Build and Push Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
registry: code.dlmw.ch
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: true
tags: code.dlmw.ch/dlmw/qv:${{ env.GITHUB_REF_NAME }}

1
.gitignore vendored
View File

@ -30,3 +30,4 @@ go.work.sum
# exclude built binary
web
dist/

43
.goreleaser.yaml Normal file
View File

@ -0,0 +1,43 @@
version: 2
before:
hooks:
- go mod tidy
builds:
- id: qv
main: ./cmd/web
binary: qv
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
goamd64:
- v1
- v2
- v3
- v4
goarm64:
- v8.0
ldflags:
- -s -w
archives:
- id: qv-archive
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- .Arch }}_
{{- if eq .Arch "amd64" }}{{ .Amd64 }}
{{- else if eq .Arch "arm64" }}{{ .Arm64 }}
{{- end }}
formats: [ "tar.gz" ]
format_overrides:
- goos: windows
formats: [ "zip" ]

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM golang:1.23-alpine AS build
WORKDIR /usr/src/qv
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN go build -ldflags "-s -w" -v -o /usr/local/bin/qv ./cmd/web/
FROM alpine
RUN mkdir /qv
ENV QV_DATABASE_PATH="/qv/qv.sqlite"
VOLUME /qv
COPY --from=build /usr/local/bin/qv /usr/local/bin/
ENTRYPOINT ["qv"]

10
Makefile Normal file
View File

@ -0,0 +1,10 @@
.PHONY: compile compile-snapshot clean
compile:
@goreleaser release --clean --skip=publish
compile-snapshot:
@goreleaser release --clean --snapshot --skip=publish
clean:
@rm -r dist/

View File

@ -2,20 +2,103 @@ package main
import (
api "code.dlmw.ch/dlmw/qv/internal"
"code.dlmw.ch/dlmw/qv/internal/mappers"
"code.dlmw.ch/dlmw/qv/internal/models"
"code.dlmw.ch/dlmw/qv/internal/validator"
"code.dlmw.ch/dlmw/qv/ui"
"database/sql"
"encoding/json"
"errors"
"fmt"
"math"
"math/rand"
"net/http"
"slices"
"time"
)
func (app *application) indexPage(w http.ResponseWriter, r *http.Request) {
content, err := ui.Files.ReadFile("index.html")
if err != nil {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(content)
}
func (app *application) createElectionPage(w http.ResponseWriter, r *http.Request) {
content, err := ui.Files.ReadFile("create-election.html")
if err != nil {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(content)
}
func (app *application) getElectionResultsPage(w http.ResponseWriter, r *http.Request) {
content, err := ui.Files.ReadFile("election-results.html")
if err != nil {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(content)
}
func (app *application) getElectionPage(w http.ResponseWriter, r *http.Request) {
content, err := ui.Files.ReadFile("election.html")
if err != nil {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(content)
}
type createElectionRequestWithValidator struct {
api.CreateElectionRequest
validator.Validator
}
func (app *application) createElection(w http.ResponseWriter, r *http.Request) {
func (r *createElectionRequestWithValidator) isValid() bool {
r.CheckField(validator.NotBlank(r.Name), "name", "must not be blank")
r.CheckField(validator.GreaterThan(r.Tokens, 0), "tokens", "must be greater than 0")
r.CheckField(validator.After(r.ExpiresAt, time.Now()), "expiresAt", "must expire in a future date")
r.CheckField(validator.GreaterThan(len(r.Choices), 1), "choices", "there must be more than 1 choice")
r.CheckField(validator.UniqueValues(r.Choices), "choices", "must not contain duplicate values")
for _, choice := range r.Choices {
r.CheckField(validator.NotBlank(choice), "choice", "must not be blank")
}
if r.AreVotersKnown {
r.CheckField(validator.LesserThan(r.MaxVoters, 101),
"maxVoters",
"cannot create a known-voters election with more than 100 voters",
)
r.CheckField(
validator.GreaterThan(r.MaxVoters, 0),
"maxVoters",
"must be greater than 0 when voters are known",
)
} else {
r.CheckField(
validator.GreaterThanOrEquals(r.MaxVoters, 0),
"maxVoters",
"must be a positive number",
)
}
return r.Valid()
}
func (app *application) CreateElection(w http.ResponseWriter, r *http.Request) {
var request createElectionRequestWithValidator
if err := app.unmarshalRequest(r, &request); err != nil {
@ -46,11 +129,12 @@ func (app *application) createElection(w http.ResponseWriter, r *http.Request) {
voterIdentities := make([]string, 0, request.MaxVoters)
for i := 0; i < request.MaxVoters; i++ {
randomIdentity := randomVoterIdentity()
_, err := app.voters.Insert(randomIdentity, electionId)
voterIdentities = append(voterIdentities, randomIdentity)
}
_, err = app.voters.InsertMultiple(voterIdentities, electionId)
if err != nil {
app.serverError(w, r, err)
}
voterIdentities = append(voterIdentities, randomIdentity)
return
}
res, err = json.Marshal(api.CreateElectionResponse{VoterIdentities: &voterIdentities})
@ -58,40 +142,13 @@ func (app *application) createElection(w http.ResponseWriter, r *http.Request) {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
}
w.Header().Set("Location", fmt.Sprintf("/election/%v", electionId))
w.Write(res)
}
func (r *createElectionRequestWithValidator) isValid() bool {
r.CheckField(validator.NotBlank(r.Name), "name", "must not be blank")
r.CheckField(validator.GreaterThan(r.Tokens, 0), "tokens", "must be greater than 0")
r.CheckField(validator.After(r.ExpiresAt, time.Now()), "expiresAt", "must expire in a future date")
r.CheckField(validator.GreaterThan(len(r.Choices), 1), "choices", "there must be more than 1 choice")
r.CheckField(validator.UniqueValues(r.Choices), "choices", "must not contain duplicate values")
for _, choice := range r.Choices {
r.CheckField(validator.NotBlank(choice), "choice", "must not be blank")
}
if r.AreVotersKnown {
r.CheckField(
validator.GreaterThan(r.MaxVoters, 0),
"maxVoters",
"must be greater than 0 when voters are known",
)
} else {
r.CheckField(
validator.GreaterThanOrEquals(r.MaxVoters, 0),
"maxVoters",
"must be a positive number",
)
}
return r.Valid()
}
func randomVoterIdentity() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, 16)
@ -101,6 +158,216 @@ func randomVoterIdentity() string {
return string(b)
}
func (app *application) createVote(w http.ResponseWriter, r *http.Request) {
type createVotesRequestWithValidator struct {
api.CreateVotesRequest
validator.Validator
}
func (r *createVotesRequestWithValidator) isValid() bool {
for _, choice := range r.Choices {
r.CheckField(validator.NotBlank(choice.ChoiceText), "choiceText", "must not be blank")
}
return r.Valid()
}
func (app *application) CreateVotes(w http.ResponseWriter, r *http.Request, id string) {
var request createVotesRequestWithValidator
if err := app.unmarshalRequest(r, &request); err != nil {
app.clientError(w, http.StatusBadRequest, err.Error())
return
}
if !request.isValid() {
app.unprocessableEntityError(w, request.Validator)
return
}
election, err := app.elections.GetById(id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
app.clientError(w, http.StatusNotFound, "Couldn't find an election with the ID you provided")
return
}
app.serverError(w, r, err)
}
for _, c := range request.Choices {
choiceExists := slices.Contains(election.Choices, c.ChoiceText)
if !choiceExists {
app.clientError(w, http.StatusUnprocessableEntity, fmt.Sprintf("choice %v doesn't exist", c.ChoiceText))
return
}
}
tokensUsed := 0
for _, c := range request.Choices {
tokensUsed += c.Tokens
}
if tokensUsed > election.Tokens {
app.clientError(w, http.StatusUnprocessableEntity, fmt.Sprintf("you used too many tokens; must not exceed %v tokens", election.Tokens))
return
}
electionHasExpired := election.ExpiresAt.Before(time.Now())
if electionHasExpired {
app.clientError(w, http.StatusUnprocessableEntity, "election has expired")
return
}
var voterIdentity string
if election.AreVotersKnown {
voterIdentity, err = app.createVotesHandleKnownVotersElection(w, r, request, election)
if err != nil {
// everything will have been logged already
return
}
} else {
voterIdentity, err = app.createVotesHandleUnknownVotersElection(w, r, election)
if err != nil {
// everything will have been logged already
return
}
}
for _, c := range request.Choices {
_, err := app.votes.Insert(voterIdentity, election.ID, c.ChoiceText, c.Tokens)
if err != nil {
app.serverError(w, r, err)
return
}
}
w.WriteHeader(http.StatusCreated)
}
func (app *application) createVotesHandleKnownVotersElection(w http.ResponseWriter, r *http.Request, request createVotesRequestWithValidator, election *models.Election) (string, error) {
if request.VoterIdentity == nil || validator.Blank(*request.VoterIdentity) {
message := "election has known voters; you must provide an identity provided by the organizer"
app.clientError(w, http.StatusUnprocessableEntity, message)
return "", fmt.Errorf(message)
}
voterIdentity := *request.VoterIdentity
hasCastVotes, err := app.votes.Exists(voterIdentity, election.ID)
if err != nil {
app.serverError(w, r, err)
return "", err
}
if hasCastVotes {
message := "you already voted"
app.clientError(w, http.StatusUnprocessableEntity, message)
return "", fmt.Errorf(message)
}
voterExists, err := app.voters.Exists(voterIdentity, election.ID)
if err != nil {
app.serverError(w, r, err)
return "", err
}
if !voterExists {
message := "invalid voter identity"
app.clientError(w, http.StatusUnprocessableEntity, message)
return "", fmt.Errorf(message)
}
return voterIdentity, nil
}
func (app *application) createVotesHandleUnknownVotersElection(w http.ResponseWriter, r *http.Request, election *models.Election) (string, error) {
voterIdentity := realIP(r)
voterExists, err := app.voters.Exists(voterIdentity, election.ID)
if err != nil {
app.serverError(w, r, err)
return "", err
}
if voterExists {
message := "you already voted"
app.clientError(w, http.StatusUnprocessableEntity, message)
return "", fmt.Errorf(message)
}
voterCount, err := app.voters.CountByElection(election.ID)
if err != nil && !errors.Is(sql.ErrNoRows, err) {
app.serverError(w, r, err)
return "", err
}
if voterCount != 0 && voterCount == election.MaxVoters {
message := "maximum voters reached"
app.clientError(w, http.StatusUnprocessableEntity, message)
return "", fmt.Errorf(message)
}
_, err = app.voters.InsertMultiple([]string{voterIdentity}, election.ID)
if err != nil {
app.serverError(w, r, err)
return "", err
}
return voterIdentity, nil
}
func (app *application) GetElectionResults(w http.ResponseWriter, r *http.Request, id string) {
votes, err := app.votes.GetByElection(id)
if err != nil {
if errors.Is(sql.ErrNoRows, err) {
app.clientError(w, http.StatusNotFound, fmt.Sprintf("couldn't find votes for an election with id %v", id))
return
}
app.serverError(w, r, err)
return
}
results := getResultsFromVotes(votes)
response, err := json.Marshal(api.ElectionResultsResponse{Results: &results})
if err != nil {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
}
func getResultsFromVotes(votes *[]models.Vote) []api.VotesForChoice {
votesForChoice := make(map[string]int)
for _, v := range *votes {
votesForChoice[v.ChoiceText] += int(math.Floor(math.Sqrt(float64(v.Tokens))))
}
results := make([]api.VotesForChoice, 0)
for choice, votes := range votesForChoice {
result := api.VotesForChoice{
Choice: choice,
Votes: votes,
}
results = append(results, result)
}
return results
}
func (app *application) GetElection(w http.ResponseWriter, r *http.Request, id string) {
election, err := app.elections.GetById(id)
if err != nil {
if errors.Is(sql.ErrNoRows, err) {
app.clientError(w, http.StatusNotFound, fmt.Sprintf("couldn't find election with id %v", id))
return
}
app.serverError(w, r, err)
return
}
response, err := json.Marshal(mappers.ElectionResponse(election))
if err != nil {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
}

View File

@ -3,19 +3,32 @@ package main
import (
"bytes"
"code.dlmw.ch/dlmw/qv/internal"
"code.dlmw.ch/dlmw/qv/internal/models"
"database/sql"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"testing"
"time"
)
const baseUri = "/api/"
func TestCreateElection(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
path := "/election"
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(id.String(), nil)
path := baseUri + "election"
tests := []struct {
name string
@ -69,7 +82,7 @@ func TestCreateElection(t *testing.T) {
Choices: []string{"Gandhi", "Buddha"},
ExpiresAt: time.Now().Add(24 * time.Hour),
AreVotersKnown: false,
MaxVoters: 10,
MaxVoters: 1000,
Name: "Guy of the year",
Tokens: 100,
},
@ -166,6 +179,19 @@ func TestCreateElection(t *testing.T) {
},
expectedCode: http.StatusUnprocessableEntity,
},
{
name: "Invalid request for known voters election (max voters greater than 100)",
urlPath: path,
body: api.CreateElectionRequest{
Choices: []string{"Gandhi", "Buddha", ""},
ExpiresAt: time.Now().Add(24 * time.Hour),
AreVotersKnown: true,
MaxVoters: 101,
Name: "Guy of the year",
Tokens: 100,
},
expectedCode: http.StatusUnprocessableEntity,
},
}
for _, tt := range tests {
@ -180,3 +206,716 @@ func TestCreateElection(t *testing.T) {
})
}
}
func TestCreateElection_KnownVoters(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
electionId, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(electionId.String(), nil)
mockVoters := app.voters.(*mockVoterModel)
mockVoters.
On("InsertMultiple", mock.Anything, mock.Anything).
Return([]int{1}, nil)
path := baseUri + "election"
requestBody := api.CreateElectionRequest{
Choices: []string{"宮本武蔵", "伊東一刀斎"},
ExpiresAt: time.Now().Add(24 * time.Hour),
AreVotersKnown: true,
MaxVoters: 100,
Name: "強",
Tokens: 100,
}
requestBodyJson, err := json.Marshal(requestBody)
if err != nil {
t.Fatal(err)
}
code, _, body := server.post(t, path, bytes.NewReader(requestBodyJson))
assert.Equal(t, http.StatusOK, code)
var voterIdentities struct {
VoterIdentities []string
}
err = json.Unmarshal([]byte(body), &voterIdentities)
if err != nil {
t.Fatal(err)
}
assert.Len(t, voterIdentities.VoterIdentities, 100)
}
func TestCreateElection_ServerError(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(0, fmt.Errorf(""))
path := baseUri + "election"
requestBody := api.CreateElectionRequest{
Choices: []string{"宮本武蔵", "伊東一刀斎"},
ExpiresAt: time.Now().Add(24 * time.Hour),
AreVotersKnown: false,
MaxVoters: 0,
Name: "強",
Tokens: 100,
}
requestBodyJson, err := json.Marshal(requestBody)
if err != nil {
t.Fatal(err)
}
code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson))
assert.Equal(t, 500, code)
}
func TestCreateVotes_UnknownVotersElection(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("GetById", mock.Anything).
Return(&models.Election{
ID: id.String(),
Name: "Guy of the year",
Tokens: 100,
AreVotersKnown: false,
MaxVoters: 100,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
Choices: []string{"Gandhi", "Buddha"},
}, nil)
mockVoters := app.voters.(*mockVoterModel)
mockVoters.
On("Exists", mock.Anything, mock.Anything).
Return(false, nil)
mockVoters.
On("InsertMultiple", mock.Anything, mock.Anything).
Return([]int{1}, nil)
mockVoters.
On("CountByElection", mock.Anything).
Return(0, nil)
mockVotes := app.votes.(*mockVoteModel)
mockVotes.
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(1, nil)
path := baseUri + "election/1/votes"
tests := []struct {
name string
urlPath string
body any
expectedCode int
}{
{
name: "Valid request for unknown voters election",
urlPath: path,
body: api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 40},
},
VoterIdentity: nil,
},
expectedCode: http.StatusCreated,
},
{
name: "Invalid request for unknown voters election (too many tokens used)",
urlPath: path,
body: api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 41},
},
VoterIdentity: nil,
},
expectedCode: http.StatusUnprocessableEntity,
},
{
name: "Invalid request for unknown voters election (choice doesn't exist)",
urlPath: path,
body: api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddh", Tokens: 40},
},
VoterIdentity: nil,
},
expectedCode: http.StatusUnprocessableEntity,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
requestBody, err := json.Marshal(tt.body)
if err != nil {
t.Fatal(err)
}
code, _, _ := server.post(t, tt.urlPath, bytes.NewReader(requestBody))
assert.Equal(t, tt.expectedCode, code)
})
}
}
func TestCreateVotes_KnownVotersElection(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("GetById", mock.Anything).
Return(&models.Election{
ID: id.String(),
Name: "Guy of the year",
Tokens: 100,
AreVotersKnown: true,
MaxVoters: 100,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
Choices: []string{"Gandhi", "Buddha"},
}, nil)
EXISTING_VOTER_IDENTITY := "EXISTING_VOTER_IDENTITY"
NON_EXISTING_VOTER_IDENTITY := "NON_EXISTING_VOTER_IDENTITY"
mockVoters := app.voters.(*mockVoterModel)
mockVoters.
On("Exists", EXISTING_VOTER_IDENTITY, mock.Anything).
Return(true, nil)
mockVoters.
On("Exists", NON_EXISTING_VOTER_IDENTITY, mock.Anything).
Return(false, nil)
mockVotes := app.votes.(*mockVoteModel)
mockVotes.
On("Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(1, nil)
mockVotes.
On("Exists", mock.Anything, mock.Anything).
Return(false, nil)
path := baseUri + "election/1/votes"
tests := []struct {
name string
urlPath string
body any
expectedCode int
}{
{
name: "Valid request for unknown voters election",
urlPath: path,
body: api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 40},
},
VoterIdentity: &EXISTING_VOTER_IDENTITY,
},
expectedCode: http.StatusCreated,
},
{
name: "Invalid request for unknown voters election (non-existing voter identity)",
urlPath: path,
body: api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 40},
},
VoterIdentity: &NON_EXISTING_VOTER_IDENTITY,
},
expectedCode: http.StatusUnprocessableEntity,
},
{
name: "Invalid request for unknown voters election (no voter identity provided)",
urlPath: path,
body: api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 40},
},
VoterIdentity: nil,
},
expectedCode: http.StatusUnprocessableEntity,
},
{
name: "Invalid request for unknown voters election (too many tokens used)",
urlPath: path,
body: api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 41},
},
VoterIdentity: &EXISTING_VOTER_IDENTITY,
},
expectedCode: http.StatusUnprocessableEntity,
},
{
name: "Invalid request for unknown voters election (choice doesn't exist)",
urlPath: path,
body: api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddh", Tokens: 40},
},
VoterIdentity: &EXISTING_VOTER_IDENTITY,
},
expectedCode: http.StatusUnprocessableEntity,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
requestBody, err := json.Marshal(tt.body)
if err != nil {
t.Fatal(err)
}
code, _, _ := server.post(t, tt.urlPath, bytes.NewReader(requestBody))
assert.Equal(t, tt.expectedCode, code)
})
}
}
func TestCreateVotes_NonExistingElection(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("GetById", mock.Anything).
Return((*models.Election)(nil), sql.ErrNoRows)
path := baseUri + "election/1/votes"
requestBody := api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 40},
},
VoterIdentity: nil,
}
requestBodyJson, err := json.Marshal(requestBody)
if err != nil {
t.Fatal(err)
}
code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson))
assert.Equal(t, http.StatusNotFound, code)
}
func TestCreateVotes_NonUuidElectionID(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("GetById", mock.Anything).
Return((*models.Election)(nil), sql.ErrNoRows)
path := baseUri + "election/1a/votes"
requestBody := api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 40},
},
VoterIdentity: nil,
}
requestBodyJson, err := json.Marshal(requestBody)
if err != nil {
t.Fatal(err)
}
code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson))
assert.Equal(t, http.StatusNotFound, code)
}
func TestCreateVotes_AlreadyVoted(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
unknownVotersElectionId, _ := uuid.NewV7()
unknownVotersElection := models.Election{
ID: unknownVotersElectionId.String(),
Name: "Guy of the year",
Tokens: 100,
AreVotersKnown: false,
MaxVoters: 10,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
Choices: []string{"Gandhi", "Buddha"},
}
knownVotersElection := unknownVotersElection
knownVotersElectionId, _ := uuid.NewV7()
knownVotersElection.ID = knownVotersElectionId.String()
knownVotersElection.AreVotersKnown = true
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("GetById", knownVotersElectionId.String()).
Return(&knownVotersElection, nil)
mockElections.
On("GetById", unknownVotersElectionId.String()).
Return(&unknownVotersElection, nil)
mockVotes := app.votes.(*mockVoteModel)
mockVotes.
On("Exists", mock.Anything, mock.Anything).
Return(true, nil)
mockVoters := app.voters.(*mockVoterModel)
mockVoters.
On("Exists", mock.Anything, mock.Anything).
Return(true, nil)
layout := baseUri + "election/%v/votes"
knownVotersElectionPath := fmt.Sprintf(layout, unknownVotersElectionId)
unknownVotersElectionPath := fmt.Sprintf(layout, knownVotersElectionId)
voterIdentity := "anything"
tests := []struct {
name string
urlPath string
body any
expectedCode int
}{
{
name: "Invalid request for known voters election (already voted)",
urlPath: knownVotersElectionPath,
body: api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 40},
},
VoterIdentity: &voterIdentity,
},
expectedCode: http.StatusUnprocessableEntity,
},
{
name: "Invalid request for unknown voters election (already voted)",
urlPath: unknownVotersElectionPath,
body: api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 40},
},
VoterIdentity: &voterIdentity,
},
expectedCode: http.StatusUnprocessableEntity,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
requestBody, err := json.Marshal(tt.body)
if err != nil {
t.Fatal(err)
}
code, _, _ := server.post(t, tt.urlPath, bytes.NewReader(requestBody))
assert.Equal(t, tt.expectedCode, code)
})
}
}
func TestCreateVotes_UnknownVotersElectionMaxVotersReached(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("GetById", mock.Anything).
Return(&models.Election{
ID: id.String(),
Name: "Guy of the year",
Tokens: 100,
AreVotersKnown: false,
MaxVoters: 10,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
Choices: []string{"Gandhi", "Buddha"},
}, nil)
mockVoters := app.voters.(*mockVoterModel)
mockVoters.
On("Exists", mock.Anything, mock.Anything).
Return(false, nil)
mockVoters.
On("InsertMultiple", mock.Anything, mock.Anything).
Return([]int{1}, nil)
mockVoters.
On("CountByElection", mock.Anything).
Return(10, nil)
path := baseUri + "election/1/votes"
requestBody := api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 40},
},
VoterIdentity: nil,
}
requestBodyJson, err := json.Marshal(requestBody)
if err != nil {
t.Fatal(err)
}
code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson))
assert.Equal(t, http.StatusUnprocessableEntity, code)
}
func TestCreateVotes_ExpiredElection(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("GetById", mock.Anything).
Return(&models.Election{
ID: id.String(),
Name: "Guy of the year",
Tokens: 100,
AreVotersKnown: false,
MaxVoters: 10,
CreatedAt: time.UnixMilli(1),
ExpiresAt: time.UnixMilli(100000),
Choices: []string{"Gandhi", "Buddha"},
}, nil)
mockVoters := app.voters.(*mockVoterModel)
mockVoters.
On("Exists", mock.Anything, mock.Anything).
Return(false, nil)
mockVoters.
On("Insert", mock.Anything, mock.Anything).
Return(1, nil)
mockVoters.
On("CountByElection", mock.Anything).
Return(10, nil)
path := baseUri + "election/1/votes"
requestBody := api.CreateVotesRequest{
Choices: []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
}{
{ChoiceText: "Gandhi", Tokens: 60},
{ChoiceText: "Buddha", Tokens: 40},
},
VoterIdentity: nil,
}
requestBodyJson, err := json.Marshal(requestBody)
if err != nil {
t.Fatal(err)
}
code, _, _ := server.post(t, path, bytes.NewReader(requestBodyJson))
assert.Equal(t, http.StatusUnprocessableEntity, code)
}
func TestGetElectionResults(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
electionID, _ := uuid.NewV7()
votes := []models.Vote{
{
VoterIdentity: "Voter1",
ElectionID: electionID.String(),
ChoiceText: "Choice1",
Tokens: 2,
CreatedAt: time.Now(),
},
{
VoterIdentity: "Voter2",
ElectionID: electionID.String(),
ChoiceText: "Choice2",
Tokens: 4,
CreatedAt: time.Now(),
},
{
VoterIdentity: "Voter3",
ElectionID: electionID.String(),
ChoiceText: "Choice3",
Tokens: 6,
CreatedAt: time.Now(),
},
{
VoterIdentity: "Voter4",
ElectionID: electionID.String(),
ChoiceText: "Choice1",
Tokens: 8,
CreatedAt: time.Now(),
},
{
VoterIdentity: "Voter5",
ElectionID: electionID.String(),
ChoiceText: "Choice2",
Tokens: 10,
CreatedAt: time.Now(),
},
}
mockVotes := app.votes.(*mockVoteModel)
mockVotes.
On("GetByElection", electionID.String()).
Return(&votes, nil)
path := baseUri + fmt.Sprintf("election/%v/results", electionID)
code, _, body := server.get(t, path)
assert.Equal(t, http.StatusOK, code)
var response api.ElectionResultsResponse
err := json.Unmarshal([]byte(body), &response)
if err != nil {
t.Fatal(err)
}
for _, result := range *response.Results {
switch result.Choice {
case "Choice1":
assert.Equal(t, 3, result.Votes)
case "Choice2":
assert.Equal(t, 5, result.Votes)
case "Choice3":
assert.Equal(t, 2, result.Votes)
default:
t.Fatalf("Unexpected choice: %s", result.Choice)
}
}
}
func TestGetElectionResults_NotFound(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
mockVotes := app.votes.(*mockVoteModel)
mockVotes.
On("GetByElection", mock.Anything).
Return(&[]models.Vote{}, sql.ErrNoRows)
path := baseUri + "election/1/results"
code, _, body := server.get(t, path)
assert.Equal(t, http.StatusNotFound, code)
var response api.ElectionResultsResponse
err := json.Unmarshal([]byte(body), &response)
if err != nil {
t.Fatal(err)
}
}
func TestGetElection_Found(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
id, _ := uuid.NewV7()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("GetById", mock.Anything).
Return(&models.Election{
ID: id.String(),
Name: "Guy of the year",
Tokens: 100,
AreVotersKnown: false,
MaxVoters: 10,
CreatedAt: time.UnixMilli(1),
ExpiresAt: time.UnixMilli(100000),
Choices: []string{"Gandhi", "Buddha"},
}, nil)
path := baseUri + "election/1"
code, _, _ := server.get(t, path)
assert.Equal(t, http.StatusOK, code)
}
func TestGetElection_NotFound(t *testing.T) {
app := newTestApplication(t)
server := newTestServer(t, app.routes())
defer server.Close()
mockElections := app.elections.(*mockElectionModel)
mockElections.
On("GetById", mock.Anything).
Return((*models.Election)(nil), sql.ErrNoRows)
path := baseUri + "election/1"
code, _, _ := server.get(t, path)
assert.Equal(t, http.StatusNotFound, code)
}

View File

@ -5,11 +5,14 @@ import (
"code.dlmw.ch/dlmw/qv/internal/validator"
"encoding/json"
"io"
"net"
"net/http"
"strings"
)
func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) {
app.logger.Error(err.Error())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
var response = api.ErrorResponse{
Code: http.StatusInternalServerError,
@ -19,26 +22,36 @@ func (app *application) serverError(w http.ResponseWriter, r *http.Request, err
json.NewEncoder(w).Encode(response)
}
func (app *application) badRequestError(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
var response = api.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
}
json.NewEncoder(w).Encode(response)
}
func (app *application) clientError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
var response = api.ErrorResponse{
Code: http.StatusUnprocessableEntity,
Details: &map[string]interface{}{
"error": message,
},
Message: "There was an error in the request",
Code: status,
Details: nil,
Message: message,
}
json.NewEncoder(w).Encode(response)
}
func (app *application) unprocessableEntityError(w http.ResponseWriter, v validator.Validator) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
var response = api.ErrorResponse{
Code: http.StatusUnprocessableEntity,
Details: &map[string]interface{}{
"fields": v.FieldErrors,
},
Message: "Election data is invalid",
Message: "Request data is invalid",
}
json.NewEncoder(w).Encode(response)
}
@ -56,3 +69,23 @@ func (app *application) unmarshalRequest(r *http.Request, dst any) error {
return nil
}
func realIP(r *http.Request) string {
var ip string
if tcip := r.Header.Get(http.CanonicalHeaderKey("True-Client-IP")); tcip != "" {
ip = tcip
} else if xrip := r.Header.Get(http.CanonicalHeaderKey("X-Real-IP")); xrip != "" {
ip = xrip
} else if xff := r.Header.Get(http.CanonicalHeaderKey("X-Forwarded-For")); xff != "" {
i := strings.Index(xff, ",")
if i == -1 {
i = len(xff)
}
ip = xff[:i]
}
if ip == "" || net.ParseIP(ip) == nil {
return ""
}
return ip
}

View File

@ -1,5 +1,3 @@
//go:build !integration
//go:generate oapi-codegen --config=oapi-codegen.yml openapi.yml
package main
@ -9,8 +7,8 @@ import (
"code.dlmw.ch/dlmw/qv/internal/models"
"context"
"database/sql"
_ "github.com/mattn/go-sqlite3"
"log/slog"
_ "modernc.org/sqlite"
"net/http"
"os"
"os/signal"
@ -19,6 +17,15 @@ import (
)
var addr = ":8080"
var databasePath string
func init() {
if os.Getenv("QV_DATABASE_PATH") == "" {
databasePath = "./qv.sqlite"
} else {
databasePath = os.Getenv("QV_DATABASE_PATH")
}
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
@ -39,6 +46,7 @@ func main() {
logger: logger,
elections: &models.ElectionModel{DB: db},
voters: &models.VoterModel{DB: db},
votes: &models.VoteModel{DB: db},
}
logger.Info("Starting server", "addr", addr)
@ -47,7 +55,7 @@ func main() {
Addr: addr,
Handler: app.routes(),
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
IdleTimeout: time.Minute,
IdleTimeout: 30 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
@ -61,7 +69,7 @@ func main() {
}
func openDb() (*sql.DB, error) {
db, err := sql.Open("sqlite3", "./qv.sqlite?_foreign_keys=on")
db, err := sql.Open("sqlite", databasePath+"?_foreign_keys=on&_busy_timeout=5000")
if err == nil {
err = db.Ping()
}

View File

@ -1,196 +0,0 @@
//go:build integration
package main
import (
"code.dlmw.ch/dlmw/qv/internal/migrations"
"code.dlmw.ch/dlmw/qv/internal/models"
"context"
"database/sql"
"errors"
. "github.com/Eun/go-hit"
"log/slog"
"net/http"
"os"
"time"
)
var addr = ":8080"
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
err := os.Remove("./qv.integration.sqlite")
if err != nil {
logger.Error("couldn't delete qv.integration.sqlite")
}
db, err := openDb()
if err != nil {
logger.Error(err.Error())
os.Exit(1)
}
defer db.Close()
err = migrations.Run(db)
if err != nil {
logger.Error(err.Error())
os.Exit(1)
}
app := &application{
logger: logger,
elections: &models.ElectionModel{DB: db},
voters: &models.VoterModel{DB: db},
}
logger.Info("Starting integration tests server", "addr", addr)
srv := &http.Server{
Addr: addr,
Handler: app.routes(),
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
IdleTimeout: time.Minute,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
logger.Info("Starting integration test server", "addr", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("Server error", "error", err)
os.Exit(1)
}
}()
time.Sleep(1 * time.Second) // wait until srv starts
runTests()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
srv.Shutdown(ctx)
}
func openDb() (*sql.DB, error) {
db, err := sql.Open("sqlite3", "./qv.integration.sqlite?_foreign_keys=on")
if err == nil {
err = db.Ping()
}
return db, err
}
func runTests() {
runCreateElectionTests()
}
func runCreateElectionTests() {
template := CombineSteps(
Post("http://127.0.0.1:8080/election"),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": false,
"maxVoters": 10,
"expiresAt": "2124-12-31T14:15:22Z",
"choices": [
"Gandhi",
"Buddha"
]
}`),
Expect().Status().Equal(http.StatusOK),
Expect().Headers("Location").Contains("/election/1"),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": false,
"maxVoters": 0,
"expiresAt": "2124-12-31T14:15:22Z",
"choices": [
"Gandhi",
"Buddha"
]
}`),
Expect().Status().Equal(http.StatusOK),
Expect().Headers("Location").Contains("/election/2"),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": true,
"maxVoters": 10,
"expiresAt": "2124-12-31T14:15:22Z",
"choices": [
"Gandhi",
"Buddha"
]
}`),
Expect().Status().Equal(http.StatusOK),
Expect().Headers("Location").Contains("/election/3"),
Expect().Body().JSON().JQ(".voterIdentities").Len().Equal(10),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": true,
"maxVoters": -1,
"expiresAt": "2124-12-31T14:15:22Z",
"choices": [
"Gandhi",
"Buddha"
]
}`),
Expect().Status().Equal(http.StatusUnprocessableEntity),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": false,
"maxVoters": -1,
"expiresAt": "2124-12-31T14:15:22Z",
"choices": [
"Gandhi"
]
}`),
Expect().Status().Equal(http.StatusUnprocessableEntity),
)
MustDo(
template,
Send().Body().String(`
{
"name": "Guy of the year",
"tokens": 100,
"areVotersKnown": false,
"maxVoters": 10,
"expiresAt": "2018-12-31T14:15:22Z",
"choices": [
"Gandhi",
"Buddha"
]
}`),
Expect().Status().Equal(http.StatusUnprocessableEntity),
)
}

View File

@ -20,7 +20,7 @@ func (app *application) recoverPanic(next http.Handler) http.Handler {
func (app *application) logRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
ip = r.RemoteAddr
ip = realIP(r)
proto = r.Proto
method = r.Method
uri = r.URL.RequestURI()
@ -29,3 +29,10 @@ func (app *application) logRequest(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
func (app *application) cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
next.ServeHTTP(w, r)
})
}

View File

@ -3,7 +3,7 @@ info:
title: qv - dlmw
description: |-
This is the documentation for the qv (Quadratic Voting) API.
termsOfService: http://swagger.io/terms/
termsOfService:
contact:
email: dylan@dlmw.ch
license:
@ -11,15 +11,49 @@ info:
url: https://www.gnu.org/licenses/gpl-3.0.txt
version: 0.0.1
externalDocs:
description: Find out more about qv # todo
url: http://swagger.io # todo
description: Get the code
url: https://code.dlmw.ch/dlmw/qv
servers:
- url: https://petstore3.swagger.io/api/v3 # todo
- url: https://qv.dlmw.ch/api
tags:
- name: election
description: Retrieve data related to elections
- name: vote
description: Retrieve data related to votes
paths:
/election/{id}:
get:
tags:
- election
summary: Get an election
operationId: getElection
parameters:
- name: id
in: path
required: true
description: The ID of the election
schema:
type: string
responses:
200:
description: Election returned
content:
application/json:
schema:
$ref: "#/components/schemas/Election"
400:
description: Request malformed
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
404:
description: Election not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/election:
post:
tags:
@ -34,6 +68,11 @@ paths:
responses:
200:
description: Election created. Body only returned if voterAreKnown is true
headers:
Location:
schema:
type: string
description: The path to the newly created election
content:
application/json:
schema:
@ -44,6 +83,97 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
500:
description: Server error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/election/{id}/votes:
post:
tags:
- vote
summary: Cast your votes for an election
operationId: createVotes
parameters:
- name: id
in: path
required: true
description: The ID of the election
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/CreateVotesRequest"
responses:
201:
description: Votes cast
400:
description: Request malformed
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
404:
description: Election not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
422:
description: Unprocessable content
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
500:
description: Server error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/election/{id}/results:
get:
tags:
- election
summary: Get the results of an election
operationId: getElectionResults
parameters:
- name: id
in: path
required: true
description: The ID of the election
schema:
type: string
responses:
200:
description: Election results returned
content:
application/json:
schema:
$ref: "#/components/schemas/ElectionResultsResponse"
400:
description: Request malformed
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
404:
description: Election doesn't exist
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
500:
description: Server error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
components:
schemas:
@ -55,98 +185,28 @@ components:
- tokens
- areVotersKnown
- maxVoters
- createdAt
- expiresAt
- choices
properties:
id:
type: integer
format: int64
readOnly: true
type: string
name:
type: string
minLength: 1
tokens:
type: integer
minimum: 1
areVotersKnown:
type: boolean
maxVoters:
type: integer
minimum: 0
description: Must be greater than 0 when voters are known
createdAt:
type: string
format: date-time
readOnly: true
expiresAt:
type: string
format: date-time
choices:
type: array
items:
$ref: '#/components/schemas/Choice'
voters:
type: array
items:
$ref: '#/components/schemas/Voter'
Choice:
type: object
required:
- text
- electionId
properties:
text:
type: string
minLength: 1
electionId:
type: integer
format: int64
Voter:
type: object
required:
- identity
- electionId
properties:
identity:
type: string
minLength: 1
description: When voters are known, passcodes will be pre-generated
electionId:
type: integer
format: int64
votes:
type: array
items:
$ref: '#/components/schemas/Vote'
Vote:
type: object
required:
- voterIdentity
- electionId
properties:
voterIdentity:
type: string
minLength: 1
electionId:
type: integer
format: int64
choiceText:
type: string
nullable: true
tokens:
type: integer
minimum: 1
nullable: true
calculatedVoteCount:
type: integer
readOnly: true
description: Calculated as floor(sqrt(tokens))
createdAt:
type: string
format: date-time
readOnly: true
CreateElectionRequest:
type: object
@ -189,9 +249,48 @@ components:
items:
type: string
minLength: 1
maxItems: 1
uniqueItems: true
CreateVotesRequest:
type: object
required:
- choices
properties:
voterIdentity:
type: string
description: Must be filled if election has known voters
choices:
type: array
items:
required:
- choiceText
- tokens
properties:
choiceText:
type: string
minLength: 1
tokens:
type: integer
ElectionResultsResponse:
type: object
properties:
results:
type: array
items:
$ref: "#/components/schemas/VotesForChoice"
VotesForChoice:
type: object
required:
- choice
- votes
properties:
choice:
type: string
votes:
type: integer
ErrorResponse:
type: object
required:
@ -207,4 +306,3 @@ components:
details:
type: object
description: Additional error details when available
nullable: true

View File

@ -1,7 +1,9 @@
package main
import (
api "code.dlmw.ch/dlmw/qv/internal"
"code.dlmw.ch/dlmw/qv/internal/models"
"code.dlmw.ch/dlmw/qv/ui"
"github.com/justinas/alice"
"log/slog"
"net/http"
@ -11,13 +13,25 @@ type application struct {
logger *slog.Logger
elections models.ElectionModelInterface
voters models.VoterModelInterface
votes models.VoteModelInterface
}
func (app *application) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /election", app.createElection)
mux.HandleFunc("POST /vote", app.createVote)
cached := alice.New(app.cacheStatic)
mux.Handle("GET /static/", cached.Then(http.FileServerFS(ui.Files)))
mux.HandleFunc("GET /", app.indexPage)
mux.HandleFunc("GET /election/create", app.createElectionPage)
mux.HandleFunc("GET /election/{id}/results", app.getElectionResultsPage)
mux.HandleFunc("GET /election/{id}", app.getElectionPage)
api.HandlerWithOptions(app, api.StdHTTPServerOptions{
BaseRouter: mux,
BaseURL: "/api",
ErrorHandlerFunc: app.badRequestError,
})
standard := alice.New(app.recoverPanic, app.logRequest)
return standard.Then(mux)

View File

@ -1,6 +1,9 @@
package main
import (
"bytes"
"code.dlmw.ch/dlmw/qv/internal/models"
"github.com/stretchr/testify/mock"
"io"
"log/slog"
"net/http"
@ -14,6 +17,7 @@ func newTestApplication(t *testing.T) *application {
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
elections: &mockElectionModel{},
voters: &mockVoterModel{},
votes: &mockVoteModel{},
}
}
@ -26,6 +30,22 @@ func newTestServer(t *testing.T, h http.Handler) *testServer {
return &testServer{server}
}
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
rs, err := ts.Client().Get(ts.URL + urlPath)
if err != nil {
t.Fatal(err)
}
defer rs.Body.Close()
body, err := io.ReadAll(rs.Body)
if err != nil {
t.Fatal(err)
}
body = bytes.TrimSpace(body)
return rs.StatusCode, rs.Header, string(body)
}
func (ts *testServer) post(t *testing.T, urlPath string, body io.Reader) (int, http.Header, string) {
res, err := ts.Client().Post(ts.URL+urlPath, "application/json", body)
if err != nil {
@ -42,15 +62,53 @@ func (ts *testServer) post(t *testing.T, urlPath string, body io.Reader) (int, h
}
type mockElectionModel struct {
mock.Mock
}
func (e *mockElectionModel) Insert(name string, tokens int, areVotersKnown bool, maxVoters int, choices []string, expiresAt time.Time) (int, error) {
return 1, nil
func (e *mockElectionModel) Insert(name string, tokens int, areVotersKnown bool, maxVoters int, choices []string, expiresAt time.Time) (string, error) {
args := e.Called(name, tokens, areVotersKnown, maxVoters, choices, expiresAt)
return args.String(0), args.Error(1)
}
func (e *mockElectionModel) GetById(id string) (*models.Election, error) {
args := e.Called(id)
return args.Get(0).(*models.Election), args.Error(1)
}
type mockVoterModel struct {
mock.Mock
}
func (v *mockVoterModel) Insert(identity string, electionID int) (int, error) {
return 1, nil
func (v *mockVoterModel) InsertMultiple(identities []string, electionID string) ([]int, error) {
args := v.Called(identities, electionID)
return args.Get(0).([]int), args.Error(1)
}
func (v *mockVoterModel) CountByElection(electionID string) (int, error) {
args := v.Called(electionID)
return args.Int(0), args.Error(1)
}
func (v *mockVoterModel) Exists(voterIdentity string, electionID string) (bool, error) {
args := v.Called(voterIdentity, electionID)
return args.Bool(0), args.Error(1)
}
type mockVoteModel struct {
mock.Mock
}
func (v *mockVoteModel) Insert(voterIdentity string, electionId string, choiceText string, tokens int) (int, error) {
args := v.Called(voterIdentity, electionId, choiceText, tokens)
return args.Int(0), args.Error(1)
}
func (v *mockVoteModel) Exists(voterIdentity string, electionID string) (bool, error) {
args := v.Called(voterIdentity, electionID)
return args.Bool(0), args.Error(1)
}
func (v *mockVoteModel) GetByElection(electionID string) (*[]models.Vote, error) {
args := v.Called(electionID)
return args.Get(0).(*[]models.Vote), args.Error(1)
}

42
go.mod
View File

@ -3,36 +3,40 @@ module code.dlmw.ch/dlmw/qv
go 1.23.4
require (
github.com/Eun/go-hit v0.5.23
github.com/golang-migrate/migrate/v4 v4.18.1
github.com/google/uuid v1.6.0
github.com/justinas/alice v1.2.0
github.com/mattn/go-sqlite3 v1.14.24
github.com/oapi-codegen/runtime v1.1.1
github.com/stretchr/testify v1.9.0
modernc.org/sqlite v1.18.1
)
require (
github.com/Eun/go-convert v0.0.0-20200421145326-bef6c56666ee // indirect
github.com/Eun/go-doppelgangerreader v0.0.0-20190911075941-30f1527f16b2 // indirect
github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.5.6 // indirect
github.com/gookit/color v1.4.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/itchyny/gojq v0.12.5 // indirect
github.com/itchyny/timefmt-go v0.1.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/k0kubun/pp v3.0.1+incompatible // indirect
github.com/lunixbochs/vtclean v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.7 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
golang.org/x/tools v0.24.0 // indirect
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.36.3 // indirect
modernc.org/ccgo/v3 v3.16.9 // indirect
modernc.org/libc v1.17.1 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.2.1 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.0 // indirect
)

171
go.sum
View File

@ -1,144 +1,131 @@
github.com/Eun/go-convert v0.0.0-20200421145326-bef6c56666ee h1:9oCc9EfVVSuy2WoHLAEYppJ5zX45+MQhAU1W30Uu3SI=
github.com/Eun/go-convert v0.0.0-20200421145326-bef6c56666ee/go.mod h1:cMqWKb0SQrV+L1Zve08CI1NQGPeRAjXuYTxYE/y6gcU=
github.com/Eun/go-doppelgangerreader v0.0.0-20190911075941-30f1527f16b2 h1:RfkLLL7sQdxTMWRLo//6CZcAN3j5/laO8BooS9ctG2g=
github.com/Eun/go-doppelgangerreader v0.0.0-20190911075941-30f1527f16b2/go.mod h1:+o+i8cYK1XYOQo4ocUKNV4R9D5Y7MIAPJk2l5SEh93M=
github.com/Eun/go-hit v0.5.23 h1:ezifQcvEh4qW/1/NdG59h0H7vTVJWVZWkXILaJBav4c=
github.com/Eun/go-hit v0.5.23/go.mod h1:LCHZ6WSPFDXlTQkFUSLe0VsrOhzzEEzbPzCGc6FYTXQ=
github.com/Eun/go-testdoc v0.0.1/go.mod h1:uT+GeDi7TpqQx6MBkcfXD9nF15Q8IX+kTNEnUUPbuUo=
github.com/Eun/yaegi-template v1.5.16/go.mod h1:eyFQ1QHbKLNHKpUvdjt8+99ZR1ji7lVVbduSK1M5N/U=
github.com/Eun/yaegi-template v1.5.18/go.mod h1:iVHjge496SWL7hLf1euBZIO40Bk0R38g6lu8iyvpc30=
github.com/aaw/maybe_tls v0.0.0-20160803104303-89c499bcc6aa h1:6yJyU8MlPBB2enGJdPciPlr8P+PC0nhCFHnSHYMirZI=
github.com/aaw/maybe_tls v0.0.0-20160803104303-89c499bcc6aa/go.mod h1:I0wzMZvViQzmJjxK+AtfFAnqDCkQV/+r17PO1CCSYnU=
github.com/araddon/dateparse v0.0.0-20190622164848-0fb0a474d195/go.mod h1:SLqhdZcd+dF3TEVL2RMoob5bBP5R1P1qkox+HtCBgGI=
github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1 h1:TEBmxO80TM04L8IuMWk77SGL1HomBmKTdzdJLLWznxI=
github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1/go.mod h1:SLqhdZcd+dF3TEVL2RMoob5bBP5R1P1qkox+HtCBgGI=
github.com/dave/jennifer v1.4.1/go.mod h1:7jEdnm+qBcxl8PC0zyp7vxcpSRnzXSt9r39tpTVGlwA=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk=
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/itchyny/go-flags v1.5.0/go.mod h1:lenkYuCobuxLBAd/HGFE4LRoW8D3B6iXRQfWYJ+MNbA=
github.com/itchyny/gojq v0.12.5 h1:6SJ1BQ1VAwJAlIvLSIZmqHP/RUEq3qfVWvsRxrqhsD0=
github.com/itchyny/gojq v0.12.5/go.mod h1:3e1hZXv+Kwvdp6V9HXpVrvddiHVApi5EDZwS+zLFeiE=
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 h1:uC1QfSlInpQF+M0ao65imhwqKnz3Q2z/d8PWZRMQvDM=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/k0kubun/pp v3.0.1+incompatible h1:3tqvf7QgUnZ5tXO6pNAZlrvHgl6DvifjDrd9g2S9Z40=
github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/traefik/yaegi v0.9.8/go.mod h1:FAYnRlZyuVlEkvnkHq3bvJ1lW5be6XuwgLdkYgYG6Lk=
github.com/traefik/yaegi v0.9.10/go.mod h1:FAYnRlZyuVlEkvnkHq3bvJ1lW5be6XuwgLdkYgYG6Lk=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg=
modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM=
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI=
modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs=
modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8=
modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao=
modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw=
modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM=
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=

View File

@ -9,6 +9,8 @@ import (
"fmt"
"net/http"
"time"
"github.com/oapi-codegen/runtime"
)
// CreateElectionRequest defines model for CreateElectionRequest.
@ -28,26 +30,72 @@ type CreateElectionResponse struct {
VoterIdentities *[]string `json:"voterIdentities,omitempty"`
}
// CreateVotesRequest defines model for CreateVotesRequest.
type CreateVotesRequest struct {
Choices []struct {
ChoiceText string `json:"choiceText"`
Tokens int `json:"tokens"`
} `json:"choices"`
// VoterIdentity Must be filled if election has known voters
VoterIdentity *string `json:"voterIdentity,omitempty"`
}
// Election defines model for Election.
type Election struct {
AreVotersKnown bool `json:"areVotersKnown"`
Choices []string `json:"choices"`
CreatedAt string `json:"createdAt"`
ExpiresAt string `json:"expiresAt"`
Id string `json:"id"`
MaxVoters int `json:"maxVoters"`
Name string `json:"name"`
Tokens int `json:"tokens"`
}
// ElectionResultsResponse defines model for ElectionResultsResponse.
type ElectionResultsResponse struct {
Results *[]VotesForChoice `json:"results,omitempty"`
}
// ErrorResponse defines model for ErrorResponse.
type ErrorResponse struct {
// Code Machine-readable error code
Code int `json:"code"`
// Details Additional error details when available
Details *map[string]interface{} `json:"details"`
Details *map[string]interface{} `json:"details,omitempty"`
// Message Human-readable error message
Message string `json:"message"`
}
// VotesForChoice defines model for VotesForChoice.
type VotesForChoice struct {
Choice string `json:"choice"`
Votes int `json:"votes"`
}
// CreateElectionJSONRequestBody defines body for CreateElection for application/json ContentType.
type CreateElectionJSONRequestBody = CreateElectionRequest
// CreateVotesJSONRequestBody defines body for CreateVotes for application/json ContentType.
type CreateVotesJSONRequestBody = CreateVotesRequest
// ServerInterface represents all server handlers.
type ServerInterface interface {
// Create a new election
// (POST /election)
CreateElection(w http.ResponseWriter, r *http.Request)
// Get an election
// (GET /election/{id})
GetElection(w http.ResponseWriter, r *http.Request, id string)
// Get the results of an election
// (GET /election/{id}/results)
GetElectionResults(w http.ResponseWriter, r *http.Request, id string)
// Cast your votes for an election
// (POST /election/{id}/votes)
CreateVotes(w http.ResponseWriter, r *http.Request, id string)
}
// ServerInterfaceWrapper converts contexts to parameters.
@ -74,6 +122,84 @@ func (siw *ServerInterfaceWrapper) CreateElection(w http.ResponseWriter, r *http
handler.ServeHTTP(w, r.WithContext(ctx))
}
// GetElection operation middleware
func (siw *ServerInterfaceWrapper) GetElection(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
// ------------- Path parameter "id" -------------
var id string
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetElection(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r.WithContext(ctx))
}
// GetElectionResults operation middleware
func (siw *ServerInterfaceWrapper) GetElectionResults(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
// ------------- Path parameter "id" -------------
var id string
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetElectionResults(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r.WithContext(ctx))
}
// CreateVotes operation middleware
func (siw *ServerInterfaceWrapper) CreateVotes(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
// ------------- Path parameter "id" -------------
var id string
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.CreateVotes(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r.WithContext(ctx))
}
type UnescapedCookieParamError struct {
ParamName string
Err error
@ -189,6 +315,9 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
}
m.HandleFunc("POST "+options.BaseURL+"/election", wrapper.CreateElection)
m.HandleFunc("GET "+options.BaseURL+"/election/{id}", wrapper.GetElection)
m.HandleFunc("GET "+options.BaseURL+"/election/{id}/results", wrapper.GetElectionResults)
m.HandleFunc("POST "+options.BaseURL+"/election/{id}/votes", wrapper.CreateVotes)
return m
}

View File

@ -0,0 +1,19 @@
package mappers
import (
api "code.dlmw.ch/dlmw/qv/internal"
"code.dlmw.ch/dlmw/qv/internal/models"
)
func ElectionResponse(election *models.Election) *api.Election {
return &api.Election{
Id: election.ID,
Name: election.Name,
Tokens: election.Tokens,
AreVotersKnown: election.AreVotersKnown,
MaxVoters: election.MaxVoters,
CreatedAt: election.CreatedAt.String(),
ExpiresAt: election.ExpiresAt.String(),
Choices: election.Choices,
}
}

View File

@ -0,0 +1,48 @@
package mappers
import (
"code.dlmw.ch/dlmw/qv/internal/models"
uuid2 "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestElectionResponse(t *testing.T) {
layout := "2006-01-02 15:04:05 -0700"
parsedCreatedAtTime, err := time.Parse(layout, "2025-01-14 15:13:37 +0000")
if err != nil {
t.Fatal(err.Error())
}
parsedExpiresAtTime, err := time.Parse(layout, "2025-01-15 15:13:37 +0000")
if err != nil {
t.Fatal(err.Error())
}
uuid, _ := uuid2.NewV7()
election := models.Election{
ID: uuid.String(),
Name: "The best",
Tokens: 100,
AreVotersKnown: true,
MaxVoters: 150,
CreatedAt: parsedCreatedAtTime.In(time.UTC),
ExpiresAt: parsedExpiresAtTime.In(time.UTC),
Choices: []string{"You", "Me"},
}
response := ElectionResponse(&election)
assert.Equal(t, uuid.String(), response.Id)
assert.Equal(t, "The best", response.Name)
assert.Equal(t, 100, response.Tokens)
assert.Equal(t, true, response.AreVotersKnown)
assert.Equal(t, 150, response.MaxVoters)
assert.Equal(t, "2025-01-14 15:13:37 +0000 UTC", response.CreatedAt)
assert.Equal(t, "2025-01-15 15:13:37 +0000 UTC", response.ExpiresAt)
for _, choice := range election.Choices {
assert.Contains(t, election.Choices, choice)
}
}

View File

@ -1,9 +1,9 @@
CREATE TABLE elections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
tokens INTEGER NOT NULL,
are_voters_known INTEGER NOT NULL,
max_voters INTEGER, -- mandatory when voters are known
max_voters INTEGER NOT NULL, -- must be greater than 0 when voters are known
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
CHECK (are_voters_known = 0 OR (are_voters_known = 1 AND max_voters IS NOT NULL AND max_voters >= 1))
@ -35,7 +35,7 @@ CREATE TRIGGER enforce_max_voters
SELECT 1
FROM elections e
WHERE e.id = NEW.election_id
AND e.max_voters IS NOT NULL
AND e.max_voters != 0
)
BEGIN
SELECT CASE
@ -57,7 +57,6 @@ CREATE TABLE votes (
election_id INTEGER NOT NULL,
choice_text TEXT NOT NULL,
tokens INTEGER NOT NULL,
-- calculated_vote_count GENERATED ALWAYS AS (floor(sqrt(tokens))) VIRTUAL, TODO: Cannot use math functions
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (voter_identity, choice_text),
FOREIGN KEY (voter_identity, election_id) REFERENCES voters (identity, election_id),

View File

@ -2,54 +2,111 @@ package models
import (
"database/sql"
"github.com/google/uuid"
"time"
)
type ElectionModelInterface interface {
Insert(name string, tokens int, areVotersKnown bool, maxVoters int, Choices []string, ExpiresAt time.Time) (int, error)
Insert(name string, tokens int, areVotersKnown bool, maxVoters int, Choices []string, ExpiresAt time.Time) (string, error)
GetById(id string) (*Election, error)
}
type ElectionModel struct {
DB *sql.DB
}
func (e *ElectionModel) Insert(name string, tokens int, areVotersKnown bool, maxVoters int, choices []string, expiresAt time.Time) (int, error) {
type Election struct {
ID string
Name string
Tokens int
AreVotersKnown bool
MaxVoters int
CreatedAt time.Time
ExpiresAt time.Time
Choices []string
}
func (e *ElectionModel) Insert(name string, tokens int, areVotersKnown bool, maxVoters int, choices []string, expiresAt time.Time) (string, error) {
tx, err := e.DB.Begin()
if err != nil {
return 0, err
return "", err
}
defer tx.Rollback()
result, err := tx.Exec(`
INSERT INTO elections (name, tokens, are_voters_known, max_voters, expires_at)
VALUES (?, ?, ?, ?, ?)`, name, tokens, areVotersKnown, maxVoters, expiresAt)
electionID, err := uuid.NewV7()
if err != nil {
return 0, err
return "", err
}
electionID, err := result.LastInsertId()
_, err = tx.Exec(`
INSERT INTO elections (id, name, tokens, are_voters_known, max_voters, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, electionID, name, tokens, areVotersKnown, maxVoters, expiresAt)
if err != nil {
return 0, err
return "", err
}
stmt, err := tx.Prepare(`
INSERT INTO choices (text, election_id)
VALUES (?, ?)`)
if err != nil {
return 0, err
return "", err
}
defer stmt.Close()
for _, choice := range choices {
_, err = stmt.Exec(choice, electionID)
if err != nil {
return 0, err
return "", err
}
}
if err = tx.Commit(); err != nil {
return 0, err
return "0", err
}
return int(electionID), nil
return electionID.String(), nil
}
func (e *ElectionModel) GetById(id string) (*Election, error) {
query := `
SELECT id, name, tokens, are_voters_known, max_voters, created_at, expires_at
FROM elections
WHERE id = ?
`
row := e.DB.QueryRow(query, id)
election := &Election{}
err := row.Scan(&election.ID, &election.Name, &election.Tokens, &election.AreVotersKnown, &election.MaxVoters, &election.CreatedAt, &election.ExpiresAt)
if err != nil {
return nil, err
}
// Retrieve choices for the election
queryChoices := `
SELECT text
FROM choices
WHERE election_id = ?
`
rows, err := e.DB.Query(queryChoices, id)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var choice string
if err := rows.Scan(&choice); err != nil {
return nil, err
}
election.Choices = append(election.Choices, choice)
}
if err := rows.Err(); err != nil {
return nil, err
}
return election, nil
}

View File

@ -2,35 +2,103 @@ package models
import (
"database/sql"
"errors"
)
type VoterModelInterface interface {
Insert(identity string, electionID int) (int, error)
InsertMultiple(identities []string, electionID string) ([]int, error)
CountByElection(electionID string) (int, error)
Exists(voterIdentity string, electionID string) (bool, error)
}
type VoterModel struct {
DB *sql.DB
}
func (v *VoterModel) Insert(identity string, electionID int) (int, error) {
func (v *VoterModel) InsertMultiple(identities []string, electionID string) ([]int, error) {
tx, err := v.DB.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO voters (identity, election_id)
VALUES (?, ?)`)
if err != nil {
return nil, err
}
defer stmt.Close()
voterIDs := make([]int, 0, len(identities))
for _, identity := range identities {
result, err := stmt.Exec(identity, electionID)
if err != nil {
return nil, err
}
voterID, err := result.LastInsertId()
if err != nil {
return nil, err
}
voterIDs = append(voterIDs, int(voterID))
}
if err = tx.Commit(); err != nil {
return nil, err
}
return voterIDs, nil
}
func (v *VoterModel) CountByElection(electionID string) (int, error) {
// use a transaction to prevent race conditions
tx, err := v.DB.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
result, err := tx.Exec(`
INSERT INTO voters (identity, election_id)
VALUES (?, ?)`,
identity, electionID)
query := `
SELECT COUNT(identity)
FROM voters
WHERE election_id = ?
GROUP BY election_id;
`
row := tx.QueryRow(query, electionID)
var voterCount int
err = row.Scan(&voterCount)
if err != nil {
return 0, err
}
if err = tx.Commit(); err != nil {
return 0, err
tx.Commit()
return voterCount, nil
}
voterId, err := result.LastInsertId()
return int(voterId), nil
func (v *VoterModel) Exists(voterIdentity string, electionID string) (bool, error) {
query := `
SELECT EXISTS (
SELECT 1
FROM voters
WHERE identity = ? AND election_id = ?
)
`
var exists bool
err := v.DB.QueryRow(query, voterIdentity, electionID).Scan(&exists)
if err != nil {
if errors.Is(sql.ErrNoRows, err) {
return false, nil
}
return false, err
}
return exists, nil
}

107
internal/models/votes.go Normal file
View File

@ -0,0 +1,107 @@
package models
import (
"database/sql"
"errors"
"time"
)
type VoteModelInterface interface {
Insert(voterIdentity string, electionId string, choiceText string, tokens int) (int, error)
Exists(voterIdentity string, electionID string) (bool, error)
GetByElection(electionID string) (*[]Vote, error)
}
type VoteModel struct {
DB *sql.DB
}
type Vote struct {
VoterIdentity string
ElectionID string
ChoiceText string
Tokens int
CreatedAt time.Time
}
func (v *VoteModel) Insert(voterIdentity string, electionId string, choiceText string, tokens int) (int, error) {
tx, err := v.DB.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
result, err := tx.Exec(`
INSERT INTO votes (voter_identity, election_id, choice_text, tokens)
VALUES (?, ?, ?, ?)`,
voterIdentity, electionId, choiceText, tokens)
if err != nil {
return 0, err
}
if err = tx.Commit(); err != nil {
return 0, err
}
voteId, err := result.LastInsertId()
return int(voteId), nil
}
func (v *VoteModel) Exists(voterIdentity string, electionID string) (bool, error) {
var exists bool
query := `
SELECT EXISTS (
SELECT 1
FROM votes
WHERE voter_identity = ? AND election_id = ?
)
`
err := v.DB.QueryRow(query, voterIdentity, electionID).Scan(&exists)
if err != nil {
if errors.Is(sql.ErrNoRows, err) {
return false, nil
}
return false, err
}
return exists, nil
}
func (v *VoteModel) GetByElection(electionID string) (*[]Vote, error) {
query := `
SELECT voter_identity, election_id, choice_text, tokens, created_at
FROM votes
WHERE election_id = ?
`
rows, err := v.DB.Query(query, electionID)
if err != nil {
return nil, err
}
defer rows.Close()
var votes []Vote
for rows.Next() {
var vote Vote
err := rows.Scan(
&vote.VoterIdentity,
&vote.ElectionID,
&vote.ChoiceText,
&vote.Tokens,
&vote.CreatedAt,
)
if err != nil {
return nil, err
}
votes = append(votes, vote)
}
if err = rows.Err(); err != nil {
return nil, err
}
if len(votes) == 0 {
return nil, sql.ErrNoRows
}
return &votes, nil
}

View File

@ -36,6 +36,10 @@ func (v *Validator) CheckField(ok bool, key, message string) {
}
}
func Blank(value string) bool {
return strings.TrimSpace(value) == ""
}
func NotBlank(value string) bool {
return strings.TrimSpace(value) != ""
}
@ -60,6 +64,10 @@ func GreaterThan(value int, n int) bool {
return value > n
}
func LesserThan(value int, n int) bool {
return value < n
}
func GreaterThanOrEquals(value int, n int) bool {
return value >= n
}

224
ui/create-election.html Normal file
View File

@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create New Election - qv</title>
<script src="/static/js/tailwind.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
</head>
<body class="bg-gray-100 font-sans leading-normal tracking-normal">
<div x-data="electionForm" class="container mx-auto p-6 bg-white rounded-lg shadow-lg">
<main>
<h1 class="text-3xl font-bold mb-6">Create New Election</h1>
<form @submit.prevent="createElection" class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Election Name</label>
<input type="text" id="name" x-model="election.name" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="tokens" class="block text-sm font-medium text-gray-700">Tokens per Voter</label>
<input type="number" id="tokens" x-model.number="election.tokens" min="1" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label for="areVotersKnown" class="block text-sm font-medium text-gray-700">Voter Access</label>
<select id="areVotersKnown" x-model="election.areVotersKnown" required
@change="election.areVotersKnown = JSON.parse(election.areVotersKnown)"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
<option :value=true>Known voters only</option>
<option :value=false>Open to anyone</option>
</select>
<p class="text-sm text-gray-500 mt-1">Known voters only = codes will be generated for distribution.</p>
</div>
</div>
<div>
<label for="maxVoters" class="block text-sm font-medium text-gray-700">Maximum Number of Voters</label>
<input type="number" id="maxVoters" x-model.number="election.maxVoters"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
<p class="text-sm text-gray-500">0 = unlimited</p>
<template x-if="election.areVotersKnown && election.maxVoters <= 0">
<span class="text-red-500 text-sm">Maximum number of voters must be greater than 0 if voters are known.</span>
</template>
</div>
<div>
<label for="expiresAt" class="block text-sm font-medium text-gray-700">Expiration Date</label>
<input type="datetime-local" id="expiresAt" x-model="election.expiresAt" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Choices</label>
<div class="space-y-2 mt-2">
<template x-for="(choice, index) in election.choices" :key="index">
<div class="flex space-x-2 items-center">
<input type="text" x-model="election.choices[index]" required
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
<button type="button" class="text-red-500 hover:text-red-700"
@click="removeChoice(index)" x-show="election.choices.length > 2">×</button>
</div>
</template>
</div>
<button type="button" @click="addChoice"
class="mt-3 inline-flex items-center px-4 py-2 bg-indigo-600 border border-transparent rounded-md font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Add Another Choice
</button>
</div>
<div>
<button type="submit"
class="w-full inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Create Election
</button>
</div>
</form>
<div x-show="errorMessage || Object.keys(errorDetails).length > 0" class="mt-6 bg-red-100 p-4 rounded-md">
<h2 class="text-red-700 font-bold" x-text="errorMessage"></h2>
<ul class="text-red-600 mt-2 space-y-1">
<template x-for="(message, field) in errorDetails" :key="field">
<li><strong x-text="field"></strong>: <span x-text="message"></span></li>
</template>
</ul>
</div>
<template x-if="createdElectionId != ''">
<div class="mt-6 bg-green-100 p-4 rounded-md">
<h2 class="text-green-700 font-bold">Election Created Successfully</h2>
<p class="mt-2">
<a
:href="`/election/${createdElectionId}`"
class="text-blue-600 underline hover:text-blue-800"
target="_blank">
View election
</a>
</p>
<p>
<a
:href="`/election/${createdElectionId}/results`"
class="text-blue-600 underline hover:text-blue-800"
target="_blank">
View results
</a>
</p>
</div>
</template>
<template x-if="voterIdentities.length > 0">
<div class="voter-codes">
<h2 class="text-2xl font-semibold mb-4">Voter Access Codes</h2>
<div class="codes-container">
<button @click="copyAllCodes" class="copy-all-btn bg-indigo-600 text-white py-2 px-4 rounded-md font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 mb-4">
Copy All Codes
</button>
<div class="codes-list">
<template x-for="(code, index) in voterIdentities" :key="index">
<div class="code-item flex items-center space-x-3 mb-2">
<span class="code-number text-gray-700 font-medium" x-text="index + 1"></span>.
<span class="code-text text-gray-900" x-text="code"></span>
<button @click="copyCode(code)" class="copy-btn bg-indigo-600 text-white text-sm py-1 px-3 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500">
Copy
</button>
</div>
</template>
</div>
</div>
</div>
</template>
</main>
</div>
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("electionForm", () => ({
election: {
name: "",
tokens: 100,
areVotersKnown: true,
maxVoters: 0,
expiresAt: "",
choices: ["", ""]
},
createdElectionId: "",
errorMessage: "",
errorDetails: {},
voterIdentities: [],
addChoice() {
this.election.choices.push("");
},
removeChoice(index) {
this.election.choices.splice(index, 1);
},
async createElection() {
this.errorMessage = "";
this.errorDetails = {};
this.createdElectionId = "";
this.voterIdentities = [];
const payload = {
...this.election,
expiresAt: this.election.expiresAt + ":00Z",
choices: this.election.choices.filter(choice => choice.trim() !== "")
};
try {
const response = await fetch("/api/election", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json();
this.errorMessage = errorData.message || "An error occurred.";
this.errorDetails = errorData.details?.fields || {};
return;
}
const locationHeader = response.headers.get("Location");
this.createdElectionId = locationHeader.replace("/election/", "");
const contentType = response.headers.get("Content-Type") ?? "";
if (contentType.includes("application/json")) {
const data = await response.json();
this.voterIdentities = data.voterIdentities;
}
} catch (error) {
console.log(error);
this.errorMessage = "Failed to create election.";
}
},
copyCode(code) {
navigator.clipboard.writeText(code).then(() => {
alert("Code copied to clipboard!");
}).catch(err => {
console.error("Failed to copy code: ", err);
});
},
async copyAllCodes() {
const allCodes = this.voterIdentities.join("\n");
await navigator.clipboard.writeText(allCodes)
.catch(err => {
console.error("Failed to copy all codes: ", err);
});
}
}));
});
</script>
</body>
</html>

145
ui/election-results.html Normal file
View File

@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Election Results - qv</title>
<script src="/static/js/tailwind.min.js"></script>
<script src="/static/js/chart.umd.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
</head>
<body class="bg-gray-100 font-sans leading-normal tracking-normal">
<div x-data="resultsPage" class="container mx-auto p-6 bg-white rounded-lg shadow-lg">
<main>
<h1 class="text-3xl font-bold mb-6">Election Results</h1>
<div x-show="error" class="bg-red-100 p-4 rounded-md text-red-700">
<p x-text="error"></p>
</div>
<template x-if="!error">
<div class="space-y-6">
<!-- Chart Container -->
<div class="bg-white p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4">Vote Distribution</h2>
<div class="h-96 w-full">
<canvas id="resultsChart"></canvas>
</div>
</div>
<!-- Detailed Results -->
<div class="bg-white p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">Detailed Results</h3>
<div class="space-y-2">
<template x-for="(result, index) in results" :key="index">
<div class="flex justify-between border-b pb-2">
<div class="flex items-center">
<div class="w-4 h-4 rounded mr-2" :style="{ backgroundColor: getColor(index) }"></div>
<span class="font-medium" x-text="result.choice"></span>
</div>
<span class="text-gray-600">
<span x-text="result.votes"></span> votes
(<span x-text="getPercentage(result.votes)"></span>%)
</span>
</div>
</template>
</div>
</div>
</div>
</template>
</main>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('resultsPage', () => ({
results: [],
error: '',
chart: null,
colors: [
'#4f46e5', // Indigo (primary)
'#2563eb', // Blue
'#7c3aed', // Violet
'#db2777', // Pink
'#dc2626', // Red
],
getColor(index) {
return this.colors[index % this.colors.length];
},
getPercentage(votes) {
const total = this.results.reduce((sum, result) => sum + result.votes, 0);
if (total === 0) return '0.0';
return ((votes / total) * 100).toFixed(1);
},
async init() {
await this.fetchResults();
this.initChart();
},
async fetchResults() {
const electionId = window.location.pathname.split("/")[2];
try {
const response = await fetch(`/api/election/${electionId}/results`);
if (!response.ok) throw new Error("Failed to load election results.");
const data = await response.json();
this.results = data.results;
} catch (error) {
console.error(error);
this.error = error.message;
}
},
initChart() {
if (this.error) return;
const ctx = document.getElementById('resultsChart').getContext('2d');
// Destroy existing chart if it exists
if (this.chart) {
this.chart.destroy();
}
this.chart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: this.results.map(r => r.choice),
datasets: [{
data: this.results.map(r => r.votes),
backgroundColor: this.results.map((_, index) => this.getColor(index)),
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
padding: 20,
boxWidth: 12,
boxHeight: 12
}
},
tooltip: {
callbacks: {
label: (context) => {
const total = context.dataset.data.reduce((sum, count) => sum + count, 0);
const percentage = ((context.raw / total) * 100).toFixed(1);
return `${context.raw} votes (${percentage}%)`;
}
}
}
},
cutout: '60%'
}
});
}
}));
});
</script>
</body>
</html>

126
ui/election.html Normal file
View File

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vote in Election - qv</title>
<script src="/static/js/tailwind.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
</head>
<body class="bg-gray-100 font-sans leading-normal tracking-normal">
<div x-data="votePage" class="container mx-auto p-6 bg-white rounded-lg shadow-lg">
<main>
<h1 class="text-3xl font-bold mb-6" x-text="election.name || 'Loading...'"></h1>
<form @submit.prevent="submitVote" class="space-y-6">
<!-- Code Field -->
<template x-if="election.areVotersKnown">
<div>
<label for="code" class="block text-sm font-medium text-gray-700">Access Code</label>
<input type="text" id="code" x-model="code" required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
</template>
<!-- Voting Choices -->
<template x-if="election.choices.length > 0">
<div>
<label class="block text-sm font-medium text-gray-700">Distribute Your Tokens</label>
<div class="space-y-2 mt-2">
<template x-for="(choice, index) in election.choices" :key="index">
<div>
<label class="block text-gray-700" x-text="choice"></label>
<input type="number" min="0"
x-model.number="votes[index]"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
</template>
<p class="text-sm text-gray-500 mt-2">Tokens remaining: <span x-text="remainingTokens"></span></p>
</div>
</div>
</template>
<!-- No Choices Message -->
<template x-if="!election.choices.length">
<p class="text-gray-700">No choices available for this election.</p>
</template>
<button type="submit"
:disabled="remainingTokens < 0"
class="w-full inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50">
Submit Vote
</button>
</form>
<div x-show="message" class="mt-6 p-4 rounded-md" :class="{'bg-green-100 text-green-700': success, 'bg-red-100 text-red-700': !success}">
<p x-text="message"></p>
</div>
</main>
</div>
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("votePage", () => ({
election: { name: "", choices: [], tokens: 0, areVotersKnown: false },
code: "",
votes: [],
message: "",
success: false,
get remainingTokens() {
return this.election.tokens - this.votes.reduce((sum, v) => sum + v, 0);
},
async init() {
const electionId = window.location.pathname.split("/").pop();
try {
const response = await fetch(`/api/election/${electionId}`);
if (!response.ok) throw new Error("Failed to load election details.");
const data = await response.json();
this.election = data;
this.votes = Array(data.choices.length).fill(0);
} catch (error) {
console.error(error);
this.message = "Failed to load election details.";
this.success = false;
}
},
async submitVote() {
if (this.remainingTokens < 0) {
this.message = "You have exceeded the number of available tokens.";
this.success = false;
return;
}
const electionId = window.location.pathname.split("/").pop();
const payload = {
voterIdentity: this.election.areVotersKnown ? this.code : null,
choices: this.election.choices.map((choice, index) => ({
choiceText: choice,
tokens: this.votes[index] || 0,
})),
};
try {
const response = await fetch(`/api/election/${electionId}/votes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
res = await response.json();
throw new Error(res.message);
}
this.message = "Vote submitted successfully!";
this.success = true;
} catch (error) {
this.message = error.message;
this.success = false;
}
}
}));
});
</script>
</body>
</html>

28
ui/index.html Normal file
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>qv</title>
<script src="/static/js/tailwind.min.js"></script>
</head>
<body class="bg-gray-100 text-gray-900">
<div class="min-h-screen flex flex-col items-center justify-center">
<header class="text-center mb-8">
<h1 class="text-4xl font-bold mb-4">Quadratic Voting</h1>
<p class="text-lg text-gray-600">Start your journey by creating a new election.</p>
</header>
<main class="text-center">
<a href="/election/create"
class="bg-blue-600 text-white text-lg font-semibold px-6 py-3 rounded-lg hover:bg-blue-700">
Create Election
</a>
</main>
<footer class="mt-12 text-center text-gray-600">
<a href="https://code.dlmw.ch/dlmw/qv" target="_blank">See the source code</a>
</footer>
</div>
</body>
</html>

6
ui/static/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

14
ui/static/js/chart.umd.js Normal file

File diff suppressed because one or more lines are too long

84
ui/static/js/tailwind.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
ui/ui.go Normal file
View File

@ -0,0 +1,6 @@
package ui
import "embed"
//go:embed *.html static
var Files embed.FS