@johnnyboy11234/esg-idea-contest 0.2.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/api/openapi.yaml +83 -0
- package/backend/cmd/server/main.go +35 -2
- package/backend/db/migration/V005__idea.sql +49 -0
- package/backend/db/migration/V006__login_lock.sql +15 -0
- package/backend/db/migration/V007__login_attempt.sql +15 -0
- package/backend/db/migration/V008__pending_file_deletion.sql +27 -0
- package/backend/db/migration/V009__login_throttle_audit.sql +13 -0
- package/backend/go.mod +17 -3
- package/backend/go.sum +48 -7
- package/backend/internal/account/account_test.go +6 -6
- package/backend/internal/account/integration_test.go +45 -9
- package/backend/internal/account/keyboard.go +75 -0
- package/backend/internal/account/keyboard_test.go +43 -0
- package/backend/internal/account/logging_integration_test.go +1 -1
- package/backend/internal/account/password.go +125 -0
- package/backend/internal/account/validate.go +4 -21
- package/backend/internal/api/api.gen.go +224 -0
- package/backend/internal/auth/internal/store/login_attempt.go +53 -0
- package/backend/internal/auth/internal/store/login_lock.go +77 -0
- package/backend/internal/auth/internal/store/login_throttle_audit.go +42 -0
- package/backend/internal/auth/internal/store/session.go +15 -7
- package/backend/internal/auth/login.go +16 -4
- package/backend/internal/auth/login_integration_test.go +14 -1
- package/backend/internal/auth/login_ip_integration_test.go +153 -0
- package/backend/internal/auth/login_lock_integration_test.go +205 -0
- package/backend/internal/auth/login_test.go +15 -0
- package/backend/internal/auth/login_throttle_audit_integration_test.go +88 -0
- package/backend/internal/auth/purge.go +93 -0
- package/backend/internal/auth/purge_integration_test.go +220 -0
- package/backend/internal/auth/purge_test.go +51 -0
- package/backend/internal/auth/session.go +0 -6
- package/backend/internal/auth/session_integration_test.go +3 -3
- package/backend/internal/auth/throttle.go +122 -0
- package/backend/internal/deadline/gate.go +80 -0
- package/backend/internal/deadline/gate_integration_test.go +67 -0
- package/backend/internal/deadline/gate_test.go +61 -0
- package/backend/internal/idea/create.go +352 -0
- package/backend/internal/idea/create_integration_test.go +650 -0
- package/backend/internal/idea/handler.go +148 -0
- package/backend/internal/idea/handler_integration_test.go +107 -0
- package/backend/internal/idea/integration_test.go +149 -0
- package/backend/internal/idea/internal/store/list.go +146 -0
- package/backend/internal/idea/internal/store/list_test.go +33 -0
- package/backend/internal/idea/internal/store/store.go +166 -0
- package/backend/internal/idea/list.go +166 -0
- package/backend/internal/idea/list_integration_test.go +252 -0
- package/backend/internal/idea/list_test.go +96 -0
- package/backend/internal/idea/service.go +53 -0
- package/backend/internal/server/api.go +17 -0
- package/backend/internal/server/public.go +1 -0
- package/backend/internal/server/slides.go +21 -0
- package/backend/internal/shared/config/config.go +44 -1
- package/backend/internal/shared/config/config_test.go +21 -13
- package/backend/internal/shared/db/intx_integration_test.go +179 -0
- package/backend/internal/shared/dbtest/dbtest.go +21 -3
- package/backend/internal/shared/dbtest/faults.go +162 -0
- package/backend/internal/shared/job/job.go +54 -0
- package/backend/internal/shared/job/job_test.go +105 -0
- package/backend/internal/shared/web/multipart.go +175 -0
- package/backend/internal/shared/web/multipart_test.go +169 -0
- package/backend/internal/shared/web/problem.go +1 -0
- package/backend/internal/shared/web/request.go +42 -1
- package/backend/internal/shared/web/web_test.go +36 -0
- package/backend/internal/storage/expectedfilename.go +43 -0
- package/backend/internal/storage/expectedfilename_test.go +16 -0
- package/backend/internal/storage/internal/store/pending.go +41 -0
- package/backend/internal/storage/minio.go +139 -0
- package/backend/internal/storage/minio_integration_test.go +87 -0
- package/backend/internal/storage/minio_test.go +84 -0
- package/backend/internal/storage/pending.go +144 -0
- package/backend/internal/storage/pending_integration_test.go +324 -0
- package/backend/internal/storage/pending_test.go +23 -0
- package/backend/internal/storage/validator.go +100 -0
- package/backend/internal/storage/validator_test.go +95 -0
- package/backend/test/contract/expected_filename_test.go +60 -0
- package/backend/test/contract/password_test.go +46 -0
- package/backend/test/matrix/file_test.go +52 -0
- package/backend/test/matrix/load_test.go +62 -0
- package/backend/test/matrix/matrix_integration_test.go +291 -0
- package/backend/test/migration/pending_file_deletion_test.go +46 -0
- package/docs/API/api-endpoints.md +1 -1
- package/docs/API/error-codes.md +1 -1
- package/frontend/public/auth-hero-login.jpg +0 -0
- package/frontend/public/auth-hero-register.jpg +0 -0
- package/frontend/src/app/App.test.tsx +2 -42
- package/frontend/src/app/App.tsx +11 -2
- package/frontend/src/app/index.css +4 -4
- package/frontend/src/app/layouts.tsx +7 -135
- package/frontend/src/app/pages/JoinRequests.tsx +2 -2
- package/frontend/src/app/pages/NotFound.tsx +1 -1
- package/frontend/src/app/pages/admin.tsx +1 -1
- package/frontend/src/app/pages/auth.tsx +53 -25
- package/frontend/src/app/pages/ideas.tsx +7 -7
- package/frontend/src/app/pages/login.test.tsx +3 -54
- package/frontend/src/app/pages/register.test.tsx +1 -1
- package/frontend/src/app/router.tsx +2 -6
- package/frontend/src/features/auth/IdleWarning.test.tsx +82 -0
- package/frontend/src/features/auth/IdleWarning.tsx +66 -0
- package/frontend/src/features/auth/LoginForm.tsx +8 -1
- package/frontend/src/shared/api/activity.ts +20 -0
- package/frontend/src/shared/api/http.ts +2 -0
- package/frontend/src/shared/api/index.ts +1 -0
- package/frontend/src/shared/i18n/locales/en.ts +1 -0
- package/frontend/src/shared/i18n/locales/zh-TW.ts +1 -0
- package/package.json +1 -1
- package/testdata/expected-filename.json +93 -0
- package/testdata/password.json +425 -0
- package/testdata/permission-matrix.json +157 -0
- package/testdata/schema/expected-filename.schema.json +32 -0
- package/testdata/schema/password.schema.json +59 -0
- package/testdata/schema/permission-matrix.schema.json +73 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Package store holds the SQL of the idea module. Only the idea module may
|
|
2
|
+
// import it (Go's internal rule); no other module touches the idea table.
|
|
3
|
+
package store
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"database/sql"
|
|
8
|
+
"errors"
|
|
9
|
+
"fmt"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/google/uuid"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// ErrNotFound is returned when no row exists or the viewer may not see it. The
|
|
16
|
+
// two cases are deliberately indistinguishable (PRD 6.6, Backend.md 8).
|
|
17
|
+
var ErrNotFound = errors.New("idea not found")
|
|
18
|
+
|
|
19
|
+
// Querier is satisfied by *sql.DB, *sql.Tx and *sql.Conn.
|
|
20
|
+
type Querier interface {
|
|
21
|
+
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// NewIdea is a row to insert. Every value is already validated and cleaned;
|
|
25
|
+
// the table's CHECKs are the second line of defence (V005).
|
|
26
|
+
type NewIdea struct {
|
|
27
|
+
ID uuid.UUID
|
|
28
|
+
LeaderID uuid.UUID
|
|
29
|
+
Category string
|
|
30
|
+
Org string
|
|
31
|
+
Direction string
|
|
32
|
+
Name string
|
|
33
|
+
Summary string
|
|
34
|
+
Slides Slides
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Slides is the slide metadata stored on the idea row (D22).
|
|
38
|
+
type Slides struct {
|
|
39
|
+
ObjectKey string // slides/{ideaId}/{uuid}.{ext}
|
|
40
|
+
Ext string // pdf | pptx
|
|
41
|
+
Size int64
|
|
42
|
+
SHA256 string // lower-case hex
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Idea is a row of idea joined with its Leader's public fields.
|
|
46
|
+
type Idea struct {
|
|
47
|
+
ID uuid.UUID
|
|
48
|
+
Category string
|
|
49
|
+
Org string
|
|
50
|
+
Direction string
|
|
51
|
+
Name string
|
|
52
|
+
Summary string
|
|
53
|
+
LeaderID uuid.UUID
|
|
54
|
+
LeaderEmpNo string
|
|
55
|
+
LeaderName string
|
|
56
|
+
Slides Slides
|
|
57
|
+
SlidesUpdatedAt time.Time
|
|
58
|
+
Version uint32
|
|
59
|
+
CreatedAt time.Time
|
|
60
|
+
UpdatedAt time.Time
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// InsertIdea inserts an Idea in the caller's transaction. A unique-key
|
|
64
|
+
// violation on uq_idea_leader_name is returned as the driver error; the
|
|
65
|
+
// service maps it with db.DuplicateKey to IDEA_DUPLICATE.
|
|
66
|
+
func InsertIdea(ctx context.Context, tx *sql.Tx, n NewIdea) error {
|
|
67
|
+
_, err := tx.ExecContext(ctx,
|
|
68
|
+
`INSERT INTO idea (id, leader_id, category, org, direction, name, summary,
|
|
69
|
+
slides_object_key, slides_ext, slides_size, slides_sha256)
|
|
70
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
71
|
+
n.ID.String(), n.LeaderID.String(), n.Category, n.Org, n.Direction, n.Name, n.Summary,
|
|
72
|
+
n.Slides.ObjectKey, n.Slides.Ext, n.Slides.Size, n.Slides.SHA256)
|
|
73
|
+
if err != nil {
|
|
74
|
+
return fmt.Errorf("insert idea: %w", err)
|
|
75
|
+
}
|
|
76
|
+
return nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// VisibleWhere is the one visibility predicate (PRD 6.6: an Idea is visible to
|
|
80
|
+
// its Leader, its approved Members and Admins). Every read of an Idea, single
|
|
81
|
+
// or list, is built on it; no other code decides who may see an Idea.
|
|
82
|
+
// Arguments, in order: is_admin (0/1, CAST AS SIGNED in sqlc terms, rule R4),
|
|
83
|
+
// viewer user id. It assumes the idea table is aliased i.
|
|
84
|
+
//
|
|
85
|
+
// TODO(B5-01): add the approved-Member branch
|
|
86
|
+
//
|
|
87
|
+
// OR EXISTS (SELECT 1 FROM idea_member m WHERE m.idea_id = i.id
|
|
88
|
+
// AND m.user_id = ? AND m.status = 'ACCEPTED')
|
|
89
|
+
//
|
|
90
|
+
// when the idea_member table exists. Until then Members cannot exist.
|
|
91
|
+
const VisibleWhere = `(? = 1 OR i.leader_id = ?)`
|
|
92
|
+
|
|
93
|
+
const selectIdea = `
|
|
94
|
+
SELECT i.id, i.category, i.org, i.direction, i.name, i.summary,
|
|
95
|
+
i.leader_id, u.emp_no, u.name,
|
|
96
|
+
i.slides_object_key, i.slides_ext, i.slides_size, i.slides_sha256, i.slides_updated_at,
|
|
97
|
+
i.version, i.created_at, i.updated_at
|
|
98
|
+
FROM idea i
|
|
99
|
+
JOIN app_user u ON u.id = i.leader_id`
|
|
100
|
+
|
|
101
|
+
// GetVisible is the single visibility query for one Idea. A missing Idea and
|
|
102
|
+
// an Idea the viewer may not see both return ErrNotFound.
|
|
103
|
+
func GetVisible(ctx context.Context, q Querier, id, viewer uuid.UUID, isAdmin bool) (Idea, error) {
|
|
104
|
+
var (
|
|
105
|
+
out Idea
|
|
106
|
+
sid, lid, vid string
|
|
107
|
+
admin int
|
|
108
|
+
)
|
|
109
|
+
admin = 0
|
|
110
|
+
if isAdmin {
|
|
111
|
+
admin = 1
|
|
112
|
+
}
|
|
113
|
+
vid = viewer.String()
|
|
114
|
+
err := q.QueryRowContext(ctx, selectIdea+` WHERE i.id = ? AND `+VisibleWhere,
|
|
115
|
+
id.String(), admin, vid).Scan(
|
|
116
|
+
&sid, &out.Category, &out.Org, &out.Direction, &out.Name, &out.Summary,
|
|
117
|
+
&lid, &out.LeaderEmpNo, &out.LeaderName,
|
|
118
|
+
&out.Slides.ObjectKey, &out.Slides.Ext, &out.Slides.Size, &out.Slides.SHA256, &out.SlidesUpdatedAt,
|
|
119
|
+
&out.Version, &out.CreatedAt, &out.UpdatedAt)
|
|
120
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
121
|
+
return Idea{}, ErrNotFound
|
|
122
|
+
}
|
|
123
|
+
if err != nil {
|
|
124
|
+
return Idea{}, fmt.Errorf("select idea: %w", err)
|
|
125
|
+
}
|
|
126
|
+
if out.ID, err = uuid.Parse(sid); err != nil {
|
|
127
|
+
return Idea{}, fmt.Errorf("idea id: %w", err)
|
|
128
|
+
}
|
|
129
|
+
if out.LeaderID, err = uuid.Parse(lid); err != nil {
|
|
130
|
+
return Idea{}, fmt.Errorf("idea leader id: %w", err)
|
|
131
|
+
}
|
|
132
|
+
return out, nil
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ErrNoLeader is returned by LeaderName when the user row is gone, which a
|
|
136
|
+
// valid session rules out; the caller treats it as an internal error.
|
|
137
|
+
var ErrNoLeader = errors.New("leader account not found")
|
|
138
|
+
|
|
139
|
+
// LeaderName returns the account name of user id: the expected filename takes
|
|
140
|
+
// the Leader's name from the account, never from the form (PRD 6.6).
|
|
141
|
+
func LeaderName(ctx context.Context, q Querier, id uuid.UUID) (string, error) {
|
|
142
|
+
var name string
|
|
143
|
+
err := q.QueryRowContext(ctx, `SELECT name FROM app_user WHERE id = ?`, id.String()).Scan(&name)
|
|
144
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
145
|
+
return "", ErrNoLeader
|
|
146
|
+
}
|
|
147
|
+
if err != nil {
|
|
148
|
+
return "", fmt.Errorf("select leader name: %w", err)
|
|
149
|
+
}
|
|
150
|
+
return name, nil
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// NameTaken is the read-only duplicate pre-check of create: it saves an upload
|
|
154
|
+
// in the common case, while uq_idea_leader_name stays authoritative.
|
|
155
|
+
func NameTaken(ctx context.Context, q Querier, leader uuid.UUID, name string) (bool, error) {
|
|
156
|
+
var one int
|
|
157
|
+
err := q.QueryRowContext(ctx, `SELECT 1 FROM idea WHERE leader_id = ? AND name = ?`,
|
|
158
|
+
leader.String(), name).Scan(&one)
|
|
159
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
160
|
+
return false, nil
|
|
161
|
+
}
|
|
162
|
+
if err != nil {
|
|
163
|
+
return false, fmt.Errorf("select idea name: %w", err)
|
|
164
|
+
}
|
|
165
|
+
return true, nil
|
|
166
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
package idea
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"math"
|
|
5
|
+
"net/http"
|
|
6
|
+
"regexp"
|
|
7
|
+
"slices"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
11
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
12
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const (
|
|
16
|
+
defaultPageSize = 20
|
|
17
|
+
maxPageSize = 100
|
|
18
|
+
maxPage = math.MaxInt32 // keeps (page-1)*size far from int overflow
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// Closed value sets (PRD 6.6); the idea table's CHECKs list the same values.
|
|
22
|
+
var (
|
|
23
|
+
categories = []string{"COOL_IDEA", "OUTSTANDING_IMPACT"}
|
|
24
|
+
orgs = []string{"AAID", "BSID", "ICSD", "TSID", "PLED", "DEID"}
|
|
25
|
+
directions = []string{"GREEN_MANUFACTURING", "RESPONSIBLE_SUPPLY_CHAIN", "INCLUSIVE_WORKPLACE",
|
|
26
|
+
"TALENT_DEVELOPMENT", "CARING_FOR_DISADVANTAGED"}
|
|
27
|
+
sorts = []string{"createdAt", "name"}
|
|
28
|
+
order = []string{"asc", "desc"}
|
|
29
|
+
empNoFormat = regexp.MustCompile(`^[0-9]{6}$`)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
type summary struct {
|
|
33
|
+
ID string `json:"id"`
|
|
34
|
+
Name string `json:"name"`
|
|
35
|
+
Category string `json:"category"`
|
|
36
|
+
Org string `json:"org"`
|
|
37
|
+
Direction string `json:"direction"`
|
|
38
|
+
Leader person `json:"leader"`
|
|
39
|
+
MemberCount int `json:"memberCount"`
|
|
40
|
+
MyRelation any `json:"myRelation"`
|
|
41
|
+
CreatedAt string `json:"createdAt"`
|
|
42
|
+
SlidesUpdatedAt string `json:"slidesUpdatedAt"`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type page struct {
|
|
46
|
+
Items []summary `json:"items"`
|
|
47
|
+
Page int `json:"page"`
|
|
48
|
+
Size int `json:"size"`
|
|
49
|
+
Total int `json:"total"`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// listQuery is the validated query of GET /ideas.
|
|
53
|
+
type listQuery struct {
|
|
54
|
+
page, size int
|
|
55
|
+
filter ListFilter
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// parseList validates every parameter and reports all failures at once, in
|
|
59
|
+
// parameter order, each parameter with its first error (PRD 6.2, 6.6). No
|
|
60
|
+
// invalid value is ignored.
|
|
61
|
+
func parseList(p api.ListIdeasParams) (listQuery, []web.FieldError) {
|
|
62
|
+
q := listQuery{page: 1, size: defaultPageSize}
|
|
63
|
+
var errs []web.FieldError
|
|
64
|
+
bad := func(field string, code web.ErrorCode) {
|
|
65
|
+
errs = append(errs, web.FieldError{Field: field, Code: code})
|
|
66
|
+
}
|
|
67
|
+
if p.Page != nil {
|
|
68
|
+
if *p.Page < 1 || *p.Page > maxPage {
|
|
69
|
+
bad("page", web.CodeOutOfRange)
|
|
70
|
+
} else {
|
|
71
|
+
q.page = *p.Page
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if p.Size != nil {
|
|
75
|
+
if *p.Size < 1 || *p.Size > maxPageSize {
|
|
76
|
+
bad("size", web.CodeOutOfRange)
|
|
77
|
+
} else {
|
|
78
|
+
q.size = *p.Size
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
enum := func(field string, v *string, allowed []string, dst *string) {
|
|
82
|
+
if v == nil {
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if !slices.Contains(allowed, *v) {
|
|
86
|
+
bad(field, web.CodeInvalidEnum)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
*dst = *v
|
|
90
|
+
}
|
|
91
|
+
enum("org", p.Org, orgs, &q.filter.Org)
|
|
92
|
+
enum("category", p.Category, categories, &q.filter.Category)
|
|
93
|
+
enum("direction", p.Direction, directions, &q.filter.Direction)
|
|
94
|
+
if p.LeaderEmpNo != nil {
|
|
95
|
+
if !empNoFormat.MatchString(*p.LeaderEmpNo) {
|
|
96
|
+
bad("leaderEmpNo", web.CodeInvalidFormat)
|
|
97
|
+
} else {
|
|
98
|
+
q.filter.LeaderEmpNo = *p.LeaderEmpNo
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
instant := func(field string, v *string) *time.Time {
|
|
102
|
+
if v == nil {
|
|
103
|
+
return nil
|
|
104
|
+
}
|
|
105
|
+
// RFC 3339 requires the offset or Z, so a bare date or local time fails.
|
|
106
|
+
t, err := time.Parse(time.RFC3339, *v)
|
|
107
|
+
if err != nil {
|
|
108
|
+
bad(field, web.CodeInvalidFormat)
|
|
109
|
+
return nil
|
|
110
|
+
}
|
|
111
|
+
return &t
|
|
112
|
+
}
|
|
113
|
+
q.filter.CreatedFrom = instant("createdFrom", p.CreatedFrom)
|
|
114
|
+
q.filter.CreatedTo = instant("createdTo", p.CreatedTo)
|
|
115
|
+
if p.Q != nil {
|
|
116
|
+
q.filter.Q = *p.Q
|
|
117
|
+
}
|
|
118
|
+
sort := "createdAt"
|
|
119
|
+
enum("sort", p.Sort, sorts, &sort)
|
|
120
|
+
q.filter.SortName = sort == "name"
|
|
121
|
+
dir := "desc"
|
|
122
|
+
if q.filter.SortName {
|
|
123
|
+
dir = "asc"
|
|
124
|
+
}
|
|
125
|
+
enum("order", p.Order, order, &dir)
|
|
126
|
+
q.filter.Desc = dir == "desc"
|
|
127
|
+
return q, errs
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ListIdeas serves GET /ideas.
|
|
131
|
+
func (h Handler) ListIdeas(w http.ResponseWriter, r *http.Request, params api.ListIdeasParams) {
|
|
132
|
+
p, ok := security.PrincipalFrom(r.Context())
|
|
133
|
+
if !ok {
|
|
134
|
+
h.Fail(w, r, web.NewError(web.CodeUnauthenticated, "authentication required"))
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
q, errs := parseList(params)
|
|
138
|
+
if len(errs) > 0 {
|
|
139
|
+
e := web.NewError(web.CodeValidationFailed, "invalid query parameter")
|
|
140
|
+
e.Fields = errs
|
|
141
|
+
h.Fail(w, r, e)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
rows, total, err := h.Service.List(r.Context(), p, q.filter, q.size, (q.page-1)*q.size)
|
|
145
|
+
if err != nil {
|
|
146
|
+
h.Fail(w, r, err)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
out := page{Items: make([]summary, 0, len(rows)), Page: q.page, Size: q.size, Total: total}
|
|
150
|
+
for _, s := range rows {
|
|
151
|
+
var rel any // null for Admin
|
|
152
|
+
if !p.IsAdmin() {
|
|
153
|
+
rel = "MEMBER"
|
|
154
|
+
if s.LeaderID == p.UserID {
|
|
155
|
+
rel = "LEADER"
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
out.Items = append(out.Items, summary{
|
|
159
|
+
ID: s.ID.String(), Name: s.Name, Category: s.Category, Org: s.Org, Direction: s.Direction,
|
|
160
|
+
Leader: person{EmpNo: s.LeaderEmpNo, Name: s.LeaderName},
|
|
161
|
+
MemberCount: s.MemberCount, MyRelation: rel,
|
|
162
|
+
CreatedAt: web.FormatTime(s.CreatedAt), SlidesUpdatedAt: web.FormatTime(s.SlidesUpdatedAt),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
web.WriteJSON(w, http.StatusOK, out)
|
|
166
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
//go:build integration
|
|
2
|
+
|
|
3
|
+
package idea_test
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"fmt"
|
|
9
|
+
"net/http"
|
|
10
|
+
"net/http/httptest"
|
|
11
|
+
"net/url"
|
|
12
|
+
"slices"
|
|
13
|
+
"testing"
|
|
14
|
+
|
|
15
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
16
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/audit"
|
|
17
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/idea"
|
|
18
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/server"
|
|
19
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/dbtest"
|
|
20
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
func TestListIdeasHandler(t *testing.T) {
|
|
24
|
+
d := dbtest.New(t)
|
|
25
|
+
a := addUser(t, d, "100001", "USER")
|
|
26
|
+
b := addUser(t, d, "100002", "USER")
|
|
27
|
+
admin := addUser(t, d, "900001", "ADMIN")
|
|
28
|
+
|
|
29
|
+
// a owns 3 Ideas, b owns 1 (different org/category). created_at is set explicitly.
|
|
30
|
+
mk := func(leader security.Principal, name, org, cat, at string) string {
|
|
31
|
+
n := newIdea(leader.UserID, name)
|
|
32
|
+
n.Org, n.Category = org, cat
|
|
33
|
+
if err := insert(d, n); err != nil {
|
|
34
|
+
t.Fatal(err)
|
|
35
|
+
}
|
|
36
|
+
if _, err := d.Exec(`UPDATE idea SET created_at = ? WHERE id = ?`, at, n.ID.String()); err != nil {
|
|
37
|
+
t.Fatal(err)
|
|
38
|
+
}
|
|
39
|
+
return n.ID.String()
|
|
40
|
+
}
|
|
41
|
+
a1 := mk(a, "A1", "BSID", "COOL_IDEA", "2026-03-01 00:00:00.000000")
|
|
42
|
+
a2 := mk(a, "A2", "BSID", "COOL_IDEA", "2026-03-02 00:00:00.000000")
|
|
43
|
+
a3 := mk(a, "A3", "AAID", "OUTSTANDING_IMPACT", "2026-03-03 00:00:00.000000")
|
|
44
|
+
b1 := mk(b, "B1", "AAID", "COOL_IDEA", "2026-03-04 00:00:00.000000")
|
|
45
|
+
|
|
46
|
+
aud := audit.New(d, nil)
|
|
47
|
+
errs := server.ErrorAdapter{Audit: aud}
|
|
48
|
+
h := idea.Handler{Service: &idea.Service{DB: d, Audit: aud}, ExpectedFilename: expectedBase, Fail: errs.Write}
|
|
49
|
+
|
|
50
|
+
call := func(p security.Principal, query string) (int, map[string]any) {
|
|
51
|
+
v, _ := url.ParseQuery(query)
|
|
52
|
+
var params api.ListIdeasParams
|
|
53
|
+
get := func(k string) *string {
|
|
54
|
+
if !v.Has(k) {
|
|
55
|
+
return nil
|
|
56
|
+
}
|
|
57
|
+
s := v.Get(k)
|
|
58
|
+
return &s
|
|
59
|
+
}
|
|
60
|
+
geti := func(k string) *int {
|
|
61
|
+
if !v.Has(k) {
|
|
62
|
+
return nil
|
|
63
|
+
}
|
|
64
|
+
var n int
|
|
65
|
+
_, _ = fmt.Sscan(v.Get(k), &n)
|
|
66
|
+
return &n
|
|
67
|
+
}
|
|
68
|
+
params.Page, params.Size = geti("page"), geti("size")
|
|
69
|
+
params.Org, params.Category, params.Direction = get("org"), get("category"), get("direction")
|
|
70
|
+
params.LeaderEmpNo, params.CreatedFrom, params.CreatedTo = get("leaderEmpNo"), get("createdFrom"), get("createdTo")
|
|
71
|
+
params.Q, params.Sort, params.Order = get("q"), get("sort"), get("order")
|
|
72
|
+
req := httptest.NewRequest(http.MethodGet, "/ideas?"+query, nil)
|
|
73
|
+
req = req.WithContext(security.WithPrincipal(context.Background(), p))
|
|
74
|
+
rec := httptest.NewRecorder()
|
|
75
|
+
h.ListIdeas(rec, req, params)
|
|
76
|
+
var body map[string]any
|
|
77
|
+
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
78
|
+
return rec.Code, body
|
|
79
|
+
}
|
|
80
|
+
ids := func(body map[string]any) (out []string) {
|
|
81
|
+
for _, it := range body["items"].([]any) {
|
|
82
|
+
out = append(out, it.(map[string]any)["id"].(string))
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
eq := func(got, want []string) bool {
|
|
87
|
+
if len(got) != len(want) {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
for i := range got {
|
|
91
|
+
if got[i] != want[i] {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// User sees only own Ideas, newest first; myRelation LEADER; envelope.
|
|
99
|
+
code, body := call(a, "")
|
|
100
|
+
if code != 200 || !eq(ids(body), []string{a3, a2, a1}) || body["total"].(float64) != 3 ||
|
|
101
|
+
body["page"].(float64) != 1 || body["size"].(float64) != 20 {
|
|
102
|
+
t.Fatalf("user list: %d %v", code, body)
|
|
103
|
+
}
|
|
104
|
+
if rel := body["items"].([]any)[0].(map[string]any)["myRelation"]; rel != "LEADER" {
|
|
105
|
+
t.Errorf("myRelation = %v", rel)
|
|
106
|
+
}
|
|
107
|
+
// Admin sees all, myRelation null.
|
|
108
|
+
code, body = call(admin, "")
|
|
109
|
+
if code != 200 || !eq(ids(body), []string{b1, a3, a2, a1}) {
|
|
110
|
+
t.Fatalf("admin list: %d %v", code, body)
|
|
111
|
+
}
|
|
112
|
+
if rel, ok := body["items"].([]any)[0].(map[string]any)["myRelation"]; !ok || rel != nil {
|
|
113
|
+
t.Errorf("admin myRelation = %v", rel)
|
|
114
|
+
}
|
|
115
|
+
// A user with nothing gets [] not null.
|
|
116
|
+
if _, body = call(addUser(t, d, "100003", "USER"), ""); body["items"] == nil || body["total"].(float64) != 0 {
|
|
117
|
+
t.Errorf("empty: %v", body)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Paging: size 2, page 2; beyond last page is 200 and empty with total intact.
|
|
121
|
+
_, body = call(admin, "size=2&page=2")
|
|
122
|
+
if !eq(ids(body), []string{a2, a1}) || body["total"].(float64) != 4 {
|
|
123
|
+
t.Errorf("page 2: %v", body)
|
|
124
|
+
}
|
|
125
|
+
code, body = call(admin, "size=2&page=3")
|
|
126
|
+
if code != 200 || len(ids(body)) != 0 || body["total"].(float64) != 4 {
|
|
127
|
+
t.Errorf("page 3: %d %v", code, body)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Filters.
|
|
131
|
+
for q, want := range map[string][]string{
|
|
132
|
+
"org=BSID": {a2, a1},
|
|
133
|
+
"category=OUTSTANDING_IMPACT": {a3},
|
|
134
|
+
"leaderEmpNo=100002": {b1},
|
|
135
|
+
"org=AAID&category=COOL_IDEA": {b1},
|
|
136
|
+
"direction=TALENT_DEVELOPMENT": {b1, a3, a2, a1},
|
|
137
|
+
"direction=GREEN_MANUFACTURING": nil,
|
|
138
|
+
// [from, to) is half-open: 03-02 is included, 03-04 (== to) is not.
|
|
139
|
+
"createdFrom=2026-03-02T00:00:00Z&createdTo=2026-03-04T00:00:00Z": {a3, a2},
|
|
140
|
+
// Offset is honoured: 08:00+08:00 is 00:00Z.
|
|
141
|
+
"createdFrom=2026-03-04T08:00:00%2B08:00": {b1},
|
|
142
|
+
} {
|
|
143
|
+
if _, body = call(admin, q); !eq(ids(body), want) {
|
|
144
|
+
t.Errorf("%s: got %v want %v", q, ids(body), want)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Filters never widen visibility.
|
|
148
|
+
if _, body = call(a, "leaderEmpNo=100002"); len(ids(body)) != 0 {
|
|
149
|
+
t.Errorf("user saw another Leader's Idea: %v", body)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 422 with the right field code; top-level VALIDATION_FAILED.
|
|
153
|
+
for q, field := range map[string]string{
|
|
154
|
+
"size=101": "size", "page=0": "page", "org=XXXX": "org", "category=": "category",
|
|
155
|
+
"createdFrom=2026-03-01": "createdFrom", "leaderEmpNo=1": "leaderEmpNo",
|
|
156
|
+
"sort=id": "sort", "sort=": "sort", "order=up": "order", "order=ASC": "order",
|
|
157
|
+
} {
|
|
158
|
+
code, body = call(admin, q)
|
|
159
|
+
if code != 422 || body["code"] != "VALIDATION_FAILED" ||
|
|
160
|
+
body["errors"].([]any)[0].(map[string]any)["field"] != field {
|
|
161
|
+
t.Errorf("%s: %d %v", q, code, body)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if _, body = call(admin, "org=X"); body["errors"].([]any)[0].(map[string]any)["code"] != "INVALID_ENUM" {
|
|
165
|
+
t.Errorf("org code: %v", body)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func TestListIdeasSearchSort(t *testing.T) {
|
|
170
|
+
d := dbtest.New(t)
|
|
171
|
+
u := addUser(t, d, "100001", "USER")
|
|
172
|
+
admin := addUser(t, d, "900001", "ADMIN")
|
|
173
|
+
svc := &idea.Service{DB: d}
|
|
174
|
+
|
|
175
|
+
mk := func(name, at string) string {
|
|
176
|
+
n := newIdea(u.UserID, name)
|
|
177
|
+
if err := insert(d, n); err != nil {
|
|
178
|
+
t.Fatal(err)
|
|
179
|
+
}
|
|
180
|
+
if _, err := d.Exec(`UPDATE idea SET created_at = ? WHERE id = ?`, at, n.ID.String()); err != nil {
|
|
181
|
+
t.Fatal(err)
|
|
182
|
+
}
|
|
183
|
+
return n.ID.String()
|
|
184
|
+
}
|
|
185
|
+
pct := mk("100% green", "2026-03-01 00:00:00.000000")
|
|
186
|
+
und := mk("a_b", "2026-03-02 00:00:00.000000")
|
|
187
|
+
axb := mk("axb", "2026-03-03 00:00:00.000000")
|
|
188
|
+
bang := mk("Hi!", "2026-03-04 00:00:00.000000")
|
|
189
|
+
upper := mk("ZETA Solar", "2026-03-05 00:00:00.000000")
|
|
190
|
+
zh := mk("綠能創意", "2026-03-06 00:00:00.000000")
|
|
191
|
+
|
|
192
|
+
names := func(f idea.ListFilter) (out []string) {
|
|
193
|
+
rows, _, err := svc.List(context.Background(), admin, f, 100, 0)
|
|
194
|
+
if err != nil {
|
|
195
|
+
t.Fatal(err)
|
|
196
|
+
}
|
|
197
|
+
for _, r := range rows {
|
|
198
|
+
out = append(out, r.ID.String())
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
same := func(got, want []string) bool { return fmt.Sprint(got) == fmt.Sprint(want) }
|
|
203
|
+
|
|
204
|
+
for _, tc := range []struct {
|
|
205
|
+
q string
|
|
206
|
+
want []string
|
|
207
|
+
}{
|
|
208
|
+
{"%", []string{pct}}, // not a wildcard
|
|
209
|
+
{"_", []string{und}}, // not a wildcard: axb does not match
|
|
210
|
+
{"a_b", []string{und}}, // axb does not match
|
|
211
|
+
{"!", []string{bang}}, // the escape character itself is literal
|
|
212
|
+
{"zeta", []string{upper}}, // case-insensitive
|
|
213
|
+
{"SOLAR", []string{upper}},
|
|
214
|
+
{"綠能", []string{zh}},
|
|
215
|
+
{"nothing", nil},
|
|
216
|
+
{"", []string{zh, upper, bang, axb, und, pct}}, // empty term is no filter
|
|
217
|
+
} {
|
|
218
|
+
if got := names(idea.ListFilter{Q: tc.q, Desc: true}); !same(got, tc.want) {
|
|
219
|
+
t.Errorf("q=%q: got %v want %v", tc.q, got, tc.want)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// created_at both ways; name by code point (digits < upper < lower < CJK).
|
|
224
|
+
if got := names(idea.ListFilter{Desc: false}); !same(got, []string{pct, und, axb, bang, upper, zh}) {
|
|
225
|
+
t.Errorf("createdAt asc: %v", got)
|
|
226
|
+
}
|
|
227
|
+
if got := names(idea.ListFilter{SortName: true}); !same(got, []string{pct, bang, upper, und, axb, zh}) {
|
|
228
|
+
t.Errorf("name asc: %v", got)
|
|
229
|
+
}
|
|
230
|
+
if got := names(idea.ListFilter{SortName: true, Desc: true}); !same(got, []string{zh, axb, und, upper, bang, pct}) {
|
|
231
|
+
t.Errorf("name desc: %v", got)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Equal sort keys fall back to id (MariaDB UUID order, not text order), in the sort direction. Same created_at for all.
|
|
235
|
+
if _, err := d.Exec(`UPDATE idea SET created_at = '2026-04-01 00:00:00.000000'`); err != nil {
|
|
236
|
+
t.Fatal(err)
|
|
237
|
+
}
|
|
238
|
+
asc, desc := names(idea.ListFilter{}), names(idea.ListFilter{Desc: true})
|
|
239
|
+
if len(asc) != 6 {
|
|
240
|
+
t.Errorf("tie-break asc: %v", asc)
|
|
241
|
+
}
|
|
242
|
+
slices.Reverse(desc)
|
|
243
|
+
if !same(asc, desc) {
|
|
244
|
+
t.Errorf("tie-break desc is not the reverse of asc: %v vs %v", asc, desc)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// A User's search never reaches another Leader's Ideas.
|
|
248
|
+
other := addUser(t, d, "100002", "USER")
|
|
249
|
+
if rows, _, err := svc.List(context.Background(), other, idea.ListFilter{Q: "zeta"}, 10, 0); err != nil || len(rows) != 0 {
|
|
250
|
+
t.Errorf("search widened visibility: %v %v", rows, err)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
package idea
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/api"
|
|
8
|
+
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func ptr[T any](v T) *T { return &v }
|
|
12
|
+
|
|
13
|
+
func TestParseListDefaults(t *testing.T) {
|
|
14
|
+
q, errs := parseList(api.ListIdeasParams{})
|
|
15
|
+
if len(errs) != 0 || q.page != 1 || q.size != 20 {
|
|
16
|
+
t.Fatalf("q=%+v errs=%v", q, errs)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func TestParseListErrors(t *testing.T) {
|
|
21
|
+
tests := []struct {
|
|
22
|
+
name string
|
|
23
|
+
p api.ListIdeasParams
|
|
24
|
+
want []web.FieldError
|
|
25
|
+
}{
|
|
26
|
+
{"page 0", api.ListIdeasParams{Page: ptr(0)}, []web.FieldError{{Field: "page", Code: web.CodeOutOfRange}}},
|
|
27
|
+
{"size 101", api.ListIdeasParams{Size: ptr(101)}, []web.FieldError{{Field: "size", Code: web.CodeOutOfRange}}},
|
|
28
|
+
{"size 0", api.ListIdeasParams{Size: ptr(0)}, []web.FieldError{{Field: "size", Code: web.CodeOutOfRange}}},
|
|
29
|
+
{"bad org", api.ListIdeasParams{Org: ptr("XXXX")}, []web.FieldError{{Field: "org", Code: web.CodeInvalidEnum}}},
|
|
30
|
+
{"empty org not ignored", api.ListIdeasParams{Org: ptr("")}, []web.FieldError{{Field: "org", Code: web.CodeInvalidEnum}}},
|
|
31
|
+
{"lower-case category", api.ListIdeasParams{Category: ptr("cool_idea")}, []web.FieldError{{Field: "category", Code: web.CodeInvalidEnum}}},
|
|
32
|
+
{"bad direction", api.ListIdeasParams{Direction: ptr("X")}, []web.FieldError{{Field: "direction", Code: web.CodeInvalidEnum}}},
|
|
33
|
+
{"short emp no", api.ListIdeasParams{LeaderEmpNo: ptr("12345")}, []web.FieldError{{Field: "leaderEmpNo", Code: web.CodeInvalidFormat}}},
|
|
34
|
+
{"date only", api.ListIdeasParams{CreatedFrom: ptr("2026-01-01")}, []web.FieldError{{Field: "createdFrom", Code: web.CodeInvalidFormat}}},
|
|
35
|
+
{"no offset", api.ListIdeasParams{CreatedTo: ptr("2026-01-01T00:00:00")}, []web.FieldError{{Field: "createdTo", Code: web.CodeInvalidFormat}}},
|
|
36
|
+
{"all, in parameter order", api.ListIdeasParams{Size: ptr(500), Page: ptr(-1), Org: ptr("x"), CreatedTo: ptr("nope")},
|
|
37
|
+
[]web.FieldError{{Field: "page", Code: web.CodeOutOfRange}, {Field: "size", Code: web.CodeOutOfRange},
|
|
38
|
+
{Field: "org", Code: web.CodeInvalidEnum}, {Field: "createdTo", Code: web.CodeInvalidFormat}}},
|
|
39
|
+
}
|
|
40
|
+
for _, tc := range tests {
|
|
41
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
42
|
+
_, got := parseList(tc.p)
|
|
43
|
+
if len(got) != len(tc.want) {
|
|
44
|
+
t.Fatalf("got %v, want %v", got, tc.want)
|
|
45
|
+
}
|
|
46
|
+
for i := range got {
|
|
47
|
+
if got[i] != tc.want[i] {
|
|
48
|
+
t.Errorf("[%d] got %v, want %v", i, got[i], tc.want[i])
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestParseListValid(t *testing.T) {
|
|
56
|
+
q, errs := parseList(api.ListIdeasParams{
|
|
57
|
+
Page: ptr(3), Size: ptr(100), Org: ptr("BSID"), Category: ptr("COOL_IDEA"), Direction: ptr("TALENT_DEVELOPMENT"),
|
|
58
|
+
LeaderEmpNo: ptr("000123"), CreatedFrom: ptr("2026-01-01T08:00:00+08:00"), CreatedTo: ptr("2026-02-01T00:00:00.5Z"),
|
|
59
|
+
})
|
|
60
|
+
if len(errs) != 0 {
|
|
61
|
+
t.Fatal(errs)
|
|
62
|
+
}
|
|
63
|
+
if q.page != 3 || q.size != 100 || q.filter.Org != "BSID" || q.filter.LeaderEmpNo != "000123" {
|
|
64
|
+
t.Errorf("q = %+v", q)
|
|
65
|
+
}
|
|
66
|
+
if !q.filter.CreatedFrom.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
|
67
|
+
t.Errorf("from = %v", q.filter.CreatedFrom)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func TestParseListSortOrder(t *testing.T) {
|
|
72
|
+
for _, tc := range []struct {
|
|
73
|
+
sort, order *string
|
|
74
|
+
name, desc bool
|
|
75
|
+
}{
|
|
76
|
+
{nil, nil, false, true},
|
|
77
|
+
{ptr("createdAt"), nil, false, true},
|
|
78
|
+
{ptr("createdAt"), ptr("asc"), false, false},
|
|
79
|
+
{ptr("name"), nil, true, false},
|
|
80
|
+
{ptr("name"), ptr("desc"), true, true},
|
|
81
|
+
{nil, ptr("asc"), false, false},
|
|
82
|
+
} {
|
|
83
|
+
q, errs := parseList(api.ListIdeasParams{Sort: tc.sort, Order: tc.order})
|
|
84
|
+
if len(errs) != 0 || q.filter.SortName != tc.name || q.filter.Desc != tc.desc {
|
|
85
|
+
t.Errorf("%v %v: %+v %v", tc.sort, tc.order, q.filter, errs)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
_, errs := parseList(api.ListIdeasParams{Sort: ptr("id"), Order: ptr("DESC"), Q: ptr("x")})
|
|
89
|
+
if len(errs) != 2 || errs[0] != (web.FieldError{Field: "sort", Code: web.CodeInvalidEnum}) ||
|
|
90
|
+
errs[1] != (web.FieldError{Field: "order", Code: web.CodeInvalidEnum}) {
|
|
91
|
+
t.Errorf("errs = %v", errs)
|
|
92
|
+
}
|
|
93
|
+
if q, _ := parseList(api.ListIdeasParams{Q: ptr("50%_")}); q.filter.Q != "50%_" {
|
|
94
|
+
t.Errorf("q = %q", q.filter.Q)
|
|
95
|
+
}
|
|
96
|
+
}
|